Files
minstrel/internal/auth/session.go
T
bvandeusen 236637fcd3
test-go / test (push) Failing after 24s
test-go / integration (push) Failing after 9m54s
feat(server): HMAC stream token auth path (UPnP slice 1/6)
Adds SignStreamToken / VerifyStreamToken (HMAC-SHA256 over
trackID|exp) and modifies handleGetStream to accept either the
existing session cookie OR a valid signed token. Stream route
moved out of the authed group so the handler's own auth check
runs and the token bypass is reachable.

Enables Sonos / UPnP speakers to fetch the stream URL without
carrying the user's session cookie - they cannot. The token is
short-lived (max 24h per the design); expiry checked at request
time only, not per-byte, so long tracks play through.

streamSecret field on handlers is nil for now; Task 2 wires the
loader (env var with auto-generated fallback persisted in
app_preferences).

Adds auth.OptionalUser - the permissive sibling of RequireUser
that attaches the user to context when a valid cookie / bearer is
present but does NOT 401 on absence. The stream route is wrapped
with it so the handler can fall through to the token path when
no session is present.

newLibraryRouter (test fixture) gets a synthetic-user middleware
on the stream route so existing media_test tests keep passing
without seeding a real session row - production traffic uses
auth.OptionalUser, the test path uses auth.UserCtxKeyForTest().

Five tests cover round-trip, tampered token rejection, expiry,
wrong-track-ID, and wrong-secret rejection. CI verifies.
2026-06-03 11:34:02 -04:00

182 lines
6.7 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.
if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
slog.Warn("api: touch session last_seen failed", "err", err)
}
ctx := context.WithValue(r.Context(), userCtxKey, user)
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 }
// 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)
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):])
}