package api import ( "context" "errors" "log/slog" "net/http" "strconv" "strings" "time" "git.dcrubro.com/dcrubro/hammy-backend/internal/apikey" "git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit" ) // Scope names. These must match the keys_scopes_known CHECK in // migrations/003_api.sql; the database is the source of truth and will reject // anything not listed there. const ( ScopeReference = "reference" ScopeCallsign = "callsign" ScopeSpots = "spots" ScopePropagation = "propagation" ScopeLogbook = "logbook" ScopeNet44 = "net44" ScopeAdmin = "admin" ) // ErrKeyNotFound is what a KeyStore returns when no active key matches. var ErrKeyNotFound = errors.New("api: key not found") // KeyRecord is what authentication needs from the database. Deliberately not // the sqlc-generated type. type KeyRecord struct { ID int64 OwnerID int64 Scopes []string QuotaTier string Status string OwnerStatus string ExpiresAt *time.Time } // KeyStore is the database slice this middleware needs. type KeyStore interface { KeyByHash(ctx context.Context, hash []byte) (KeyRecord, error) } // Principal is the authenticated caller, attached to the request context. type Principal struct { KeyID int64 OwnerID int64 Scopes []string QuotaTier string // EndUser is the caller's own identifier for whoever triggered the // request - for the bot, a hashed Discord user ID. Optional, but without // it a single user can consume a whole instance's quota, and the // per-user counter is the enumeration signal worth watching. EndUser string } // Has reports whether the principal holds a scope. Admin is NOT a wildcard - // making it one means a single compromised admin key reaches everything, and // makes it impossible to tell from the database what a key can actually do. func (p *Principal) Has(scope string) bool { if p == nil { return false } for _, s := range p.Scopes { if s == scope { return true } } return false } type contextKey struct{} var principalKey contextKey // PrincipalFrom returns the authenticated caller, or nil if the request did not // pass through Authenticate. func PrincipalFrom(ctx context.Context) *Principal { p, _ := ctx.Value(principalKey).(*Principal) return p } // Authenticator resolves a bearer token to a Principal and enforces quota. type Authenticator struct { Keys KeyStore Limiter ratelimit.Limiter Logger *slog.Logger // Now is injectable so expiry can be tested without waiting. Now func() time.Time // EndUserHeader carries the caller's identifier for the person on whose // behalf the request is made. Empty disables per-user limiting. EndUserHeader string } func (a *Authenticator) now() time.Time { if a.Now != nil { return a.Now() } return time.Now() } func (a *Authenticator) log() *slog.Logger { if a.Logger != nil { return a.Logger } return slog.Default() } // bearer pulls the token out of an Authorization header. func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if h == "" { return "" } // Scheme is case-insensitive per RFC 7235, and clients get this wrong. const prefix = "bearer " if len(h) < len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { return "" } return strings.TrimSpace(h[len(prefix):]) } // Authenticate is the middleware. Every route that touches user data or an // upstream feed must sit behind it. // // The bot is NOT a trusted client: it is GPL, so anyone can fork it, strip its // checks and point it at this API. Scope and quota decisions therefore live // here, on the server, and the bot's versions of them are a UX nice-ty that // saves a round trip. func (a *Authenticator) Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := bearer(r) if token == "" { WriteError(w, http.StatusUnauthorized, CodeMissingKey, "Provide an API key as 'Authorization: Bearer hmy_live_...'.") return } // Structure and checksum first: a flood of garbage headers costs no // database queries. if _, err := apikey.Validate(token); err != nil { WriteError(w, http.StatusUnauthorized, CodeInvalidKey, "That does not look like a Hammy API key.") return } rec, err := a.Keys.KeyByHash(r.Context(), apikey.Hash(token)) if err != nil { if errors.Is(err, ErrKeyNotFound) { WriteError(w, http.StatusUnauthorized, CodeInvalidKey, "Unknown API key.") return } a.log().Error("key lookup failed", "err", err, "key", apikey.Redact(token)) WriteError(w, http.StatusInternalServerError, CodeInternal, "Could not verify that key. Try again shortly.") return } if code, msg, ok := checkUsable(&rec, a.now()); !ok { status := http.StatusUnauthorized if code == CodeOwnerBlocked { status = http.StatusForbidden } WriteError(w, status, code, msg) return } principal := &Principal{ KeyID: rec.ID, OwnerID: rec.OwnerID, Scopes: rec.Scopes, QuotaTier: rec.QuotaTier, } if a.EndUserHeader != "" { principal.EndUser = r.Header.Get(a.EndUserHeader) } if !a.allow(w, r, principal) { return } ctx := context.WithValue(r.Context(), principalKey, principal) next.ServeHTTP(w, r.WithContext(ctx)) }) } // checkUsable applies the status and expiry rules. Split out so it can be // tested directly and so the reasons stay in one readable place. func checkUsable(rec *KeyRecord, now time.Time) (code, message string, ok bool) { switch rec.Status { case "revoked": return CodeKeyRevoked, "This key has been revoked.", false case "suspended": return CodeKeySuspended, "This key is suspended. Check your email for the reason.", false case "active": // fall through default: return CodeInvalidKey, "This key is not usable.", false } if rec.OwnerStatus != "active" { return CodeOwnerBlocked, "This account is not active.", false } if rec.ExpiresAt != nil && !rec.ExpiresAt.After(now) { return CodeKeyExpired, "This key expired. Issue a new one and rotate.", false } return "", "", true } // allow enforces both ceilings and writes the 429 itself. Returns false if the // request must stop. func (a *Authenticator) allow(w http.ResponseWriter, r *http.Request, p *Principal) bool { if a.Limiter == nil { return true } quota := ratelimit.QuotaFor(p.QuotaTier) res, err := a.Limiter.Allow(r.Context(), "key:"+strconv.FormatInt(p.KeyID, 10), quota.KeyLimit, quota.Window) if err != nil { // Fail OPEN. A Redis outage taking the whole API down with it is a // worse outcome than briefly unmetered traffic - the limiter protects // against abuse, it is not an authentication control. a.log().Error("rate limiter unavailable, allowing", "err", err, "key_id", p.KeyID) return true } writeLimitHeaders(w, res) if !res.Allowed { WriteError(w, http.StatusTooManyRequests, CodeRateLimited, "Rate limit exceeded for this API key.") return false } // Per-user ceiling, if the caller told us who this is for. if p.EndUser != "" && quota.UserLimit > 0 { userRes, err := a.Limiter.Allow(r.Context(), "user:"+strconv.FormatInt(p.KeyID, 10)+":"+p.EndUser, quota.UserLimit, quota.Window) if err != nil { a.log().Error("per-user rate limiter unavailable, allowing", "err", err) return true } if !userRes.Allowed { writeLimitHeaders(w, userRes) WriteError(w, http.StatusTooManyRequests, CodeRateLimited, "Rate limit exceeded for this user.") return false } } return true } func writeLimitHeaders(w http.ResponseWriter, res ratelimit.Result) { if res.Limit <= 0 { return } w.Header().Set("X-RateLimit-Limit", strconv.Itoa(res.Limit)) w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining)) if !res.Allowed { secs := int(res.RetryAfter.Seconds()) if secs < 1 { secs = 1 } w.Header().Set("Retry-After", strconv.Itoa(secs)) } } // RequireScope wraps a handler so it only runs for principals holding the // scope. Authorisation is separate from authentication on purpose - a key being // valid says nothing about what it may do. func RequireScope(scope string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.Has(scope) { WriteError(w, http.StatusForbidden, CodeForbidden, "This key does not have the '"+scope+"' scope.") return } next.ServeHTTP(w, r) }) }