diff --git a/internal/auth/session.go b/internal/auth/session.go index c5fdff22..a5a5e4fc 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -1,11 +1,20 @@ 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 @@ -38,3 +47,84 @@ func HashSessionToken(token string) []byte { 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)) + }) + } +} + +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):]) +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 4fe7ff6b..dc640f46 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -3,6 +3,8 @@ package auth import ( "crypto/sha256" "encoding/base64" + "net/http" + "net/http/httptest" "strings" "testing" @@ -50,3 +52,38 @@ func TestVerifyPassword(t *testing.T) { t.Error("malformed hash accepted") } } + +func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("handler must not be called") + }) + h := RequireUser(nil)(next) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestExtractBearerToken(t *testing.T) { + cases := []struct { + header string + want string + }{ + {"", ""}, + {"Bearer abc", "abc"}, + {"bearer abc", "abc"}, + {"Token abc", ""}, + {"Bearer", ""}, + {"Bearer whitespace-token ", "whitespace-token"}, + } + for _, c := range cases { + got := extractBearerToken(c.header) + if got != c.want { + t.Errorf("extractBearerToken(%q) = %q, want %q", c.header, got, c.want) + } + } +} diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index b2aedd4c..2e7dccbf 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -74,6 +74,25 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, return i, err } +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + ) + return i, err +} + const getUserByUsername = `-- name: GetUserByUsername :one SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE username = $1 ` diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 1a9b401c..4710ed75 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -16,3 +16,6 @@ SELECT count(*) FROM users; -- Stores (or clears with NULL) the per-user Subsonic legacy credential used -- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. UPDATE users SET subsonic_password = $2 WHERE id = $1; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1;