package middleware import ( "errors" "net/http" "strings" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) var errInvalidAPIToken = errors.New("invalid API token") // APITokenGuard authenticates the shared golem15.user API token and stores // both its user principal and *models.APIToken credential on the request. type APITokenGuard struct { App *backpack.App DB func() *gorm.DB Prefix string ClientIP func(*http.Request) string } var ( _ bouncer.CredentialGuard = (*APITokenGuard)(nil) _ bouncer.UnauthorizedWriter = (*APITokenGuard)(nil) ) func (g *APITokenGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) { if g == nil || r == nil { return nil, nil, errInvalidAPIToken } prefix := g.Prefix if prefix == "" { var err error prefix, err = classes.APITokenPrefix(g.App) if err != nil { return nil, nil, errInvalidAPIToken } } secret := BearerToken(r) if secret == "" || !strings.HasPrefix(secret, prefix) { return nil, nil, errInvalidAPIToken } var db *gorm.DB if g.DB != nil { db = g.DB() } else if g.App != nil { db, _ = g.App.Lookup[*gorm.DB]() } if db == nil { return nil, nil, errInvalidAPIToken } db = db.WithContext(r.Context()) var token models.APIToken result := db.Where("token_hash = ?", classes.HashAPIToken(secret)).Limit(1).Find(&token) now := time.Now() if result.Error != nil || result.RowsAffected == 0 || !token.IsUsableAt(now) { return nil, nil, errInvalidAPIToken } principal, err := classes.PrincipalByID(r.Context(), db, token.UserID) if err != nil || principal == nil { return nil, nil, errInvalidAPIToken } used := map[string]any{"last_used_at": now} if g.ClientIP != nil { if ip := strings.TrimSpace(g.ClientIP(r)); ip != "" { used["last_used_ip"] = ip token.LastUsedIP = &ip } } if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumns(used).Error; err != nil { return nil, nil, errInvalidAPIToken } token.LastUsedAt = &now return principal, &token, nil } func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."}) } // Scope requires one delegated scope from the shared token credential. func Scope(scope string) pact.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { credential, _ := bouncer.Credential(r.Context()) token, ok := credential.(*models.APIToken) if !ok || token == nil || !token.HasScope(scope) { wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope}) return } next.ServeHTTP(w, r) }) } } // Permission requires one current user-group permission. Tokens never carry // a permission snapshot: revoking the user's grant takes effect immediately. func Permission(code string) pact.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, _ := bouncer.User(r.Context()) if !cabana.Allows(principal, []string{code}) { wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."}) return } next.ServeHTTP(w, r) }) } } // BearerToken returns the Authorization bearer value or an empty string. func BearerToken(r *http.Request) string { if r == nil { return "" } token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ") if !ok { return "" } return strings.TrimSpace(token) }