package classes import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "slices" "strings" "time" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) const DefaultAPITokenPrefix = "usr_" var ( DefaultAPITokenScopes = []string{"read"} AllowedAPITokenScopes = []string{"read", "write", "ai"} ) // APITokenPrefix returns the configured on-wire prefix. Applications may keep // a legacy prefix while every plugin still consumes the same token table. func APITokenPrefix(app *backpack.App) (string, error) { prefix := DefaultAPITokenPrefix if app != nil && app.Config != nil { if configured := strings.TrimSpace(app.Config.String("golem15.user.api_tokens.prefix")); configured != "" { prefix = configured } } if len(prefix) < 2 || len(prefix) > 16 { return "", fmt.Errorf("golem15.user: api_tokens.prefix must contain 2 to 16 safe characters") } for _, r := range prefix { if !(r == '_' || r == '-' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { return "", fmt.Errorf("golem15.user: api_tokens.prefix contains an unsafe character") } } return prefix, nil } // MintAPIToken builds a token record and returns its one-time clear-text // secret. A nil scopes slice receives DefaultAPITokenScopes. func MintAPIToken(prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error) { name = strings.TrimSpace(name) if userID == 0 || name == "" || len(name) > 255 { return "", nil, fmt.Errorf("a user and token name of 1 to 255 characters are required") } if prefix == "" { prefix = DefaultAPITokenPrefix } if scopes == nil { scopes = slices.Clone(DefaultAPITokenScopes) } else { scopes = slices.Clone(scopes) } for _, scope := range scopes { if !slices.Contains(AllowedAPITokenScopes, scope) { return "", nil, fmt.Errorf("scope %q is not allowed", scope) } } seenScopes := make(map[string]bool, len(scopes)) uniqueScopes := make([]string, 0, len(scopes)) for _, scope := range scopes { if !seenScopes[scope] { seenScopes[scope] = true uniqueScopes = append(uniqueScopes, scope) } } scopes = uniqueScopes raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", nil, err } secret := prefix + base64.RawURLEncoding.EncodeToString(raw) token := &models.APIToken{ UserID: userID, Name: &name, TokenHash: HashAPIToken(secret), Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, ExpiresAt: expiresAt, } if len(collectionIDs) > 0 { collectionIDs = slices.Compact(slices.Sorted(slices.Values(collectionIDs))) token.CollectionIDs = lagoon.Jsonable[[]uint]{Data: collectionIDs, Valid: true} } return secret, token, nil } // HashAPIToken returns the only representation of a token secret stored in // the database. func HashAPIToken(secret string) string { sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } // RevokeAPIToken invalidates token immediately. func RevokeAPIToken(db *gorm.DB, token *models.APIToken) error { if db == nil || token == nil { return fmt.Errorf("golem15.user: token is missing") } now := time.Now() if err := db.Model(token).Update("revoked_at", now).Error; err != nil { return err } token.RevokedAt = &now return nil }