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