init
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user