Merge pull request 'feat: web UI server auth foundation (/api/* login, logout, me)' (#14) from dev into main
This commit was merged in pull request #14.
This commit is contained in:
@@ -25,3 +25,6 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
|
||||
# Superpowers brainstorming companion sessions
|
||||
.superpowers/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
# Web UI Scaffold — Design
|
||||
|
||||
**Status:** Approved (brainstorm), ready for implementation plan
|
||||
**Date:** 2026-04-20
|
||||
**Milestone:** M1.5 (new) — scaffold for the web UI that M2–M5 layer features onto
|
||||
|
||||
## Purpose
|
||||
|
||||
Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set.
|
||||
|
||||
Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2–M5 in step with the server.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1).
|
||||
- Light-theme support (dark theme only for first cut; light mode follows).
|
||||
- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience).
|
||||
- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter).
|
||||
- Reusing the `/rest/*` Subsonic surface from the SPA.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Framework:** SvelteKit + TypeScript + Vite.
|
||||
- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS.
|
||||
- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path.
|
||||
- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal.
|
||||
- **State:** Svelte stores. No external state library.
|
||||
- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses.
|
||||
|
||||
### Why Svelte over React/Solid
|
||||
|
||||
- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later).
|
||||
- Built-in routing / stores / forms mean fewer library decisions for a solo project.
|
||||
- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries.
|
||||
|
||||
## Packaging
|
||||
|
||||
- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`).
|
||||
- **Dockerfile:** multi-stage.
|
||||
1. `FROM node:<lts> AS web` — `npm ci`, `npm run build`, produces `web/build/`.
|
||||
2. `FROM golang:<ver> AS go` — `go build`, with `web/build/` copied in before build so `//go:embed` picks it up.
|
||||
3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run.
|
||||
- Release artifact remains a single container image. `docker compose up` stays one service for the app.
|
||||
|
||||
## Dev workflow
|
||||
|
||||
- Two processes during development:
|
||||
1. `docker compose up` — Go server on `:4533`, Postgres, scanner.
|
||||
2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR.
|
||||
- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin.
|
||||
- Production build: `npm run build` runs in CI / Docker build; static assets get embedded.
|
||||
|
||||
## API surface
|
||||
|
||||
### Principle
|
||||
|
||||
- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.**
|
||||
- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers.
|
||||
|
||||
### First-cut endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. |
|
||||
| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. |
|
||||
| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). |
|
||||
| GET | `/api/artists` | Paginated artist list with sort keys. |
|
||||
| GET | `/api/artists/{id}` | Artist with albums array. |
|
||||
| GET | `/api/albums/{id}` | Album with tracks array. |
|
||||
| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). |
|
||||
| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. |
|
||||
| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `<audio src>` sends cookies; bearer works for non-browser clients. |
|
||||
| GET | `/api/cover/{albumId}` | Album cover art, sized via `?size=` query. |
|
||||
|
||||
### Implementation approach
|
||||
|
||||
- Handlers are thin — argument parsing, call to existing sqlc queries in `internal/db/dbq/*`, JSON marshalling.
|
||||
- Shared types go in `internal/api/types.go` (request/response shapes). These map 1:1 to TypeScript types in `web/src/lib/types.ts` (kept hand-synced; codegen deferred).
|
||||
- Errors follow `{error: {code, message}}` with standard HTTP status codes. No Subsonic-style "status=failed" envelope on `/api/*`.
|
||||
|
||||
### Deferred endpoints (land with their backend milestone)
|
||||
|
||||
- M2: `POST /api/events/{play|skip|complete}`, `POST /api/tracks/{id}/star`, `DELETE /api/tracks/{id}/star`, `GET /api/me/recently-played`.
|
||||
- M3: `GET /api/shuffle`, `POST /api/tracks/{id}/contextual-like`, related artist/session queries.
|
||||
- M4: `POST /api/radio`, `GET /api/radio/{id}`, ListenBrainz settings.
|
||||
- M5: Lidarr proxy + quarantine endpoints.
|
||||
|
||||
## Authentication
|
||||
|
||||
### Login flow
|
||||
|
||||
1. SPA `POST /api/auth/login` with `{username, password}`.
|
||||
2. Server verifies against `password_hash`, creates a session row (opaque 32-byte random token), returns `{token, user}`.
|
||||
3. Server also sets `Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/`.
|
||||
4. SPA stores the user object in a Svelte store; the cookie travels automatically. **SPA does not persist the bearer token** — it's returned only so Flutter can consume the same endpoint later.
|
||||
|
||||
### Middleware
|
||||
|
||||
- `internal/auth.RequireUser` middleware resolves the caller in this order:
|
||||
1. `Cookie: session=<token>` → sessions table lookup.
|
||||
2. `Authorization: Bearer <token>` → sessions table lookup.
|
||||
3. Legacy Subsonic params (only within `/rest/*` — unchanged from today).
|
||||
- On success, user is placed in request context (same pattern as `UserFromContext` used in `internal/subsonic`).
|
||||
- Unauthenticated requests to `/api/*` → `401 {error: {code: "unauthenticated", message: "..."}}`.
|
||||
|
||||
### Sessions table
|
||||
|
||||
- New migration: `sessions(id uuid pk, user_id uuid fk, token_hash bytea, created_at, last_seen_at, user_agent text)`.
|
||||
- Token is hashed in DB (sha256) so a DB leak doesn't grant active sessions.
|
||||
- Logout deletes the session row; cookie is expired via `Set-Cookie: session=; Max-Age=0`.
|
||||
|
||||
### Why this design
|
||||
|
||||
- Web is cookie-safe (no token in JS / localStorage → no XSS extraction).
|
||||
- Flutter later uses the same `POST /api/auth/login` endpoint; it ignores the cookie and sends `Authorization: Bearer` on every call.
|
||||
- OIDC, when it eventually lands, plugs in at the same login-success point: the callback handler mints the same `{token + cookie}` response. The rest of the app doesn't change.
|
||||
|
||||
## Shell layout
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------+
|
||||
| SIDEBAR | MAIN CONTENT |
|
||||
| | |
|
||||
| Minstrel | (route content — artists list, |
|
||||
| | album grid, search results, |
|
||||
| ▸ Home | artist detail, settings, etc.) |
|
||||
| ▸ Library | |
|
||||
| ▸ Search | |
|
||||
| ▸ Recent | |
|
||||
| ▸ Settings | |
|
||||
| | |
|
||||
| — Admin — | |
|
||||
| ▸ Users | |
|
||||
| ▸ Scan | |
|
||||
| ▸ Quarantine | |
|
||||
| | |
|
||||
+-------------------------------------------------------------+
|
||||
| PLAYER BAR: art · title / artist · controls · scrub · time |
|
||||
+-------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- `Sidebar.svelte` — brand, primary nav, admin section (items render as disabled until their backend exists).
|
||||
- `PlayerBar.svelte` — persistent across route changes. Owns a single `<audio>` element via `bind:this`. Reads from and writes to the `player` Svelte store.
|
||||
- `RouteShell.svelte` — root layout. Mounts sidebar + outlet + player bar. Subscribes `player` store to `<audio>` element events (`timeupdate`, `ended`, `error`).
|
||||
- Routes: `/login`, `/` (home), `/library`, `/artists/[id]`, `/albums/[id]`, `/search`, `/settings`, `/admin/*`.
|
||||
|
||||
### Player state (Svelte store)
|
||||
|
||||
```typescript
|
||||
interface PlayerState {
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
currentTime: number; // seconds
|
||||
duration: number; // seconds
|
||||
volume: number; // 0..1
|
||||
}
|
||||
```
|
||||
|
||||
- Queue operations: `enqueue`, `playNow`, `prev`, `next`, `seek`, `setVolume`.
|
||||
- Store methods kick the `<audio>` element via imperative calls (`audio.play()`, `audio.currentTime = …`).
|
||||
- The store is the only place that writes player state; components read via `$player` subscriptions.
|
||||
|
||||
### Theme
|
||||
|
||||
- Dark-only for first cut. Colors via CSS custom properties in `:root`. Palette: neutral dark grays (`#14161a` / `#1a1d22` / `#2a2f36`), light text (`#cdd3db` / `#e8ecf2`), accent color picked during implementation — scaffold works with any single-hue accent.
|
||||
- Layout convention: left sidebar width `240px`, player bar height `72px`, content scrolls independently of shell.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
- `Space` — play/pause (blocked when focus is in an input).
|
||||
- `ArrowLeft` / `ArrowRight` — skip back / forward 5s.
|
||||
- `Shift+ArrowLeft` / `Shift+ArrowRight` — prev / next track.
|
||||
- `/` — focus search input.
|
||||
|
||||
## First-cut feature scope
|
||||
|
||||
- Login / logout.
|
||||
- Artist list (sortable, searchable within-list).
|
||||
- Artist detail → albums grid.
|
||||
- Album detail → track list with inline play buttons.
|
||||
- Global search: `?q=` against tracks + albums + artists, results grouped.
|
||||
- Player: queue, play/pause/seek/prev/next, volume, keyboard shortcuts, auto-advance on `ended`.
|
||||
- Now-playing: persistent player bar always visible; clicking it opens an expanded queue panel.
|
||||
|
||||
## Deferred
|
||||
|
||||
Tracked in Fable milestones 27–30 (M2–M5). Each milestone adds the corresponding UI alongside its backend work.
|
||||
|
||||
- **M2:** Star/unstar UI, recently-played view, event reporting from player (play/skip/complete at the appropriate thresholds).
|
||||
- **M3:** Smart-shuffle entry points, contextual-like controls.
|
||||
- **M4:** Radio seed / start UI, ListenBrainz account connection in Settings.
|
||||
- **M5:** Lidarr quarantine admin, "suggested additions" panel on radio.
|
||||
- **Post-v1:** Stats dashboards, user management UI, OIDC login, light theme, mobile-responsive polish.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Svelte (Vitest):** component tests for non-trivial units only — `player` store (queue/seek/advance logic), `api.ts` (error parsing / auth header injection), auth store. Skip snapshot tests and boilerplate component rendering.
|
||||
- **Go (`internal/api`):** handler-level tests per the existing pattern in `internal/subsonic/*_test.go` — httptest ResponseRecorder, context-injected user, JSON decode of response body.
|
||||
- **No E2E / Playwright** for first cut. If a full-stack smoke test becomes valuable, add one headless Playwright scenario (login → play a track) later.
|
||||
|
||||
## Repo structure
|
||||
|
||||
```
|
||||
minstrel/
|
||||
├── cmd/minstrel/ # Go binary (unchanged)
|
||||
├── internal/
|
||||
│ ├── api/ # NEW: /api/* handlers + shared types
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── library.go
|
||||
│ │ ├── search.go
|
||||
│ │ ├── stream.go
|
||||
│ │ ├── types.go
|
||||
│ │ └── *_test.go
|
||||
│ ├── auth/ # existing; add session token logic
|
||||
│ ├── subsonic/ # unchanged
|
||||
│ ├── library/ # unchanged
|
||||
│ ├── db/ # + migration for sessions
|
||||
│ └── server/ # Router() wires /api/* alongside /rest/*
|
||||
├── web/ # NEW: SvelteKit project
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api.ts # fetch wrapper
|
||||
│ │ │ ├── stores/
|
||||
│ │ │ │ ├── player.ts
|
||||
│ │ │ │ └── auth.ts
|
||||
│ │ │ ├── components/ # Sidebar, PlayerBar, etc.
|
||||
│ │ │ └── types.ts # TypeScript mirrors of internal/api/types.go
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── +layout.svelte
|
||||
│ │ │ ├── +page.svelte # Home
|
||||
│ │ │ ├── login/+page.svelte
|
||||
│ │ │ ├── artists/+page.svelte
|
||||
│ │ │ ├── artists/[id]/+page.svelte
|
||||
│ │ │ ├── albums/[id]/+page.svelte
|
||||
│ │ │ ├── search/+page.svelte
|
||||
│ │ │ └── settings/+page.svelte
|
||||
│ │ └── app.html
|
||||
│ ├── static/
|
||||
│ ├── svelte.config.js # adapter-static + fallback
|
||||
│ ├── vite.config.ts # /api + /rest proxy for dev
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
├── docs/
|
||||
├── Dockerfile # multi-stage: node → go → slim
|
||||
├── docker-compose.yml
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Open questions resolved
|
||||
|
||||
- **Framework:** Svelte (B in brainstorm).
|
||||
- **Packaging:** Single binary with embedded static (A in brainstorm).
|
||||
- **Auth:** Cookie + bearer from same endpoint (C in brainstorm).
|
||||
- **API shape:** New `/api/*` surface (B in brainstorm).
|
||||
- **Scope:** Rich target, scaffold first, features woven through M2–M5 (C in brainstorm with sequencing plan).
|
||||
- **Layout:** Left sidebar + bottom persistent player (A in brainstorm).
|
||||
|
||||
## Risks / decisions to revisit during implementation
|
||||
|
||||
- **Cover art sizing.** `/rest/getCoverArt` does thumbnail scaling via ffmpeg (or similar). `/api/cover/{id}?size=` may need the same logic; could call the existing Subsonic helper internally rather than re-implementing.
|
||||
- **TypeScript / Go type sync.** Manual hand-sync is fine at first-cut scale (maybe 10 types). If it becomes a churn point, introduce codegen (oapi-codegen / sqlc-like generator) — not first-cut scope.
|
||||
- **Stream auth with `<audio>` elements.** Cookies carry automatically. Verify Chromium, Firefox, Safari all forward the cookie on `<audio src>` requests — we believe they do; flag if not.
|
||||
- **Keyboard shortcut collisions.** Space-bar for play/pause conflicts with page scroll. Only capture when body isn't scrolled-to-focus-element; easy to get wrong. Write a small test when implementing.
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package api implements Minstrel's native JSON surface under /api. It is
|
||||
// consumed by the built-in web SPA and (eventually) the Flutter client.
|
||||
// Subsonic-compatible endpoints under /rest are intentionally separate —
|
||||
// see internal/subsonic — and the two packages must not depend on each other.
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
|
||||
h := &handlers{pool: pool, logger: logger}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/login", h.handleLogin)
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire
|
||||
// server-side yet (future work); the cookie still caps browser-side lifetime
|
||||
// so an abandoned laptop doesn't stay logged in forever.
|
||||
const sessionCookieMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// The session token can be on the cookie OR bearer header — RequireUser
|
||||
// accepted either. Re-resolve it here so we can delete the row.
|
||||
token := sessionTokenFromHTTP(r)
|
||||
if token != "" {
|
||||
if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
|
||||
h.logger.Warn("api: delete session failed", "err", err)
|
||||
// Continue — logout is best-effort; the client still gets the
|
||||
// cookie cleared.
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// sessionTokenFromHTTP duplicates the internal helper in internal/auth
|
||||
// because that one is unexported. Cheap to repeat here; keeping the auth
|
||||
// package's internal helper package-private is worth more than DRY. Must
|
||||
// match that helper's trimming behavior exactly — otherwise a bearer with
|
||||
// trailing whitespace authenticates via RequireUser (which trims) but logout
|
||||
// hashes the padded value and silently no-ops, leaving the session alive.
|
||||
func sessionTokenFromHTTP(r *http.Request) string {
|
||||
if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "bearer "
|
||||
if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "username and password required")
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
user, err := q.GetUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: user lookup failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.MintSessionToken()
|
||||
if err != nil {
|
||||
h.logger.Error("api: mint session token failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "mint failed")
|
||||
return
|
||||
}
|
||||
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashSessionToken(token),
|
||||
UserAgent: r.UserAgent(),
|
||||
}); err != nil {
|
||||
h.logger.Error("api: insert session failed", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(LoginResponse{
|
||||
Token: token,
|
||||
User: UserView{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
IsAdmin: user.IsAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
|
||||
// Skips in -short mode or when the env var is missing, matching the pattern
|
||||
// used elsewhere (scanner_test.go, etc.).
|
||||
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping api integration in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := db.Migrate(dsn, logger); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
"TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
return &handlers{pool: pool, logger: logger}, pool
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("bcrypt: %v", err)
|
||||
}
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: "test-api-token-" + username,
|
||||
IsAdmin: isAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
seedUser(t, pool, "alice", "hunter2", false)
|
||||
|
||||
body := strings.NewReader(`{"username":"alice","password":"hunter2"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
|
||||
}
|
||||
if resp.Token == "" {
|
||||
t.Error("token empty")
|
||||
}
|
||||
if resp.User.Username != "alice" || resp.User.IsAdmin {
|
||||
t.Errorf("user = %+v, want alice/non-admin", resp.User)
|
||||
}
|
||||
|
||||
var cookieFound bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName {
|
||||
cookieFound = true
|
||||
if !c.HttpOnly {
|
||||
t.Error("session cookie missing HttpOnly")
|
||||
}
|
||||
if c.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("SameSite = %v, want Strict", c.SameSite)
|
||||
}
|
||||
if c.Value != resp.Token {
|
||||
t.Error("cookie value does not match response token")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cookieFound {
|
||||
t.Error("session cookie not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogin_WrongPasswordReturns401(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
seedUser(t, pool, "alice", "hunter2", false)
|
||||
|
||||
body := strings.NewReader(`{"username":"alice","password":"wrong"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
body := strings.NewReader(`{"username":"ghost","password":"whatever"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
|
||||
bytes.NewReader([]byte("not-json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() }
|
||||
|
||||
func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
|
||||
// Manually create a session to log out of.
|
||||
token, err := auth.MintSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashSessionToken(token),
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||
// handleLogout runs behind RequireUser in real routing; simulate that by
|
||||
// putting the user into context here.
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLogout(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204", w.Code)
|
||||
}
|
||||
|
||||
// Cookie should be cleared.
|
||||
var cleared bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Error("session cookie not cleared")
|
||||
}
|
||||
|
||||
// Session row should be gone.
|
||||
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
|
||||
if err == nil {
|
||||
t.Error("session row still present after logout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogout_BearerHeaderWithTrailingWhitespaceDeletesSession(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
|
||||
token, err := auth.MintSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashSessionToken(token),
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||
// Trailing whitespace — RequireUser trims this, so logout must too.
|
||||
req.Header.Set("Authorization", "Bearer "+token+" ")
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||
w := httptest.NewRecorder()
|
||||
h.handleLogout(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204", w.Code)
|
||||
}
|
||||
|
||||
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
|
||||
if err == nil {
|
||||
t.Error("session row still present after bearer logout with trailing whitespace")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// errorBody is the JSON envelope for failures on /api/*. Kept separate from
|
||||
// the Subsonic envelope so native clients don't have to parse two shapes.
|
||||
type errorBody struct {
|
||||
Error errorPayload `json:"error"`
|
||||
}
|
||||
|
||||
type errorPayload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// writeErr is the canonical way to emit a failure. Code is a stable
|
||||
// short-string clients can switch on; message is human-readable.
|
||||
func writeErr(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteErr_EmitsJSONEnvelope(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "field x required")
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("content-type = %q, want application/json", got)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
|
||||
}
|
||||
if body.Error.Code != "bad_request" || body.Error.Message != "field x required" {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
// Hitting /me without RequireUser in front of it is a routing bug;
|
||||
// it can't happen in real traffic.
|
||||
h.logger.Error("api: /me reached without authenticated user")
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "missing auth context")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(UserView{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
IsAdmin: user.IsAdmin,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||
w := httptest.NewRecorder()
|
||||
h.handleGetMe(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got UserView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Username != "alice" || !got.IsAdmin {
|
||||
t.Errorf("user = %+v, want alice/admin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetMe_MissingContextReturns500(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleGetMe(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500 (context should be populated by RequireUser)", w.Code)
|
||||
}
|
||||
_ = dbq.User{} // keep the import used
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import "github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
|
||||
// web and Flutter both send the same payload.
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse carries the opaque session token AND the authenticated user
|
||||
// so the SPA can hydrate its auth store in one round-trip. The token is also
|
||||
// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserView `json:"user"`
|
||||
}
|
||||
|
||||
// UserView is the /api/* view of a user. Narrower than dbq.User — no hash,
|
||||
// no api_token, no subsonic_password.
|
||||
type UserView struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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):])
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) {
|
||||
token, err := MintSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("MintSessionToken: %v", err)
|
||||
}
|
||||
if len(token) < 40 {
|
||||
t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token))
|
||||
}
|
||||
if strings.ContainsAny(token, "+/=") {
|
||||
t.Errorf("token %q contains non-url-safe chars", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) {
|
||||
token := "test-token-xyz"
|
||||
want := sha256.Sum256([]byte(token))
|
||||
|
||||
got := HashSessionToken(token)
|
||||
if len(got) != sha256.Size {
|
||||
t.Fatalf("hash length = %d, want %d", len(got), sha256.Size)
|
||||
}
|
||||
if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) {
|
||||
t.Errorf("hash = %x, want %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromPassword: %v", err)
|
||||
}
|
||||
if !VerifyPassword(string(hash), "hunter2") {
|
||||
t.Error("correct password rejected")
|
||||
}
|
||||
if VerifyPassword(string(hash), "wrong") {
|
||||
t.Error("wrong password accepted")
|
||||
}
|
||||
if VerifyPassword("not-a-hash", "hunter2") {
|
||||
t.Error("malformed hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
|
||||
next := http.HandlerFunc(func(_ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@ type Artist struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
TokenHash []byte
|
||||
UserAgent string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
LastSeenAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID pgtype.UUID
|
||||
Title string
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: sessions.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteSession = `-- name: DeleteSession :exec
|
||||
DELETE FROM sessions WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSession(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSessionByTokenHash = `-- name: DeleteSessionByTokenHash :exec
|
||||
DELETE FROM sessions WHERE token_hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte) error {
|
||||
_, err := q.db.Exec(ctx, deleteSessionByTokenHash, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at FROM sessions WHERE token_hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TokenHash,
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastSeenAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertSession = `-- name: InsertSession :one
|
||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at
|
||||
`
|
||||
|
||||
type InsertSessionParams struct {
|
||||
UserID pgtype.UUID
|
||||
TokenHash []byte
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, insertSession, arg.UserID, arg.TokenHash, arg.UserAgent)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TokenHash,
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastSeenAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
|
||||
UPDATE sessions SET last_seen_at = now() WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchSessionLastSeen(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchSessionLastSeen, id)
|
||||
return err
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS sessions_user_id_idx;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Session tokens authenticate /api/* requests. We store sha256(token) so a
|
||||
-- read-only DB leak doesn't grant active sessions; the raw token lives only
|
||||
-- in the client's cookie (web) or bearer header (Flutter).
|
||||
--
|
||||
-- last_seen_at enables an "active sessions" UI later (not wired in this plan)
|
||||
-- without schema churn. user_agent is captured at issue time for the same
|
||||
-- reason — free metadata now, no migration later.
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
user_agent text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- name: InsertSession :one
|
||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSessionByTokenHash :one
|
||||
SELECT * FROM sessions WHERE token_hash = $1;
|
||||
|
||||
-- name: TouchSessionLastSeen :exec
|
||||
UPDATE sessions SET last_seen_at = now() WHERE id = $1;
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions WHERE id = $1;
|
||||
|
||||
-- name: DeleteSessionByTokenHash :exec
|
||||
DELETE FROM sessions WHERE token_hash = $1;
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
@@ -40,13 +41,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/healthz", s.handleHealthz)
|
||||
|
||||
if s.Pool != nil {
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Route("/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin(s.Pool))
|
||||
if s.Scanner != nil {
|
||||
admin.Post("/scan", s.handleAdminScan)
|
||||
}
|
||||
})
|
||||
api.Mount(r, s.Pool, s.Logger)
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin(s.Pool))
|
||||
if s.Scanner != nil {
|
||||
admin.Post("/scan", s.handleAdminScan)
|
||||
}
|
||||
})
|
||||
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user