323 lines
8.3 KiB
Go
323 lines
8.3 KiB
Go
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)
|
|
})
|
|
}
|