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) } } }