- 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>
158 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|