Vellum/internal/auth/middleware.go
mithral 6e04e957b8 Rate limit every surface that checks a password
The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName
and Subsonic's authenticate both called auth.Authenticate with no limiting,
so an attacker brute-forced through those instead: the same password, the
same argon2, no ceiling.

The limiter moves from auth.Handlers onto *Authenticator, which all three
surfaces already hold, so they share one budget and splitting guesses
across surfaces gains nothing. main.go builds one authenticator and hands
it to both compat layers, so the sharing is real rather than intended.

Subsonic needs care: it has no login step, authenticate runs on every
request, so a naive check would charge playback against the login budget
and lock a listener out mid-album. The limiter is consulted only when a
credential was actually supplied, and a success or an uncredentialed
request spends nothing.

Verified red-green on all three: removing the Jellyfin gate fails
TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the
Subsonic credential guard fails
TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or
ResetLogin fails the auth tests.

Known gap, unchanged: the limiter is in-process, so it resets on restart
and does not span replicas. Fine for one binary against one SQLite file.
2026-10-12 00:57:56 -04:00

314 lines
10 KiB
Go

package auth
import (
"context"
"database/sql"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/mithral/vellum/internal/httpx"
)
// contextKey is unexported so no other package can collide with our context values.
type contextKey string
const (
userContextKey contextKey = "auth.user"
sessionContextKey contextKey = "auth.session"
)
// defaultTouchEvery throttles last_seen writes. last_seen exists for session
// listing and expiry hygiene, not security, so minute granularity is plenty and
// a write per request is not worth the contention it causes on SQLite's single
// write lock — especially for media playback, where one seek fires several
// Range requests that would otherwise each take the write lock.
const defaultTouchEvery = 5 * time.Minute
// Authenticator loads sessions from cookies and enforces access.
type Authenticator struct {
DB *sql.DB
CookieName string
CookieTTL time.Duration
Secure bool
Log *slog.Logger
// TrustedProxies are the networks allowed to name the real client in
// X-Forwarded-For. Empty means trust nobody. Set from cfg.TrustedProxies.
TrustedProxies []*net.IPNet
// limiter throttles password attempts. It lives here rather than on any one
// handler so that every surface which checks a password — the web login,
// the Jellyfin login, Subsonic's per-request auth — spends one budget, and
// an attacker cannot multiply it by guessing through a different surface.
limiter *loginLimiter
// touchEvery is the minimum age of a session's last_seen before Middleware
// will write it again. A zero value means "touch on every request" (the
// pre-throttling behaviour), which is a safe fallback for hand-built values.
touchEvery time.Duration
}
// NewAuthenticator builds an Authenticator with sane cookie defaults.
func NewAuthenticator(db *sql.DB, cookieName string, ttl time.Duration, secure bool, log *slog.Logger) *Authenticator {
if cookieName == "" {
cookieName = "vellum_session"
}
return &Authenticator{
DB: db,
CookieName: cookieName,
CookieTTL: ttl,
Secure: secure,
Log: log,
limiter: newLoginLimiter(10, 5*time.Minute),
touchEvery: defaultTouchEvery,
}
}
// Middleware attaches the current user to the request context when a valid
// session cookie is present. It never rejects a request: enforcement is the
// job of RequireAuth, so public routes can also see the caller.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if token, err := a.TokenFromRequest(r); err == nil {
if sess, err := SessionByToken(ctx, a.DB, token); err == nil {
if u, err := GetUserByID(ctx, a.DB, sess.UserID); err == nil {
ctx = context.WithValue(ctx, userContextKey, u)
ctx = context.WithValue(ctx, sessionContextKey, sess)
// Throttle the last_seen write. It is for session listing
// and expiry hygiene, not security, so minute granularity
// is plenty; a write on every request would take SQLite's
// single write lock each time, and a media seek alone can
// issue several Range requests. The decision is made from
// the LastSeen we already loaded via SessionByToken, so no
// extra query is added.
if time.Since(sess.LastSeen) >= a.touchEvery {
// Best-effort; must not fail the request.
if err := TouchSession(ctx, a.DB, sess.ID); err != nil {
a.Log.Warn("touch session", "err", err)
}
}
r = r.WithContext(ctx)
} else if a.Log != nil {
a.Log.Warn("session references missing user", "user_id", sess.UserID)
}
} else if err != ErrNoSession && a.Log != nil {
a.Log.Warn("load session", "err", err)
}
}
next.ServeHTTP(w, r)
})
}
// RequireAuth rejects unauthenticated requests with 401.
func (a *Authenticator) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := CurrentUser(r.Context()); !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r)
})
}
// RequireAdmin rejects authenticated non-admins with 403.
func (a *Authenticator) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := CurrentUser(r.Context())
if !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
if !u.IsAdmin {
httpx.WriteError(w, http.StatusForbidden, "administrator access required")
return
}
next.ServeHTTP(w, r)
})
}
// Require calls fn with the current user, or 401s if there is none.
func (a *Authenticator) Require(fn func(http.ResponseWriter, *http.Request, User)) http.Handler {
return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
fn(w, r, u)
}))
}
// CurrentUser returns the authenticated user, if any.
func CurrentUser(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userContextKey).(User)
return u, ok
}
// CurrentSession returns the session backing the authenticated user, if any.
func CurrentSession(ctx context.Context) (Session, bool) {
s, ok := ctx.Value(sessionContextKey).(Session)
return s, ok
}
// ── cookies ────────────────────────────────────────────────────────────
// SetSessionCookie issues the session cookie. HttpOnly and SameSite=Lax are
// always on; Secure follows configuration (it must be off for plain-HTTP dev).
func (a *Authenticator) SetSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: a.CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: a.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie removes the session cookie.
func (a *Authenticator) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: a.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: a.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// TokenFromRequest reads the session token from the cookie, or the
// Authorization: Bearer header for non-browser clients (native apps, CLIs).
func (a *Authenticator) TokenFromRequest(r *http.Request) (string, error) {
if c, err := r.Cookie(a.CookieName); err == nil && c.Value != "" {
return c.Value, nil
}
if h := r.Header.Get("Authorization"); len(h) > 7 && h[:7] == "Bearer " {
return h[7:], nil
}
return "", ErrNoSession
}
// ClientIP extracts a best-effort client address for logging and rate limiting.
//
// Behind a reverse proxy every request arrives from the proxy, so RemoteAddr
// alone keys the login limiter identically for every client on the internet:
// one attacker locks out a username for everyone, and sessions.ip records the
// proxy. X-Forwarded-For fixes that, but only for a peer we already trust —
// from an untrusted peer that header is attacker-controlled, so it is ignored
// outright and the connection's own address is used, as before.
//
// With a trusted peer the chain is walked right-to-left and the first address
// that is not itself a trusted proxy wins: everything to its right is a hop we
// control, and everything to its left was appended by a client we do not. If
// every hop is trusted the leftmost entry is the best available answer.
func (a *Authenticator) ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !a.trusts(host) {
return host
}
chain := r.Header.Get("X-Forwarded-For")
if chain == "" {
return host
}
hops := strings.Split(chain, ",")
for i := len(hops) - 1; i >= 0; i-- {
if hop := strings.TrimSpace(hops[i]); !a.trusts(hop) {
return hop
}
}
return strings.TrimSpace(hops[0])
}
// trusts reports whether ip falls inside one of the configured proxy networks.
func (a *Authenticator) trusts(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, n := range a.TrustedProxies {
if n.Contains(parsed) {
return true
}
}
return false
}
// ── login rate limiting ─────────────────────────────────────────────────
// AllowLogin records a password attempt and reports whether it is still within
// budget. Call it before checking a password, on every surface that checks one.
func (a *Authenticator) AllowLogin(ip, username string) bool {
return a.limiter.allow(loginKey(ip, username))
}
// ResetLogin clears the window after a successful login, so a user who fumbled
// a few guesses is not left locked out of their own account.
func (a *Authenticator) ResetLogin(ip, username string) {
a.limiter.reset(loginKey(ip, username))
}
// loginKey builds the limiter key. It lives here so the web login, Jellyfin and
// Subsonic all count one thing as one attempt.
func loginKey(ip, username string) string { return ip + "|" + username }
// loginLimiter is a fixed-window counter keyed by "ip|username".
//
// In-memory is the right scope for a single-process server: it cannot be
// bypassed by restarting a client, and there is no second process to share it
// with. Persisting failed-login counts would be overkill for a LAN server.
type loginLimiter struct {
mu sync.Mutex
hits map[string]*hitWindow
max int
window time.Duration
}
type hitWindow struct {
count int
reset time.Time
}
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
return &loginLimiter{hits: make(map[string]*hitWindow), max: max, window: window}
}
func (l *loginLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
h, ok := l.hits[key]
if !ok || now.After(h.reset) {
l.hits[key] = &hitWindow{count: 1, reset: now.Add(l.window)}
l.pruneLocked(now)
return true
}
h.count++
return h.count <= l.max
}
func (l *loginLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// pruneLocked drops expired windows so the map cannot grow without bound.
func (l *loginLimiter) pruneLocked(now time.Time) {
if len(l.hits) < 1000 {
return
}
for k, h := range l.hits {
if now.After(h.reset) {
delete(l.hits, k)
}
}
}