Files
minstrel/internal/auth/session.go
T
bvandeusen 8537821d29 feat(api): POST /api/auth/logout
Deletes the session row keyed by the cookie/bearer token and
clears the cookie on the client. Best-effort DB delete — logout
still succeeds for the client if the row's already gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:07:16 -04:00

136 lines
4.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).
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):])
}