248 lines
7.0 KiB
Go
248 lines
7.0 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// Identity is what a verified token proves. It is immutable once returned and
|
|
// is shared by every request using that key, so callers must not modify it.
|
|
type Identity struct {
|
|
KeyID string
|
|
Scope store.Scope
|
|
ProjectID *int64 // nil for admin keys
|
|
Name string
|
|
ExpiresAt *time.Time
|
|
}
|
|
|
|
// IsAdmin reports whether the identity may act on every project.
|
|
func (i *Identity) IsAdmin() bool { return i != nil && i.Scope == store.ScopeAdmin }
|
|
|
|
// Owns reports whether the identity may act on the project with this row id.
|
|
// Admins own everything.
|
|
//
|
|
// Callers must pass a resolved row id, never a name from the URL: comparing
|
|
// names would make the boundary depend on string handling in every handler.
|
|
func (i *Identity) Owns(projectID int64) bool {
|
|
if i == nil {
|
|
return false
|
|
}
|
|
if i.Scope == store.ScopeAdmin {
|
|
return true
|
|
}
|
|
return i.ProjectID != nil && *i.ProjectID == projectID
|
|
}
|
|
|
|
// Failure reasons. All of them are reported to the client as one indistinct
|
|
// 401: telling an unauthenticated caller whether a key exists, is revoked or
|
|
// merely expired is free reconnaissance.
|
|
var (
|
|
ErrUnknownKey = errors.New("auth: unknown key id")
|
|
ErrBadSecret = errors.New("auth: secret mismatch")
|
|
ErrRevoked = errors.New("auth: key revoked")
|
|
ErrExpired = errors.New("auth: key expired")
|
|
)
|
|
|
|
// DefaultCacheTTL bounds how long a revocation can take to become visible if
|
|
// the process that revoked it is not this one. Within one process, Invalidate
|
|
// makes revocation immediate.
|
|
const DefaultCacheTTL = 60 * time.Second
|
|
|
|
// Verifier turns a bearer token into an Identity.
|
|
//
|
|
// Verified keys are cached, because otherwise every deploy request would pay a
|
|
// database round trip before doing any work. The cache stores only positive
|
|
// results: caching unknown key ids would let anyone grow the map without bound
|
|
// by presenting random tokens. An unknown id costs one indexed lookup on a
|
|
// WITHOUT ROWID table, and the rate limiter covers the flood case.
|
|
//
|
|
// sync.Map fits this exactly — the key set is small and stable, entries are
|
|
// written once and read many times, and different goroutines mostly touch
|
|
// different keys.
|
|
type Verifier struct {
|
|
db *store.DB
|
|
log *slog.Logger
|
|
ttl time.Duration
|
|
|
|
cache sync.Map // keyID -> *cacheEntry
|
|
gen atomic.Uint64
|
|
|
|
// Pending last-use timestamps, flushed in batches. Writing last_used_at per
|
|
// request would funnel every authenticated read through the single write
|
|
// connection, which is the contention the two-pool design exists to avoid.
|
|
mu sync.Mutex
|
|
touch map[string]time.Time
|
|
|
|
now func() time.Time // swapped in tests
|
|
}
|
|
|
|
type cacheEntry struct {
|
|
ident *Identity
|
|
hash []byte
|
|
gen uint64
|
|
exp time.Time
|
|
}
|
|
|
|
// NewVerifier returns a verifier reading from db. A ttl of zero means
|
|
// DefaultCacheTTL.
|
|
func NewVerifier(db *store.DB, log *slog.Logger, ttl time.Duration) *Verifier {
|
|
if ttl <= 0 {
|
|
ttl = DefaultCacheTTL
|
|
}
|
|
return &Verifier{
|
|
db: db,
|
|
log: log,
|
|
ttl: ttl,
|
|
touch: make(map[string]time.Time),
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// Verify authenticates a bearer token.
|
|
//
|
|
// On success it also records the key as used; the timestamp is written to the
|
|
// database later, in a batch, so it is approximate by design.
|
|
func (v *Verifier) Verify(ctx context.Context, token string) (*Identity, error) {
|
|
keyID, secret, err := Parse(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := v.now()
|
|
gen := v.gen.Load()
|
|
|
|
entry, ok := v.lookupCache(keyID, gen, now)
|
|
if !ok {
|
|
key, err := v.db.KeyByID(ctx, keyID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, ErrUnknownKey
|
|
}
|
|
return nil, err
|
|
}
|
|
entry = &cacheEntry{
|
|
ident: identityOf(key),
|
|
hash: key.SecretHash,
|
|
gen: gen,
|
|
exp: now.Add(v.ttl),
|
|
}
|
|
// Revoked keys are never cached: the entry would only ever produce a
|
|
// rejection, and keeping it lets a caller pin memory with a dead key.
|
|
if key.RevokedAt != nil {
|
|
return nil, ErrRevoked
|
|
}
|
|
v.cache.Store(keyID, entry)
|
|
}
|
|
|
|
// The comparison happens on every request, cache hit or not. The cache
|
|
// saves the database round trip; it must never save the check itself.
|
|
if !SecretMatches(secret, entry.hash) {
|
|
return nil, ErrBadSecret
|
|
}
|
|
if entry.ident.ExpiresAt != nil && !now.Before(*entry.ident.ExpiresAt) {
|
|
return nil, ErrExpired
|
|
}
|
|
|
|
v.recordUse(keyID, now)
|
|
return entry.ident, nil
|
|
}
|
|
|
|
func (v *Verifier) lookupCache(keyID string, gen uint64, now time.Time) (*cacheEntry, bool) {
|
|
raw, ok := v.cache.Load(keyID)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
e := raw.(*cacheEntry)
|
|
if e.gen != gen || !now.Before(e.exp) {
|
|
v.cache.Delete(keyID)
|
|
return nil, false
|
|
}
|
|
return e, true
|
|
}
|
|
|
|
// Invalidate discards every cached identity.
|
|
//
|
|
// Called after any key or project change. Bumping a generation counter rather
|
|
// than deleting individual entries is deliberate: a caller that forgets which
|
|
// ids a change touched cannot leave a stale entry behind, and the cost is one
|
|
// atomic load per verification.
|
|
func (v *Verifier) Invalidate() { v.gen.Add(1) }
|
|
|
|
func (v *Verifier) recordUse(keyID string, at time.Time) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
if prev, ok := v.touch[keyID]; !ok || at.After(prev) {
|
|
v.touch[keyID] = at
|
|
}
|
|
}
|
|
|
|
// FlushTouches writes the accumulated last-use timestamps.
|
|
//
|
|
// The pending set is taken before the write and not restored on failure: a lost
|
|
// last_used_at is a cosmetic loss, and retrying would let a persistently
|
|
// failing write grow the map without bound.
|
|
func (v *Verifier) FlushTouches(ctx context.Context) error {
|
|
v.mu.Lock()
|
|
pending := v.touch
|
|
v.touch = make(map[string]time.Time)
|
|
v.mu.Unlock()
|
|
|
|
if len(pending) == 0 {
|
|
return nil
|
|
}
|
|
return v.db.TouchKeys(ctx, pending)
|
|
}
|
|
|
|
// RunFlusher writes pending last-use timestamps every interval until ctx is
|
|
// done, then flushes once more so a clean shutdown does not drop them.
|
|
func (v *Verifier) RunFlusher(ctx context.Context, interval time.Duration) {
|
|
if interval <= 0 {
|
|
interval = time.Minute
|
|
}
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// ctx is already cancelled, so the final flush needs its own
|
|
// deadline or TouchKeys would return immediately.
|
|
final, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer cancel()
|
|
if err := v.FlushTouches(final); err != nil && v.log != nil {
|
|
v.log.Warn("final last_used_at flush failed", "error", err)
|
|
}
|
|
return
|
|
case <-t.C:
|
|
if err := v.FlushTouches(ctx); err != nil && v.log != nil {
|
|
v.log.Warn("last_used_at flush failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func identityOf(k *store.APIKey) *Identity {
|
|
// Everything reachable from a cached Identity is copied: the value is shared
|
|
// by every concurrent request using that key, so it must not alias a struct
|
|
// the store still owns.
|
|
id := &Identity{
|
|
KeyID: k.ID,
|
|
Scope: k.Scope,
|
|
Name: k.Name,
|
|
}
|
|
if k.ProjectID != nil {
|
|
pid := *k.ProjectID
|
|
id.ProjectID = &pid
|
|
}
|
|
if k.ExpiresAt != nil {
|
|
exp := *k.ExpiresAt
|
|
id.ExpiresAt = &exp
|
|
}
|
|
return id
|
|
}
|