Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change.
202 lines
7.8 KiB
Go
202 lines
7.8 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).
|
|
//
|
|
// trustedHops supplies the reverse-proxy depth used to record the session's
|
|
// current address (#2453). It's a func rather than an int because the value
|
|
// is operator-editable at runtime and this middleware is constructed once at
|
|
// boot — reading it per request is what makes an admin change take effect
|
|
// without a restart. Passing nil means "trust nothing", i.e. the socket peer.
|
|
func RequireUser(pool *pgxpool.Pool, trustedHops func() int) 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, hopsOf(trustedHops)),
|
|
}); 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):])
|
|
}
|