Files
summercms/modules/cabana/api_tokens.go
Jakub Zych 183583e0e9 feat(16.3-01): interactive-only gates, ResolveBackend, last_used stamp
- Refuse g15_ tokens on account/avatar/refresh/logout; gate field-types and markdown
- Publish cookie-less ResolveBackend; debounce last_used_at once per minute
- Reserve account vendor segment; TestTokenGatesSmoke covers the deny path

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

158 lines
5.0 KiB
Go

package cabana
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"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
}
// issueAPIToken mints a token for ownerID with the given scopes and optional
// expiry, inserts the row, and returns the plaintext exactly once. Task 3
// grows this into the shared API/CLI validation path.
func issueAPIToken(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
}
// 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)
}
}