- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded migration runner; each module's migrations are name-prefixed (D-012) - audit: the append-only audit log; the actor threads via context (R-1) - users: the user_id/GUID/email anchors (D-013) - config: the YAML settings file (D-035) + validation - httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security headers · redaction - middleware: correlation id · audit · rate limiting - mail: the invite/recovery mailer seam - server: the core DOOR — the resolution switch, the dispatch registry (RegisterModule), the role gates, /setup · enroll/login · recovery flows, the reserved `static` workflow, and the loopback-only bootstrap (D-025)
129 lines
3 KiB
Go
129 lines
3 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
|
)
|
|
|
|
// RateLimiter is a per-key fixed-window limiter (A3, SPEC-003 §4/AC-9). It
|
|
// guards the auth-sensitive routes: /{GUID}, /access, /setup. State is
|
|
// in-memory and single-process, which matches the single-instance model
|
|
// (D-012).
|
|
type RateLimiter struct {
|
|
mu sync.Mutex
|
|
limit int
|
|
window time.Duration
|
|
now func() time.Time
|
|
state map[string]*window
|
|
}
|
|
|
|
type window struct {
|
|
count int
|
|
reset time.Time
|
|
}
|
|
|
|
// DefaultRateLimit / DefaultRateWindow are the code defaults for the
|
|
// auth-sensitive routes.
|
|
const (
|
|
DefaultRateLimit = 30
|
|
DefaultRateWindow = time.Minute
|
|
)
|
|
|
|
// NewRateLimiter builds a limiter allowing limit requests per window per key.
|
|
// Non-positive values fall back to the defaults.
|
|
func NewRateLimiter(limit int, win time.Duration) *RateLimiter {
|
|
if limit <= 0 {
|
|
limit = DefaultRateLimit
|
|
}
|
|
if win <= 0 {
|
|
win = DefaultRateWindow
|
|
}
|
|
return &RateLimiter{
|
|
limit: limit,
|
|
window: win,
|
|
now: time.Now,
|
|
state: make(map[string]*window),
|
|
}
|
|
}
|
|
|
|
// Allow records a request for key and reports whether it is within the limit.
|
|
func (l *RateLimiter) Allow(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
now := l.now()
|
|
w, ok := l.state[key]
|
|
if !ok || !now.Before(w.reset) {
|
|
l.state[key] = &window{count: 1, reset: now.Add(l.window)}
|
|
return true
|
|
}
|
|
w.count++
|
|
return w.count <= l.limit
|
|
}
|
|
|
|
// SetClock injects a clock (tests).
|
|
func (l *RateLimiter) SetClock(fn func() time.Time) {
|
|
if fn == nil {
|
|
return
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.now = fn
|
|
}
|
|
|
|
// RetryAfter returns the whole seconds until the window resets for key (for the
|
|
// Retry-After header). It is best-effort.
|
|
func (l *RateLimiter) RetryAfter(key string) int {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
w, ok := l.state[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
d := time.Until(w.reset)
|
|
if d < 0 {
|
|
return 0
|
|
}
|
|
return int(d.Seconds()) + 1
|
|
}
|
|
|
|
// Class classifies a path into an auth-sensitive bucket, or "" when the path is
|
|
// not rate-limited. /access and /setup are explicit; any non-reserved first
|
|
// segment is a GUID lookup.
|
|
func Class(path string) string {
|
|
p := routing.Parse(path)
|
|
if len(p) == 0 || p[0] == "" {
|
|
return ""
|
|
}
|
|
switch p[0] {
|
|
case "access", "setup":
|
|
return p[0]
|
|
}
|
|
if routing.IsReservedWorkflow(p[0]) {
|
|
return ""
|
|
}
|
|
return "guid"
|
|
}
|
|
|
|
// AuthSensitive reports whether path is one of the rate-limited routes (A3).
|
|
func AuthSensitive(path string) bool { return Class(path) != "" }
|
|
|
|
// Middleware wraps next with a limiter keyed by keyFn. It answers 429 with a
|
|
// Retry-After header once the limit is exceeded.
|
|
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
key := keyFn(r)
|
|
if !l.Allow(key) {
|
|
if secs := l.RetryAfter(key); secs > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
|
}
|
|
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|