Server half of the active-sessions surface. Web UI follows. The operator wants this specifically to notice a compromised account, which sets the bar: the addresses have to be trustworthy, or the feature is worse than absent because it looks like evidence. Migration 0052 adds created_ip + last_ip. Two columns, not one, and the pair is the signal: a session issued at home and now being used from elsewhere is the shape of a stolen token, and neither column alone can show that. Typed text, matching the user_agent column beside it — these are displayed, never queried by subnet, and inet round-trips through pgx as a netip.Prefix that renders "1.2.3.4/32". The rest of the schema was already waiting. Migration 0004 anticipated this exactly: "last_seen_at enables an 'active sessions' UI later (not wired in this plan) without schema churn." last_seen_at is live data — the auth middleware already touches it per request — so last_ip rides that same UPDATE for free. Getting the address right is the substance here. Nothing extracted a client IP anywhere before, and both obvious approaches are wrong: - RemoteAddr alone shows the reverse proxy on every session, which is the normal self-hosted deployment. Noise shaped like data. - Trusting X-Forwarded-For lets any client choose what its victim sees. A security surface an attacker can write to is worse than none. So auth.ClientIP trusts the header only when the request actually arrived from a proxy range. Public RemoteAddr means a direct connection, so XFF is attacker-controlled and ignored outright. Private RemoteAddr means we walk XFF right-to-left — proxies append, so the right end is what our own infrastructure wrote — and take the first non-proxy address. A forged XFF only prepends to the left end, which that walk never reaches. Unit-tested, including both spoofing shapes. Fails closed on a public-addressed proxy (separate host, CDN): we report the proxy rather than trusting a forgeable header. Documented at the function. Endpoints, all scoped by user_id per rule #47: GET /api/me/sessions → list, flagging the current row DELETE /api/me/sessions/{id} → 204, or 404 if not yours POST /api/me/sessions/logout-others → {"revoked": n} Keyed on session id alone, any household member could revoke another's session by guessing a uuid, so the delete carries user_id in its WHERE and :execrows distinguishes "not yours" (404) from a false 204. There's a test that asserts the row actually survives, not merely that we returned 404. The middleware now also puts the session id in context. logout-others is defined by exclusion, and without knowing which session is ours the safe-looking action deletes everything including the caller's — so it refuses rather than guesses when the id is absent, and that refusal is tested for non-deletion too. audit_log.action is plain text with no CHECK, so the two new actions need no migration (rule #36 checked, not assumed). Codegen is real sqlc 1.31.1 via the container in `make generate` — docker is present on this workstation even though Go and sqlc aren't — rather than the hand-written .sql.go shortcut used in milestone #268.
196 lines
7.4 KiB
Go
196 lines
7.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// sessionTokenBytes is the raw entropy per session token. 32 bytes of
|
|
// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie
|
|
// value is 43 chars with no padding.
|
|
const sessionTokenBytes = 32
|
|
|
|
// MintSessionToken returns a freshly-generated, url-safe opaque token.
|
|
// The token is what the client carries; the DB only ever sees its sha256.
|
|
func MintSessionToken() (string, error) {
|
|
b := make([]byte, sessionTokenBytes)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// HashSessionToken is the single source of truth for mapping a raw token to
|
|
// the `sessions.token_hash` column. sha256 is fine here — we're not guarding
|
|
// against offline brute force (the token has 256 bits of entropy); we only
|
|
// want "leaked DB row can't be replayed without also having the raw token."
|
|
func HashSessionToken(token string) []byte {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return sum[:]
|
|
}
|
|
|
|
// VerifyPassword is the canonical bcrypt comparison. Returns false on a
|
|
// malformed hash so callers don't need to distinguish "hash invalid" from
|
|
// "password wrong" — both are auth failures from the client's perspective.
|
|
func VerifyPassword(hash, plaintext string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
|
|
}
|
|
|
|
// SessionCookieName is the cookie the web SPA rides. Exported because handlers
|
|
// that issue/clear the cookie (handleLogin / handleLogout) need to match it.
|
|
const SessionCookieName = "minstrel_session"
|
|
|
|
// RequireUser resolves the caller from a session cookie OR Authorization
|
|
// bearer header and puts the dbq.User in request context via userCtxKey.
|
|
// Requests without a valid session return 401 with no body so callers don't
|
|
// leak whether the username existed (matches the /rest/* auth posture).
|
|
func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := sessionTokenFromRequest(r)
|
|
if token == "" {
|
|
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if pool == nil {
|
|
// Test-only path: the test at the top of this file constructs
|
|
// the middleware with nil pool to prove the no-token case
|
|
// short-circuits without a DB call. Any real token here is
|
|
// programmer error.
|
|
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
q := dbq.New(pool)
|
|
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
slog.Error("api: session lookup failed", "err", err)
|
|
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
user, err := q.GetUserByID(r.Context(), sess.UserID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Session points at a deleted user — treat as unauth,
|
|
// best-effort cleanup.
|
|
_ = q.DeleteSession(r.Context(), sess.ID)
|
|
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
slog.Error("api: user lookup failed", "err", err)
|
|
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Best-effort last-seen update. A failure here shouldn't fail the
|
|
// request; the session is still valid and this is observability.
|
|
// last_ip rides the same UPDATE — a session whose address has
|
|
// moved since it was issued is the signal the active-sessions
|
|
// surface exists to show, and it costs nothing extra here.
|
|
if err := q.TouchSessionLastSeen(r.Context(), dbq.TouchSessionLastSeenParams{
|
|
ID: sess.ID,
|
|
LastIp: ClientIP(r),
|
|
}); err != nil {
|
|
slog.Warn("api: touch session last_seen failed", "err", err)
|
|
}
|
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
|
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need
|
|
// to inject a dbq.User into request context without going through the
|
|
// middleware. Do not use this outside _test.go files.
|
|
func UserCtxKeyForTest() any { return userCtxKey }
|
|
|
|
// SessionIDCtxKeyForTest is the sibling of UserCtxKeyForTest for the session
|
|
// id, so handler tests can exercise the current-session logic (which row is
|
|
// "this device", which one logout-others must spare) without standing up the
|
|
// middleware. Do not use this outside _test.go files.
|
|
func SessionIDCtxKeyForTest() any { return sessionIDCtxKey }
|
|
|
|
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
|
// from the session cookie or bearer header and attaches the user to context
|
|
// when present + valid, but does NOT 401 on absence. The downstream handler
|
|
// runs unconditionally and is responsible for its own auth check via
|
|
// UserFromContext (or its own bespoke path — see /api/tracks/{id}/stream's
|
|
// streamAuthOk, which accepts EITHER a user-in-context OR a signed query
|
|
// token for UPnP / Sonos speakers that don't carry the user's cookie).
|
|
//
|
|
// Invalid tokens (stale session row, deleted user) silently drop through
|
|
// without attaching the user. The handler treats "no user in context" as
|
|
// "not authenticated" the same way it treats a missing cookie.
|
|
//
|
|
// Database lookup failures fall through too — a transient DB blip should not
|
|
// 5xx a stream request that may have a perfectly valid signed token. The
|
|
// error is logged so the operator can correlate.
|
|
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := sessionTokenFromRequest(r)
|
|
if token == "" || pool == nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
q := dbq.New(pool)
|
|
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
|
logger.Warn("api: optional session lookup failed", "err", err)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
user, err := q.GetUserByID(r.Context(), sess.UserID)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
|
logger.Warn("api: optional user lookup failed", "err", err)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
|
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
func sessionTokenFromRequest(r *http.Request) string {
|
|
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
|
|
return c.Value
|
|
}
|
|
return extractBearerToken(r.Header.Get("Authorization"))
|
|
}
|
|
|
|
// extractBearerToken pulls the token out of an Authorization header in
|
|
// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is
|
|
// missing, malformed, or uses a different scheme.
|
|
func extractBearerToken(header string) string {
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
const prefix = "bearer "
|
|
lower := strings.ToLower(header)
|
|
if !strings.HasPrefix(lower, prefix) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(header[len(prefix):])
|
|
}
|