d62cc08b2e
gates / gates (push) Successful in 6m44s
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once, stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it. - Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only: secrets shown/minted, deletes/resets, password, tokens. - R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test. - Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open. - 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS. - website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push. Not deployed — waits for the operator's yes. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
150 lines
5.0 KiB
Go
150 lines
5.0 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Operator access tokens (hub v0.152.0, `05` §17). A token is a scoped, expiring stand-in for the operator password,
|
|
// for scripts (Claude Code's floor step first). The plain token is shown ONCE at creation and never stored: the row
|
|
// keeps only its unkeyed SHA-256. A slow hash is not needed — the token is 32 random bytes, so there is nothing to
|
|
// guess and nothing a dictionary can find; the hash only stops a leaked database copy from being replayed as a token.
|
|
// Pinned by TestAPIToken_StoredRowHasNoPlainToken.
|
|
|
|
// APITokenPrefix starts every operator access token. The web surface accepts it; the box API refuses it.
|
|
const APITokenPrefix = "fhp_"
|
|
|
|
// APIToken is one access-token row. The plain token is never a field here.
|
|
type APIToken struct {
|
|
ID int64
|
|
Name string
|
|
Scopes []string // "<group>:read" / "<group>:write" — validated by the web package's scope list
|
|
CreatedAt time.Time
|
|
CreatedBy string
|
|
ExpiresAt time.Time
|
|
LastUsedAt *time.Time
|
|
LastUsedFrom string
|
|
RevokedAt *time.Time
|
|
}
|
|
|
|
// Expired reports whether the token's expiry has passed at now.
|
|
func (t *APIToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) }
|
|
|
|
// HashAPIToken is the stored lookup form of a plain token.
|
|
func HashAPIToken(plain string) string {
|
|
sum := sha256.Sum256([]byte(plain))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (s *Store) migrateAPITokens() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
scopes TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL,
|
|
created_by TEXT NOT NULL DEFAULT '',
|
|
expires_at DATETIME NOT NULL,
|
|
last_used_at DATETIME,
|
|
last_used_from TEXT NOT NULL DEFAULT '',
|
|
revoked_at DATETIME
|
|
);`)
|
|
return err
|
|
}
|
|
|
|
// CreateAPIToken stores a new token by its hash. The caller generated plain and shows it once.
|
|
func (s *Store) CreateAPIToken(name, plain string, scopes []string, createdBy string, expiresAt time.Time) (int64, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return 0, fmt.Errorf("token name is empty")
|
|
}
|
|
if len(scopes) == 0 {
|
|
return 0, fmt.Errorf("token has no scope")
|
|
}
|
|
res, err := s.db.Exec(`INSERT INTO api_tokens (name, token_hash, scopes, created_at, created_by, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
name, HashAPIToken(plain), strings.Join(scopes, ","), time.Now().UTC().Format(time.RFC3339),
|
|
createdBy, expiresAt.UTC().Format(time.RFC3339))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
const apiTokenCols = `id, name, scopes, created_at, created_by, expires_at, last_used_at, last_used_from, revoked_at`
|
|
|
|
func scanAPIToken(sc interface{ Scan(...any) error }) (*APIToken, error) {
|
|
var t APIToken
|
|
var scopes, created, expires string
|
|
var lastUsed, revoked sql.NullString
|
|
if err := sc.Scan(&t.ID, &t.Name, &scopes, &created, &t.CreatedBy, &expires, &lastUsed, &t.LastUsedFrom, &revoked); err != nil {
|
|
return nil, err
|
|
}
|
|
if scopes != "" {
|
|
t.Scopes = strings.Split(scopes, ",")
|
|
}
|
|
t.CreatedAt = parseSQLiteTime(created)
|
|
t.ExpiresAt = parseSQLiteTime(expires)
|
|
if lastUsed.Valid && lastUsed.String != "" {
|
|
v := parseSQLiteTime(lastUsed.String)
|
|
t.LastUsedAt = &v
|
|
}
|
|
if revoked.Valid && revoked.String != "" {
|
|
v := parseSQLiteTime(revoked.String)
|
|
t.RevokedAt = &v
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// GetAPITokenByPlain finds a token by its plain value (hashed here). nil, nil = no such token.
|
|
func (s *Store) GetAPITokenByPlain(plain string) (*APIToken, error) {
|
|
t, err := scanAPIToken(s.db.QueryRow(`SELECT `+apiTokenCols+` FROM api_tokens WHERE token_hash = ?`, HashAPIToken(plain)))
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return t, err
|
|
}
|
|
|
|
// ListAPITokens returns every token, newest first (revoked ones included — the list is the audit trail).
|
|
func (s *Store) ListAPITokens() ([]*APIToken, error) {
|
|
rows, err := s.db.Query(`SELECT ` + apiTokenCols + ` FROM api_tokens ORDER BY id DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*APIToken
|
|
for rows.Next() {
|
|
t, err := scanAPIToken(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeAPIToken marks a token revoked. Revoking twice is a no-op; an unknown id is an error.
|
|
func (s *Store) RevokeAPIToken(id int64) error {
|
|
res, err := s.db.Exec(`UPDATE api_tokens SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`,
|
|
time.Now().UTC().Format(time.RFC3339), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return fmt.Errorf("no token with id %d", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TouchAPIToken records a use. Best effort: a failure here never refuses the request.
|
|
func (s *Store) TouchAPIToken(id int64, from string) error {
|
|
_, err := s.db.Exec(`UPDATE api_tokens SET last_used_at = ?, last_used_from = ? WHERE id = ?`,
|
|
time.Now().UTC().Format(time.RFC3339), from, id)
|
|
return err
|
|
}
|