Files
summercms/modules/cabana/api_tokens.go
Jakub Zych ffdb5d00ca feat(16.3-01): account token API with OpenAPI and phrase keys
- Add list/create/revoke/scopes handlers, validation, and soft revoke
- Regenerate admin OpenAPI and TS types; inventory and conformance cases
- Add server-side api_tokens phrase keys (en/pl)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:51:11 +02:00

291 lines
9.3 KiB
Go

package cabana
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// APITokenPrefix is the fixed plaintext prefix of every personal admin API token.
const APITokenPrefix = "g15_"
// BackendAPIToken is one personal admin API token row. Only the sha256 hex of
// the plaintext and a four-character hint are stored; the plaintext is never
// persisted.
type BackendAPIToken struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
BackendUserID uint `gorm:"column:backend_user_id" json:"backend_user_id"`
Name string `gorm:"column:name" json:"name"`
TokenHash string `gorm:"column:token_hash" json:"-"`
TokenHint string `gorm:"column:token_hint" json:"hint"`
Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes" json:"scopes"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"last_used_at"`
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"`
RevokedBy *uint `gorm:"column:revoked_by" json:"revoked_by"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
// TableName is the framework-owned personal API token table.
func (BackendAPIToken) TableName() string { return "backend_api_tokens" }
func mintAPIToken() (plain, hash, hint string, err error) {
var b [20]byte
if _, err = rand.Read(b[:]); err != nil {
return "", "", "", err
}
plain = APITokenPrefix + hex.EncodeToString(b[:])
hash = hashAPIToken(plain)
hint = plain[len(plain)-4:]
return plain, hash, hint, nil
}
func hashAPIToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
func wellFormedAPIToken(v string) bool {
if len(v) != len(APITokenPrefix)+40 || !strings.HasPrefix(v, APITokenPrefix) {
return false
}
for _, c := range v[len(APITokenPrefix):] {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// cleanedTokenRequest is the validated create payload.
type cleanedTokenRequest struct {
Name string
ExpiresAt *time.Time
Scopes []string
}
// validateTokenRequest applies D-08/D-17 name, expiry and scope rules with no
// database access. details maps field names to message keys under
// backend::lang (for example api_tokens.name_required). cli enables YYYY-MM-DD
// expiry dates (00:00 UTC, must be after now).
func validateTokenRequest(reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (cleanedTokenRequest, map[string]any) {
details := map[string]any{}
name = strings.TrimSpace(name)
if name == "" {
details["name"] = []string{"api_tokens.name_required"}
} else if len([]rune(name)) > 100 {
details["name"] = []string{"api_tokens.name_too_long"}
}
expiresAt, expKey := parseTokenExpiry(expires, now, cli)
if expKey != "" {
details["expires"] = []string{expKey}
}
cleanedScopes, scopeDetails := validateTokenScopes(reg, owner, scopes)
for k, v := range scopeDetails {
details[k] = v
}
if len(details) > 0 {
return cleanedTokenRequest{}, details
}
return cleanedTokenRequest{Name: name, ExpiresAt: expiresAt, Scopes: cleanedScopes}, nil
}
func parseTokenExpiry(expires string, now time.Time, cli bool) (*time.Time, string) {
expires = strings.TrimSpace(expires)
if expires == "" {
expires = "90d"
}
switch expires {
case "30d":
t := now.UTC().Add(30 * 24 * time.Hour)
return &t, ""
case "90d":
t := now.UTC().Add(90 * 24 * time.Hour)
return &t, ""
case "365d":
t := now.UTC().Add(365 * 24 * time.Hour)
return &t, ""
case "never":
return nil, ""
}
if cli {
if t, err := time.Parse("2006-01-02", expires); err == nil {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
if !t.After(now.UTC()) {
return nil, "api_tokens.expires_invalid"
}
return &t, ""
}
}
return nil, "api_tokens.expires_invalid"
}
func validateTokenScopes(reg *Registry, owner *bouncer.Principal, scopes []string) ([]string, map[string]any) {
details := map[string]any{}
if len(scopes) == 0 {
details["scopes"] = []string{"api_tokens.scopes_required"}
return nil, details
}
for _, s := range scopes {
if s == scopeAll {
return []string{scopeAll}, nil
}
}
seen := map[string]bool{}
out := make([]string, 0, len(scopes))
for _, name := range scopes {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
if reg == nil || reg.tokenScopes == nil || reg.tokenScopes.byName[name] == nil || !scopeOverlaps(reg, owner, name) {
details["scopes"] = []string{"api_tokens.scope_unavailable|" + name}
return nil, details
}
out = append(out, name)
}
if len(out) == 0 {
details["scopes"] = []string{"api_tokens.scopes_required"}
return nil, details
}
return out, nil
}
// issueAPIToken validates and inserts a token for the owner. On validation
// failure details is non-nil and plain/row are zero. The plaintext is returned
// exactly once on success.
func issueAPIToken(db *gorm.DB, reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (plain string, row *BackendAPIToken, details map[string]any, err error) {
if owner == nil || owner.ID == 0 {
return "", nil, nil, fmt.Errorf("cabana: issue API token: missing owner")
}
cleaned, details := validateTokenRequest(reg, owner, name, expires, scopes, now, cli)
if details != nil {
return "", nil, details, nil
}
plain, row, err = insertAPIToken(db, owner.ID, cleaned.Name, cleaned.Scopes, cleaned.ExpiresAt)
return plain, row, nil, err
}
// insertAPIToken mints and persists a row without validation (tests and the
// validated issueAPIToken path).
func insertAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) {
if db == nil || ownerID == 0 {
return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner")
}
if name == "" {
name = "token"
}
if len(scopes) == 0 {
return "", nil, fmt.Errorf("cabana: issue API token: scopes required")
}
plain, hash, hint, err := mintAPIToken()
if err != nil {
return "", nil, err
}
row = &BackendAPIToken{
BackendUserID: ownerID,
Name: name,
TokenHash: hash,
TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
ExpiresAt: expiresAt,
}
if err := db.Create(row).Error; err != nil {
return "", nil, err
}
return plain, row, nil
}
// revokeAPIToken soft-revokes the owner's active token. notFound is true when
// no matching active row was updated.
func revokeAPIToken(db *gorm.DB, ownerID, id uint, revokedBy *uint) (row *BackendAPIToken, notFound bool, err error) {
if db == nil || ownerID == 0 || id == 0 {
return nil, true, nil
}
now := time.Now().UTC()
res := db.Model(&BackendAPIToken{}).
Where("id = ? AND backend_user_id = ? AND revoked_at IS NULL", id, ownerID).
Updates(map[string]any{"revoked_at": now, "revoked_by": revokedBy})
if res.Error != nil {
return nil, false, res.Error
}
if res.RowsAffected == 0 {
return nil, true, nil
}
var out BackendAPIToken
if err := db.First(&out, id).Error; err != nil {
return nil, false, err
}
return &out, false, nil
}
// tokenStore looks up usable personal API tokens by hash and stamps last_used_at.
type tokenStore interface {
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
StampLastUsed(ctx context.Context, id uint, now time.Time) error
}
type gormTokenStore struct {
db *gorm.DB
}
func (s gormTokenStore) FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
if s.db == nil {
return nil, fmt.Errorf("cabana: token store has no database")
}
var row BackendAPIToken
err := s.db.WithContext(ctx).
Where("token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)", hash, now).
Limit(1).
Find(&row).Error
if err != nil {
return nil, err
}
if row.ID == 0 {
return nil, nil
}
return &row, nil
}
func (s gormTokenStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error {
if s.db == nil || id == 0 {
return nil
}
return s.db.WithContext(ctx).Exec(
`UPDATE backend_api_tokens SET last_used_at = ?
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
now, id, now.Add(-time.Minute),
).Error
}
// lastStamp gates last_used_at writes to at most once per minute per token id
// in this process; the SQL UPDATE is additionally conditional.
var lastStamp sync.Map // uint -> time.Time
func stampLastUsed(ctx context.Context, store tokenStore, id uint, now time.Time) {
if store == nil || id == 0 {
return
}
if v, ok := lastStamp.Load(id); ok {
if now.Sub(v.(time.Time)) < time.Minute {
return
}
}
lastStamp.Store(id, now)
if err := store.StampLastUsed(ctx, id, now); err != nil {
slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err)
}
}