diff --git a/api/user.go b/api/user.go index f6de76c..f426a54 100644 --- a/api/user.go +++ b/api/user.go @@ -24,6 +24,7 @@ type UserManager interface { GetUser(id string) *User AddUser(*User) error RemoveUser(id string) error + ForEachUser(cb func(*User) error) error UpdateUserPassword(username string, password string) error UpdateUserPermissions(username string, permissions PermissionFlag) error diff --git a/api/v0/auth.go b/api/v0/auth.go index 0af1303..f16dce1 100644 --- a/api/v0/auth.go +++ b/api/v0/auth.go @@ -281,215 +281,3 @@ func (h *Handler) routeLogout(rw http.ResponseWriter, req *http.Request) { h.tokens.InvalidToken(tid) rw.WriteHeader(http.StatusNoContent) } - -// var ( -// ErrUnsupportAuthType = errors.New("unsupported authorization type") -// ErrScopeNotMatch = errors.New("scope not match") -// ErrJTINotExists = errors.New("jti not exists") - -// ErrStrictPathNotMatch = errors.New("strict path not match") -// ErrStrictQueryNotMatch = errors.New("strict query value not match") -// ) - -// func (cr *Cluster) getJWTKey(t *jwt.Token) (any, error) { -// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { -// return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) -// } -// return cr.apiHmacKey, nil -// } - -// const ( -// challengeTokenScope = "GOBA-challenge" -// authTokenScope = "GOBA-auth" -// apiTokenScope = "GOBA-API" -// ) - -// type challengeTokenClaims struct { -// jwt.RegisteredClaims - -// Scope string `json:"scope"` -// Action string `json:"act"` -// } - -// func (cr *Cluster) generateChallengeToken(cliId string, action string) (string, error) { -// now := time.Now() -// exp := now.Add(time.Minute * 1) -// token := jwt.NewWithClaims(jwt.SigningMethodHS256, &challengeTokenClaims{ -// RegisteredClaims: jwt.RegisteredClaims{ -// Subject: cliId, -// Issuer: cr.jwtIssuer, -// IssuedAt: jwt.NewNumericDate(now), -// ExpiresAt: jwt.NewNumericDate(exp), -// }, -// Scope: challengeTokenScope, -// Action: action, -// }) -// tokenStr, err := token.SignedString(cr.apiHmacKey) -// if err != nil { -// return "", err -// } -// return tokenStr, nil -// } - -// func (cr *Cluster) verifyChallengeToken(cliId string, action string, token string) (err error) { -// var claims challengeTokenClaims -// if _, err = jwt.ParseWithClaims( -// token, -// &claims, -// cr.getJWTKey, -// jwt.WithSubject(cliId), -// jwt.WithIssuedAt(), -// jwt.WithIssuer(cr.jwtIssuer), -// ); err != nil { -// return -// } -// if claims.Scope != challengeTokenScope { -// return ErrScopeNotMatch -// } -// if claims.Action != action { -// return ErrJTINotExists -// } -// return -// } - -// type authTokenClaims struct { -// jwt.RegisteredClaims - -// Scope string `json:"scope"` -// User string `json:"usr"` -// } - -// func (cr *Cluster) generateAuthToken(cliId string, userId string) (string, error) { -// jti, err := utils.GenRandB64(16) -// if err != nil { -// return "", err -// } -// now := time.Now() -// exp := now.Add(time.Hour * 24) -// token := jwt.NewWithClaims(jwt.SigningMethodHS256, &authTokenClaims{ -// RegisteredClaims: jwt.RegisteredClaims{ -// ID: jti, -// Subject: cliId, -// Issuer: cr.jwtIssuer, -// IssuedAt: jwt.NewNumericDate(now), -// ExpiresAt: jwt.NewNumericDate(exp), -// }, -// Scope: authTokenScope, -// User: userId, -// }) -// tokenStr, err := token.SignedString(cr.apiHmacKey) -// if err != nil { -// return "", err -// } -// if err = cr.database.AddJTI(jti, exp); err != nil { -// return "", err -// } -// return tokenStr, nil -// } - -// func (cr *Cluster) verifyAuthToken(cliId string, token string) (id string, user string, err error) { -// var claims authTokenClaims -// if _, err = jwt.ParseWithClaims( -// token, -// &claims, -// cr.getJWTKey, -// jwt.WithSubject(cliId), -// jwt.WithIssuedAt(), -// jwt.WithIssuer(cr.jwtIssuer), -// ); err != nil { -// return -// } -// if claims.Scope != authTokenScope { -// err = ErrScopeNotMatch -// return -// } -// if user = claims.User; user == "" { -// // reject old token -// err = ErrJTINotExists -// return -// } -// id = claims.ID -// if ok, _ := cr.database.ValidJTI(id); !ok { -// err = ErrJTINotExists -// return -// } -// return -// } - -// type apiTokenClaims struct { -// jwt.RegisteredClaims - -// Scope string `json:"scope"` -// User string `json:"usr"` -// StrictPath string `json:"str-p"` -// StrictQuery map[string]string `json:"str-q,omitempty"` -// } - -// func (cr *Cluster) generateAPIToken(cliId string, userId string, path string, query map[string]string) (string, error) { -// jti, err := utils.GenRandB64(8) -// if err != nil { -// return "", err -// } -// now := time.Now() -// exp := now.Add(time.Minute * 10) -// token := jwt.NewWithClaims(jwt.SigningMethodHS256, &apiTokenClaims{ -// RegisteredClaims: jwt.RegisteredClaims{ -// ID: jti, -// Subject: cliId, -// Issuer: cr.jwtIssuer, -// IssuedAt: jwt.NewNumericDate(now), -// ExpiresAt: jwt.NewNumericDate(exp), -// }, -// Scope: apiTokenScope, -// User: userId, -// StrictPath: path, -// StrictQuery: query, -// }) -// tokenStr, err := token.SignedString(cr.apiHmacKey) -// if err != nil { -// return "", err -// } -// if err = cr.database.AddJTI(jti, exp); err != nil { -// return "", err -// } -// return tokenStr, nil -// } - -// func (h *Handler) verifyAPIToken(cliId string, token string, path string, query url.Values) (id string, user string, err error) { -// var claims apiTokenClaims -// _, err = jwt.ParseWithClaims( -// token, -// &claims, -// cr.getJWTKey, -// jwt.WithSubject(cliId), -// jwt.WithIssuedAt(), -// jwt.WithIssuer(cr.jwtIssuer), -// ) -// if err != nil { -// return -// } -// if claims.Scope != apiTokenScope { -// err = ErrScopeNotMatch -// return -// } -// if user = claims.User; user == "" { -// err = ErrJTINotExists -// return -// } -// id = claims.ID -// if ok, _ := cr.database.ValidJTI(id); !ok { -// err = ErrJTINotExists -// return -// } -// if claims.StrictPath != path { -// err = ErrStrictPathNotMatch -// return -// } -// for k, v := range claims.StrictQuery { -// if query.Get(k) != v { -// err = ErrStrictQueryNotMatch -// return -// } -// } -// return -// } diff --git a/database/db.go b/database/db.go index 594b777..8bca346 100644 --- a/database/db.go +++ b/database/db.go @@ -51,6 +51,15 @@ type DB interface { // the callback should not edit the record pointer ForEachFileRecord(cb func(*FileRecord) error) error + // GetUsers() []*api.User + // GetUser(id string) *api.User + // AddUser(*api.User) error + // RemoveUser(id string) error + // ForEachUser(cb func(*api.User) error) error + // UpdateUserPassword(username string, password string) error + // UpdateUserPermissions(username string, permissions api.PermissionFlag) error + // VerifyUserPassword(userId string, comparator func(password string) bool) error + GetSubscribe(user string, client string) (*api.SubscribeRecord, error) SetSubscribe(api.SubscribeRecord) error RemoveSubscribe(user string, client string) error diff --git a/notify/webhook/webhook.go b/notify/webhook/webhook.go index 44b8b02..1fa637e 100644 --- a/notify/webhook/webhook.go +++ b/notify/webhook/webhook.go @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -package webpush +package webhook import ( "bytes" diff --git a/runner.go b/runner.go index 7c92a5f..41251c2 100644 --- a/runner.go +++ b/runner.go @@ -53,8 +53,11 @@ import ( "github.com/LiterMC/go-openbmclapi/limited" "github.com/LiterMC/go-openbmclapi/log" "github.com/LiterMC/go-openbmclapi/notify" + "github.com/LiterMC/go-openbmclapi/notify/email" + "github.com/LiterMC/go-openbmclapi/notify/webhook" "github.com/LiterMC/go-openbmclapi/notify/webpush" "github.com/LiterMC/go-openbmclapi/storage" + "github.com/LiterMC/go-openbmclapi/token" "github.com/LiterMC/go-openbmclapi/utils" ) @@ -104,21 +107,48 @@ func NewRunner() *Runner { r.database = database.NewMemoryDB() } else if r.database, err = database.NewSqlDB(r.Config.Database.Driver, r.Config.Database.DSN); err != nil { log.Errorf("Cannot connect to database: %v", err) + os.Exit(1) } } // r.userManager = - // r.tokenManager = - webpushPlg := new(webpush.Plugin) - r.subManager = &subscriptionManager{ - webpushPlg: webpushPlg, - DB: r.database, - } - r.notifyManager = notify.NewManager(dataDir, r.database, r.client.CachedClient(), "go-openbmclapi") - r.storageManager = storage.NewManager(storages) + if apiHMACKey, err := utils.LoadOrCreateHmacKey(dataDir, "server"); err != nil { + log.Errorf("Cannot load HMAC key: %v", err) + os.Exit(1) + } else { + r.tokenManager = token.NewDBManager("go-openbmclapi", apiHMACKey, r.database) + } + { + r.notifyManager = notify.NewManager(dataDir, r.database, r.client.CachedClient(), "go-openbmclapi") + r.notifyManager.AddPlugin(new(webhook.Plugin)) + if r.Config.Notification.EnableEmail { + emailPlg, err := email.NewSMTP(r.Config.Notification.EmailSMTP, r.Config.Notification.EmailSMTPEncryption, + r.Config.Notification.EmailSender, r.Config.Notification.EmailSenderPassword) + if err != nil { + log.Errorf("Cannot init SMTP client: %v", err) + os.Exit(1) + } + r.notifyManager.AddPlugin(emailPlg) + } + r.notifyManager.AddPlugin(new(email.Plugin)) + webpushPlg := new(webpush.Plugin) + r.notifyManager.AddPlugin(webpushPlg) + + r.subManager = &subscriptionManager{ + webpushPlg: webpushPlg, + DB: r.database, + } + } + { + storages := make([]storage.Storage, len(r.Config.Storages)) + for i, s := range r.Config.Storages { + storages[i] = storage.NewStorage(s) + } + r.storageManager = storage.NewManager(storages) + } r.statManager = cluster.NewStatManager() if err := r.statManager.Load(dataDir); err != nil { - log.Errorf("Stat load failed:", err) + log.Errorf("Stat load failed: %v", err) } r.apiRateLimiter = limited.NewAPIRateMiddleWare(api.RealAddrCtxKey, "go-openbmclapi.cluster.logged.user" /* api/v0.loggedUserKey */) return r diff --git a/token/package.go b/token/package.go new file mode 100644 index 0000000..470830e --- /dev/null +++ b/token/package.go @@ -0,0 +1,256 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2023 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package token + +import ( + "errors" + "fmt" + "net/url" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/LiterMC/go-openbmclapi/utils" +) + +var ( + ErrUnsupportAuthType = errors.New("unsupported authorization type") + ErrScopeNotMatch = errors.New("scope not match") + ErrJTINotExists = errors.New("jti not exists") + + ErrStrictPathNotMatch = errors.New("strict path not match") + ErrStrictQueryNotMatch = errors.New("strict query value not match") +) + +const ( + challengeTokenScope = "GOBA-challenge" + authTokenScope = "GOBA-auth" + apiTokenScope = "GOBA-API" +) + +type ( + basicTokenManager struct { + impl basicTokenManagerImpl + } + basicTokenManagerImpl interface { + Issuer() string + HmacKey() []byte + AddJTI(string, time.Time) error + ValidJTI(string) bool + } +) + +type ( + challengeTokenClaims struct { + jwt.RegisteredClaims + + Scope string `json:"scope"` + Action string `json:"act"` + } + + authTokenClaims struct { + jwt.RegisteredClaims + + Scope string `json:"scope"` + User string `json:"usr"` + } + + apiTokenClaims struct { + jwt.RegisteredClaims + + Scope string `json:"scope"` + User string `json:"usr"` + StrictPath string `json:"str-p"` + StrictQuery map[string]string `json:"str-q,omitempty"` + } +) + +func (m *basicTokenManager) getJWTKey(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) + } + return m.impl.HmacKey(), nil +} + +func (m *basicTokenManager) GenerateChallengeToken(cliId string, action string) (string, error) { + now := time.Now() + exp := now.Add(time.Minute * 1) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &challengeTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: cliId, + Issuer: m.impl.Issuer(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(exp), + }, + Scope: challengeTokenScope, + Action: action, + }) + tokenStr, err := token.SignedString(m.impl.HmacKey()) + if err != nil { + return "", err + } + return tokenStr, nil +} + +func (m *basicTokenManager) VerifyChallengeToken(cliId string, action string, token string) (err error) { + var claims challengeTokenClaims + if _, err = jwt.ParseWithClaims( + token, + &claims, + m.getJWTKey, + jwt.WithSubject(cliId), + jwt.WithIssuedAt(), + jwt.WithIssuer(m.impl.Issuer()), + ); err != nil { + return + } + if claims.Scope != challengeTokenScope { + return ErrScopeNotMatch + } + if claims.Action != action { + return ErrJTINotExists + } + return +} + +func (m *basicTokenManager) GenerateAuthToken(cliId string, userId string) (string, error) { + jti, err := utils.GenRandB64(16) + if err != nil { + return "", err + } + now := time.Now() + exp := now.Add(time.Hour * 24) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &authTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + Subject: cliId, + Issuer: m.impl.Issuer(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(exp), + }, + Scope: authTokenScope, + User: userId, + }) + tokenStr, err := token.SignedString(m.impl.HmacKey()) + if err != nil { + return "", err + } + if err = m.impl.AddJTI(jti, exp); err != nil { + return "", err + } + return tokenStr, nil +} + +func (m *basicTokenManager) VerifyAuthToken(cliId string, token string) (id string, user string, err error) { + var claims authTokenClaims + if _, err = jwt.ParseWithClaims( + token, + &claims, + m.getJWTKey, + jwt.WithSubject(cliId), + jwt.WithIssuedAt(), + jwt.WithIssuer(m.impl.Issuer()), + ); err != nil { + return + } + if claims.Scope != authTokenScope { + err = ErrScopeNotMatch + return + } + if user = claims.User; user == "" { + // reject old token + err = ErrJTINotExists + return + } + id = claims.ID + if ok := m.impl.ValidJTI(id); !ok { + err = ErrJTINotExists + return + } + return +} + +func (m *basicTokenManager) GenerateAPIToken(cliId string, userId string, path string, query map[string]string) (string, error) { + jti, err := utils.GenRandB64(8) + if err != nil { + return "", err + } + now := time.Now() + exp := now.Add(time.Minute * 10) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &apiTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + Subject: cliId, + Issuer: m.impl.Issuer(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(exp), + }, + Scope: apiTokenScope, + User: userId, + StrictPath: path, + StrictQuery: query, + }) + tokenStr, err := token.SignedString(m.impl.HmacKey()) + if err != nil { + return "", err + } + if err = m.impl.AddJTI(jti, exp); err != nil { + return "", err + } + return tokenStr, nil +} + +func (m *basicTokenManager) VerifyAPIToken(cliId string, token string, path string, query url.Values) (user string, err error) { + var claims apiTokenClaims + _, err = jwt.ParseWithClaims( + token, + &claims, + m.getJWTKey, + jwt.WithSubject(cliId), + jwt.WithIssuedAt(), + jwt.WithIssuer(m.impl.Issuer()), + ) + if err != nil { + return + } + if claims.Scope != apiTokenScope { + err = ErrScopeNotMatch + return + } + if user = claims.User; user == "" { + err = ErrJTINotExists + return + } + if ok := m.impl.ValidJTI(claims.ID); !ok { + err = ErrJTINotExists + return + } + if claims.StrictPath != path { + err = ErrStrictPathNotMatch + return + } + for k, v := range claims.StrictQuery { + if query.Get(k) != v { + err = ErrStrictQueryNotMatch + return + } + } + return +} diff --git a/token/token_db.go b/token/token_db.go new file mode 100644 index 0000000..f9e507c --- /dev/null +++ b/token/token_db.go @@ -0,0 +1,67 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2023 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package token + +import ( + "time" + + "github.com/LiterMC/go-openbmclapi/api" + "github.com/LiterMC/go-openbmclapi/database" +) + +type DBManager struct { + basicTokenManager + db database.DB + issuer string + apiHmacKey []byte +} + +var _ api.TokenManager = (*DBManager)(nil) + +func NewDBManager(issuer string, apiHmacKey []byte, db database.DB) *DBManager { + m := &DBManager{ + db: db, + issuer: issuer, + apiHmacKey: apiHmacKey, + } + m.basicTokenManager.impl = m + return m +} + +func (m *DBManager) Issuer() string { + return m.issuer +} + +func (m *DBManager) HmacKey() []byte { + return m.apiHmacKey +} + +func (m *DBManager) AddJTI(id string, expire time.Time) error { + return m.db.AddJTI(id, expire) +} + +func (m *DBManager) ValidJTI(id string) bool { + ok, _ := m.db.ValidJTI(id) + return ok +} + +func (m *DBManager) InvalidToken(id string) error { + return m.db.RemoveJTI(id) +} diff --git a/utils/crypto.go b/utils/crypto.go index 40f7084..d1fe2bc 100644 --- a/utils/crypto.go +++ b/utils/crypto.go @@ -81,8 +81,8 @@ func GenRandB64(n int) (s string, err error) { return } -func LoadOrCreateHmacKey(dataDir string) (key []byte, err error) { - path := filepath.Join(dataDir, "server.hmac.private_key") +func LoadOrCreateHmacKey(dataDir string, name string) (key []byte, err error) { + path := filepath.Join(dataDir, name + ".hmac.private_key") buf, err := os.ReadFile(path) if err != nil { if !errors.Is(err, os.ErrNotExist) {