- The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order - Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token - TestTokenLastUsedThrottlePerGuard pins the per-guard isolation
307 lines
9.7 KiB
Go
307 lines
9.7 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
|
|
}
|
|
|
|
// stampThrottle gates last_used_at writes to at most once per minute per token
|
|
// ID per guard instance; one instance serves a cabana activation (the backend
|
|
// guard and the ResolveBackend guard share it). The SQL UPDATE is additionally
|
|
// conditional.
|
|
type stampThrottle struct {
|
|
last sync.Map // uint -> time.Time
|
|
}
|
|
|
|
// due reports whether id may be stamped at now and records the stamp if so.
|
|
// A nil throttle never debounces.
|
|
func (t *stampThrottle) due(id uint, now time.Time) bool {
|
|
if t == nil {
|
|
return true
|
|
}
|
|
if v, ok := t.last.Load(id); ok {
|
|
if now.Sub(v.(time.Time)) < time.Minute {
|
|
return false
|
|
}
|
|
}
|
|
t.last.Store(id, now)
|
|
return true
|
|
}
|
|
|
|
func stampLastUsed(ctx context.Context, stamps *stampThrottle, store tokenStore, id uint, now time.Time) {
|
|
if store == nil || id == 0 {
|
|
return
|
|
}
|
|
if !stamps.due(id, now) {
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
}
|