starter code
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRoundTrip(t *testing.T) {
|
||||
for _, env := range []Environment{Live, Test} {
|
||||
g, err := Generate(env)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", env, err)
|
||||
}
|
||||
|
||||
got, err := Validate(g.Plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate(%q): %v", g.Plaintext, err)
|
||||
}
|
||||
|
||||
if got != env {
|
||||
t.Errorf("environment round trip: got %q want %q", got, env)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(g.Plaintext, Issuer+"_"+string(env)+"_") {
|
||||
t.Errorf("prefix wrong: %q", g.Plaintext)
|
||||
}
|
||||
|
||||
if len(g.Hash) != 32 {
|
||||
t.Errorf("hash length %d, want 32 (the api.keys CHECK requires it)", len(g.Hash))
|
||||
}
|
||||
|
||||
if !bytes.Equal(g.Hash, Hash(g.Plaintext)) {
|
||||
t.Error("Generate's hash differs from Hash of its own plaintext")
|
||||
}
|
||||
|
||||
if len(g.Display) != displayLen {
|
||||
t.Errorf("display length %d, want %d", len(g.Display), displayLen)
|
||||
}
|
||||
|
||||
if strings.Contains(g.Plaintext[len(g.Display):], "") && g.Display == g.Plaintext {
|
||||
t.Error("display prefix is the whole key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIsUnique(t *testing.T) {
|
||||
const n = 2000
|
||||
|
||||
seen := make(map[string]bool, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
|
||||
if seen[g.Plaintext] {
|
||||
t.Fatalf("duplicate key after %d generations", i)
|
||||
}
|
||||
|
||||
seen[g.Plaintext] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsUnknownEnvironment(t *testing.T) {
|
||||
if _, err := Generate(Environment("prod")); !errors.Is(err, ErrEnvironment) {
|
||||
t.Errorf("got %v, want ErrEnvironment", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMalformed(t *testing.T) {
|
||||
good, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrMalformed},
|
||||
{"no separators", "notakey", ErrMalformed},
|
||||
{"one separator", "hmy_live", ErrMalformed},
|
||||
{"wrong issuer", "xxx_live_" + good.Plaintext[9:], ErrMalformed},
|
||||
{"unknown env", "hmy_prod_" + good.Plaintext[9:], ErrEnvironment},
|
||||
{"body too short", "hmy_live_abc", ErrMalformed},
|
||||
{"body too long", good.Plaintext + "X", ErrMalformed},
|
||||
{"body truncated by one", good.Plaintext[:len(good.Plaintext)-1], ErrMalformed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := Validate(c.key); !errors.Is(err, c.want) {
|
||||
t.Errorf("Validate(%q) = %v, want %v", c.key, err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the checksum: a key with a typo is rejected without a database
|
||||
// round trip.
|
||||
func TestValidateCatchesTampering(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := []byte(g.Plaintext)
|
||||
caught := 0
|
||||
|
||||
// Flip one character at each position in the secret and confirm the
|
||||
// checksum notices.
|
||||
for i := len("hmy_live_"); i < len(body)-checksumLen; i++ {
|
||||
orig := body[i]
|
||||
|
||||
if orig == 'A' {
|
||||
body[i] = 'B'
|
||||
} else {
|
||||
body[i] = 'A'
|
||||
}
|
||||
|
||||
if _, err := Validate(string(body)); errors.Is(err, ErrBadChecksum) {
|
||||
caught++
|
||||
}
|
||||
|
||||
body[i] = orig
|
||||
}
|
||||
|
||||
total := len(body) - checksumLen - len("hmy_live_")
|
||||
|
||||
if caught != total {
|
||||
t.Errorf("checksum caught %d/%d single-character corruptions", caught, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIsStable(t *testing.T) {
|
||||
const key = "hmy_live_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGabcdef"
|
||||
|
||||
a, b := Hash(key), Hash(key)
|
||||
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Error("Hash is not deterministic")
|
||||
}
|
||||
|
||||
if bytes.Equal(a, Hash(key+"x")) {
|
||||
t.Error("Hash collided on different input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Redact(g.Plaintext)
|
||||
|
||||
if strings.Contains(g.Plaintext, r) && len(r) >= len(g.Plaintext) {
|
||||
t.Error("Redact returned the whole key")
|
||||
}
|
||||
|
||||
if len(r) > displayLen+3 {
|
||||
t.Errorf("Redact returned %d chars, too much", len(r))
|
||||
}
|
||||
|
||||
// A secret must never survive redaction.
|
||||
secret := g.Plaintext[len("hmy_live_"):]
|
||||
if strings.Contains(r, secret) {
|
||||
t.Error("Redact leaked the secret")
|
||||
}
|
||||
|
||||
if got := Redact("short"); got != "hmy_***" {
|
||||
t.Errorf("Redact(short) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error is the single shape every failure takes. Clients get a stable machine
|
||||
// readable code plus a human message. A self-hosted bot instance can branch on
|
||||
// the code without parsing prose.
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
const (
|
||||
CodeMissingKey = "missing_key"
|
||||
CodeInvalidKey = "invalid_key"
|
||||
CodeKeyRevoked = "key_revoked"
|
||||
CodeKeySuspended = "key_suspended"
|
||||
CodeKeyExpired = "key_expired"
|
||||
CodeOwnerBlocked = "owner_blocked"
|
||||
CodeForbidden = "insufficient_scope"
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInternal = "internal_error"
|
||||
)
|
||||
|
||||
// WriteError sends a JSON error. Deliberately specific about WHY authentication
|
||||
// failed rather than a uniform "unauthorized".
|
||||
//
|
||||
// The usual argument for being vague is to prevent enumeration, but a key is
|
||||
// 256 bits from crypto/rand - there is nothing to enumerate. Telling a
|
||||
// developer their key expired, rather than making them guess, is worth far more
|
||||
// than the non-existent secrecy gained.
|
||||
func WriteError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(errorEnvelope{Error{Code: code, Message: message}}); err != nil {
|
||||
slog.Error("writing error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteJSON sends a success payload.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("writing response", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
|
||||
)
|
||||
|
||||
// PgKeyStore adapts the sqlc-generated queries to the KeyStore interface.
|
||||
// The indirection earns its keep twice: the middleware is testable without a
|
||||
// database, and adding an unrelated column to api.keys does not ripple into the
|
||||
// auth package.
|
||||
type PgKeyStore struct {
|
||||
Q *db.Queries
|
||||
}
|
||||
|
||||
func (s PgKeyStore) KeyByHash(ctx context.Context, hash []byte) (KeyRecord, error) {
|
||||
row, err := s.Q.KeyByHash(ctx, hash)
|
||||
if err != nil {
|
||||
// pgx returns ErrNoRows for an empty :one result. Translate it so the
|
||||
// middleware never has to know which driver is underneath.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return KeyRecord{}, ErrKeyNotFound
|
||||
}
|
||||
|
||||
return KeyRecord{}, err
|
||||
}
|
||||
|
||||
rec := KeyRecord{
|
||||
ID: row.ID,
|
||||
OwnerID: row.OwnerID,
|
||||
Scopes: row.Scopes,
|
||||
QuotaTier: row.QuotaTier,
|
||||
Status: row.Status,
|
||||
OwnerStatus: row.OwnerStatus,
|
||||
}
|
||||
|
||||
// sqlc with emit_pointers_for_null_types gives *time.Time for a nullable
|
||||
// timestamptz. If your generated code uses pgtype.Timestamptz instead,
|
||||
// convert here rather than in the middleware.
|
||||
rec.ExpiresAt = row.ExpiresAt
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Whoami echoes the resolved principal. The cheapest possible end-to-end test
|
||||
// of the auth chain: if this returns your key id and scopes, then the bearer
|
||||
// header, the checksum, the hash lookup, the status checks and the rate limiter
|
||||
// are all working.
|
||||
func Whoami() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"key_id": p.KeyID,
|
||||
"owner_id": p.OwnerID,
|
||||
"scopes": p.Scopes,
|
||||
"quota_tier": p.QuotaTier,
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user