From c5db1b9d859d24f8c4de3bf7b819a8a74453be9e Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Thu, 24 Sep 2026 11:41:34 +0200 Subject: [PATCH] feat(auth): require login and password change --- .ai/HANDOFF.md | 28 +++ .ai/REVIEW.md | 22 +++ .ai/TASKS.md | 2 +- Dockerfile | 6 +- README.md | 29 ++- cmd/webfilebrowser/main.go | 24 ++- compose.yaml | 4 + go.mod | 18 ++ go.sum | 49 +++++ internal/auth/store.go | 345 ++++++++++++++++++++++++++++++++++++ internal/auth/store_test.go | 116 ++++++++++++ internal/web/app.go | 215 ++++++++++++++++++++++ internal/web/app_test.go | 155 ++++++++++++++++ 13 files changed, 1006 insertions(+), 7 deletions(-) create mode 100644 go.sum create mode 100644 internal/auth/store.go create mode 100644 internal/auth/store_test.go create mode 100644 internal/web/app.go create mode 100644 internal/web/app_test.go diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 88cc745..e9b05c9 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -78,3 +78,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t | Next Role | none | --- + +### T-002 — dev — 2026-09-24T09:37:48Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added persistent SQLite authentication, secure sliding sessions, login/logout, and the mandatory password-change gate. | +| Files Changed | Dockerfile, README.md, cmd/webfilebrowser/main.go, compose.yaml, go.mod, go.sum, internal/auth/store.go, internal/auth/store_test.go, internal/web/app.go, internal/web/app_test.go, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md | +| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; Docker image build and named-volume startup smoke test passed; E2E not configured. | +| Commit | `feat(auth): require login and password change` | +| Verdict | PASS | +| Blocking Findings | none | +| Next Role | human approval | + +--- + +### T-002 — commit_task — 2026-09-24T09:40:46Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Approved the reviewed T-002 authentication foundation and prepared its single task commit. | +| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md | +| Validation | Review validation recorded in `.ai/REVIEW.md`. | +| Commit | `feat(auth): require login and password change` | +| Next Role | none | + +--- diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index e24904a..f08df90 100644 --- a/.ai/REVIEW.md +++ b/.ai/REVIEW.md @@ -27,3 +27,25 @@ The implementation matches T-001: it supplies a Go module and HTTP-server skeleton, validated file/environment root loading, an example Compose setup and Docker image, and operator documentation for root configuration and restart requirements. + +## T-002 — 2026-09-24T09:37:48Z + +Verdict: PASS + +Findings: none. + +Validation: + +- `go fmt ./...` (in `golang:1.24`): PASS +- `go vet ./...` (in `golang:1.24`): PASS +- `go test ./...` (in `golang:1.24`): PASS +- `docker build --tag webfilebrowser:t002 .`: PASS +- Container startup with a fresh named SQLite volume: PASS; it loaded the + sample root and began serving on `:8080` as the non-root application user. +- E2E: NOT_CONFIGURED; project documentation declares no E2E command. + +The review found that the SQLite migration seeds the temporary administrator +only on initial database creation, credentials use bcrypt, only hashed session +tokens are stored, and every current/future protected path is authenticated +before route handling. The HTTP integration tests cover the forced password +change gate for the root, file, download, stream, and admin paths. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 31e2d6d..843ac6e 100644 --- a/.ai/TASKS.md +++ b/.ai/TASKS.md @@ -21,7 +21,7 @@ Command expectations: | Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | | --- | --- | --- | --- | --- | --- | | T-001 | Project scaffolding: Go module layout, root-config loading (file/env listing container paths + display names), Dockerfile skeleton, docker-compose example, `README.md` overview/setup skeleton | done | `go build ./...` succeeds; app boots and logs the configured roots from a sample config without panicking; `README.md` documents the config file/env format for declaring roots and the volume-mount + restart-to-add-root constraint | PASS: config source/precedence/validation tests; `go fmt ./...`, `go vet ./...`, `go test ./...`, and `go build -buildvcs=false ./...` in `golang:1.24`; Docker image build + sample-config startup-log smoke test | none | -| T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | ready_for_implement | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | n/a | implement | +| T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | done | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | PASS: auth storage/bcrypt/persistence/sliding-session and HTTP login/logout/forced-change/protected-route tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; Docker image build and named-volume startup smoke test | none | | T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | ready_for_implement | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | n/a | implement | | T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | ready_for_implement | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | n/a | implement | | T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | ready_for_implement | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | n/a | implement | diff --git a/Dockerfile b/Dockerfile index 721b53e..f69db5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM golang:1.24-alpine AS build WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ RUN go mod download COPY . ./ @@ -10,7 +10,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/webfile FROM alpine:3.21 -RUN addgroup -S webfilebrowser && adduser -S -G webfilebrowser webfilebrowser +RUN addgroup -S webfilebrowser && adduser -S -G webfilebrowser webfilebrowser \ + && mkdir -p /var/lib/webfilebrowser \ + && chown webfilebrowser:webfilebrowser /var/lib/webfilebrowser COPY --from=build /out/webfilebrowser /usr/local/bin/webfilebrowser diff --git a/README.md b/README.md index 036dd7a..c5279f7 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Docker is the only prerequisite for the scaffold. ``` The server listens on `http://localhost:8080` and logs every configured root -at startup. The scaffold intentionally has no application page yet, so requests -currently receive `404 Not Found`. +at startup. `/login` is the only public application route; every other route +redirects to login until it has a valid server-side session. ## Root configuration @@ -55,6 +55,31 @@ add or remove a root, update both the configuration and the container's volume mounts, then restart the container. Roots are intentionally fixed at startup; they cannot be changed from the application. +## Authentication and persistent data + +The application stores users and server-side sessions in SQLite. By default, +the database is `/var/lib/webfilebrowser/webfilebrowser.db`; set +`WEB_FILE_BROWSER_DATABASE_PATH` to use another path. The Compose example +uses a named volume for this directory, so users and active sessions survive a +container restart. + +On the first database initialization, webFileBrowser creates this temporary +administrator account: + +```text +Email: boss@zecevic-ninja.com +Password: Start1234! +``` + +That account must change its password immediately after the first successful +login. It cannot access browsing, download, streaming, or administration routes +until the new password differs from the temporary password. Sessions are stored +server-side, use a seven-day sliding expiry, and are invalidated on logout. + +The session cookie is always `HttpOnly`, `Secure`, and `SameSite=Lax`. +Terminate TLS at a reverse proxy before exposing the service; browsers will not +send the secure cookie over plain HTTP. + ## AI Workflow This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer. diff --git a/cmd/webfilebrowser/main.go b/cmd/webfilebrowser/main.go index 2083e6a..7216d9f 100644 --- a/cmd/webfilebrowser/main.go +++ b/cmd/webfilebrowser/main.go @@ -7,10 +7,15 @@ import ( "net/http" "os" + "webfilebrowser/internal/auth" "webfilebrowser/internal/config" + "webfilebrowser/internal/web" ) -const defaultAddress = ":8080" +const ( + defaultAddress = ":8080" + defaultDatabasePath = "/var/lib/webfilebrowser/webfilebrowser.db" +) func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) @@ -24,6 +29,21 @@ func main() { logger.Info("configured browse root", "name", root.Name, "path", root.Path) } + databasePath := os.Getenv("WEB_FILE_BROWSER_DATABASE_PATH") + if databasePath == "" { + databasePath = defaultDatabasePath + } + store, err := auth.Open(databasePath, auth.Options{}) + if err != nil { + logger.Error("open authentication database", "error", err) + os.Exit(1) + } + defer func() { + if err := store.Close(); err != nil { + logger.Error("close authentication database", "error", err) + } + }() + address := os.Getenv("WEB_FILE_BROWSER_ADDR") if address == "" { address = defaultAddress @@ -31,7 +51,7 @@ func main() { server := &http.Server{ Addr: address, - Handler: http.NewServeMux(), + Handler: web.New(store), } logger.Info("starting web file browser", "address", address) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/compose.yaml b/compose.yaml index 85f8353..08d7db1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,3 +8,7 @@ services: volumes: - ./config/roots.example.json:/etc/webfilebrowser/roots.json:ro - ${MEDIA_DIR:-./example-files}:/mnt/media:ro + - webfilebrowser-data:/var/lib/webfilebrowser + +volumes: + webfilebrowser-data: diff --git a/go.mod b/go.mod index 720f1f4..50ca2bd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,21 @@ module webfilebrowser go 1.24.0 + +require ( + golang.org/x/crypto v0.36.0 + modernc.org/sqlite v1.36.3 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect + golang.org/x/sys v0.31.0 // indirect + modernc.org/libc v1.61.13 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5dd101d --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= +modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= +modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= +modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.36.3 h1:qYMYlFR+rtLDUzuXoST1SDIdEPbX8xzuhdF90WsX1ss= +modernc.org/sqlite v1.36.3/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..58db730 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,345 @@ +// Package auth manages persistent users and server-side sessions. +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" +) + +const ( + // SeedAdminEmail is the administrator account created with a new database. + SeedAdminEmail = "boss@zecevic-ninja.com" + // SeedAdminPassword is a temporary password that must be changed on first login. + SeedAdminPassword = "Start1234!" + + defaultSessionLifetime = 7 * 24 * time.Hour +) + +var ( + // ErrInvalidCredentials intentionally covers missing, disabled, and invalid users. + ErrInvalidCredentials = errors.New("invalid credentials") + // ErrPasswordUnchanged prevents retaining a temporary password. + ErrPasswordUnchanged = errors.New("new password must differ from the current password") + // ErrSessionNotFound indicates an expired, deleted, or unknown session. + ErrSessionNotFound = errors.New("session not found") +) + +// Role controls a user's access level. +type Role string + +const ( + // RoleAdmin can manage users in the later administration phase. + RoleAdmin Role = "admin" + // RoleUser can browse configured roots after authentication. + RoleUser Role = "user" +) + +// User is the safe, password-hash-free representation of an account. +type User struct { + ID int64 + Email string + Role Role + MustChangePassword bool +} + +// Options customizes the persistent authentication store. +type Options struct { + Clock func() time.Time + SessionLifetime time.Duration +} + +// Store holds SQLite-backed accounts and server-side sessions. +type Store struct { + db *sql.DB + clock func() time.Time + sessionLifetime time.Duration +} + +// Open migrates the SQLite database and seeds the initial administrator when +// the database is created for the first time. +func Open(databasePath string, options Options) (*Store, error) { + clock := options.Clock + if clock == nil { + clock = time.Now + } + sessionLifetime := options.SessionLifetime + if sessionLifetime <= 0 { + sessionLifetime = defaultSessionLifetime + } + + database, err := sql.Open("sqlite", databasePath) + if err != nil { + return nil, fmt.Errorf("open authentication database: %w", err) + } + database.SetMaxOpenConns(1) + if err := database.Ping(); err != nil { + _ = database.Close() + return nil, fmt.Errorf("connect to authentication database: %w", err) + } + if _, err := database.Exec("PRAGMA foreign_keys = ON"); err != nil { + _ = database.Close() + return nil, fmt.Errorf("enable SQLite foreign keys: %w", err) + } + + store := &Store{db: database, clock: clock, sessionLifetime: sessionLifetime} + if err := store.migrate(); err != nil { + _ = database.Close() + return nil, err + } + return store, nil +} + +// Close releases the database connection. +func (store *Store) Close() error { + return store.db.Close() +} + +// SessionLifetime returns the configured server-side sliding-session lifetime. +func (store *Store) SessionLifetime() time.Duration { + return store.sessionLifetime +} + +// Authenticate validates an enabled account without exposing its password hash. +func (store *Store) Authenticate(email, password string) (User, error) { + var user User + var passwordHash string + var mustChangePassword int + var disabled int + err := store.db.QueryRow(` + SELECT id, email, password_hash, role, must_change_password, disabled + FROM users + WHERE email = ? COLLATE NOCASE + `, email).Scan(&user.ID, &user.Email, &passwordHash, &user.Role, &mustChangePassword, &disabled) + if errors.Is(err, sql.ErrNoRows) || disabled != 0 { + return User{}, ErrInvalidCredentials + } + if err != nil { + return User{}, fmt.Errorf("look up user: %w", err) + } + if !CheckPassword(passwordHash, password) { + return User{}, ErrInvalidCredentials + } + user.MustChangePassword = mustChangePassword != 0 + return user, nil +} + +// ChangePassword verifies the current password, rejects reuse, and clears the +// forced-password-change flag. +func (store *Store) ChangePassword(userID int64, currentPassword, newPassword string) error { + var passwordHash string + err := store.db.QueryRow("SELECT password_hash FROM users WHERE id = ? AND disabled = 0", userID).Scan(&passwordHash) + if errors.Is(err, sql.ErrNoRows) { + return ErrInvalidCredentials + } + if err != nil { + return fmt.Errorf("look up password hash: %w", err) + } + if !CheckPassword(passwordHash, currentPassword) { + return ErrInvalidCredentials + } + if CheckPassword(passwordHash, newPassword) { + return ErrPasswordUnchanged + } + + newHash, err := HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash new password: %w", err) + } + if _, err := store.db.Exec(` + UPDATE users + SET password_hash = ?, must_change_password = 0, updated_at = ? + WHERE id = ? + `, newHash, store.nowUnix(), userID); err != nil { + return fmt.Errorf("update password: %w", err) + } + return nil +} + +// CreateSession stores a new random session and returns its cookie value. +func (store *Store) CreateSession(userID int64) (string, error) { + token, err := randomToken() + if err != nil { + return "", fmt.Errorf("generate session token: %w", err) + } + now := store.nowUnix() + _, err = store.db.Exec(` + INSERT INTO sessions (token_hash, user_id, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, tokenHash(token), userID, now+int64(store.sessionLifetime.Seconds()), now, now) + if err != nil { + return "", fmt.Errorf("create session: %w", err) + } + return token, nil +} + +// LookupSession returns the signed-in user and refreshes the server-side +// expiry, creating a seven-day sliding session by default. +func (store *Store) LookupSession(token string) (User, error) { + if token == "" { + return User{}, ErrSessionNotFound + } + + var user User + var mustChangePassword int + var expiresAt int64 + err := store.db.QueryRow(` + SELECT users.id, users.email, users.role, users.must_change_password, sessions.expires_at + FROM sessions + JOIN users ON users.id = sessions.user_id + WHERE sessions.token_hash = ? AND users.disabled = 0 + `, tokenHash(token)).Scan(&user.ID, &user.Email, &user.Role, &mustChangePassword, &expiresAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrSessionNotFound + } + if err != nil { + return User{}, fmt.Errorf("look up session: %w", err) + } + + now := store.nowUnix() + if expiresAt <= now { + if _, err := store.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash(token)); err != nil { + return User{}, fmt.Errorf("remove expired session: %w", err) + } + return User{}, ErrSessionNotFound + } + if _, err := store.db.Exec(` + UPDATE sessions + SET expires_at = ?, updated_at = ? + WHERE token_hash = ? + `, now+int64(store.sessionLifetime.Seconds()), now, tokenHash(token)); err != nil { + return User{}, fmt.Errorf("refresh session: %w", err) + } + + user.MustChangePassword = mustChangePassword != 0 + return user, nil +} + +// DeleteSession invalidates a cookie's session server-side. +func (store *Store) DeleteSession(token string) error { + if token == "" { + return nil + } + if _, err := store.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash(token)); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +// HashPassword creates a bcrypt hash suitable for persistent storage. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword reports whether a bcrypt hash matches a supplied password. +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func (store *Store) migrate() error { + transaction, err := store.db.Begin() + if err != nil { + return fmt.Errorf("begin database migration: %w", err) + } + defer func() { _ = transaction.Rollback() }() + + if _, err := transaction.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY + ) + `); err != nil { + return fmt.Errorf("create migration table: %w", err) + } + + var version int + if err := transaction.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&version); err != nil { + return fmt.Errorf("read schema version: %w", err) + } + if version < 1 { + if err := applyInitialSchema(transaction); err != nil { + return err + } + if err := seedAdmin(transaction, store.nowUnix()); err != nil { + return err + } + if _, err := transaction.Exec("INSERT INTO schema_migrations (version) VALUES (1)"); err != nil { + return fmt.Errorf("record initial schema migration: %w", err) + } + } + + if err := transaction.Commit(); err != nil { + return fmt.Errorf("commit database migration: %w", err) + } + return nil +} + +func applyInitialSchema(transaction *sql.Tx) error { + statements := []string{ + `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'user')), + must_change_password INTEGER NOT NULL CHECK(must_change_password IN (0, 1)), + disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + "CREATE INDEX sessions_expires_at_idx ON sessions(expires_at)", + } + for _, statement := range statements { + if _, err := transaction.Exec(statement); err != nil { + return fmt.Errorf("apply initial schema: %w", err) + } + } + return nil +} + +func seedAdmin(transaction *sql.Tx, now int64) error { + passwordHash, err := HashPassword(SeedAdminPassword) + if err != nil { + return fmt.Errorf("hash seed administrator password: %w", err) + } + if _, err := transaction.Exec(` + INSERT INTO users (email, password_hash, role, must_change_password, disabled, created_at, updated_at) + VALUES (?, ?, ?, 1, 0, ?, ?) + `, SeedAdminEmail, passwordHash, RoleAdmin, now, now); err != nil { + return fmt.Errorf("seed administrator: %w", err) + } + return nil +} + +func (store *Store) nowUnix() int64 { + return store.clock().UTC().Unix() +} + +func randomToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func tokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go new file mode 100644 index 0000000..b2d319c --- /dev/null +++ b/internal/auth/store_test.go @@ -0,0 +1,116 @@ +package auth + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +func TestSeedAdminAuthenticatesAndRequiresPasswordChange(t *testing.T) { + store := openTestStore(t, t.TempDir()) + + user, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword) + if err != nil { + t.Fatalf("Authenticate(seed admin) error = %v", err) + } + if user.Role != RoleAdmin { + t.Errorf("seed admin role = %q, want %q", user.Role, RoleAdmin) + } + if !user.MustChangePassword { + t.Error("seed admin MustChangePassword = false, want true") + } + + if _, err := store.Authenticate(SeedAdminEmail, "wrong-password"); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("Authenticate(wrong password) error = %v, want ErrInvalidCredentials", err) + } +} + +func TestPasswordHashingAndChangePassword(t *testing.T) { + password := "Start1234!" + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if hash == password { + t.Error("HashPassword() returned the plaintext password") + } + if !CheckPassword(hash, password) { + t.Error("CheckPassword() = false for the password used to create the hash") + } + if CheckPassword(hash, "different-password") { + t.Error("CheckPassword() = true for a different password") + } + + store := openTestStore(t, t.TempDir()) + user, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword) + if err != nil { + t.Fatal(err) + } + if err := store.ChangePassword(user.ID, SeedAdminPassword, SeedAdminPassword); !errors.Is(err, ErrPasswordUnchanged) { + t.Errorf("ChangePassword(same password) error = %v, want ErrPasswordUnchanged", err) + } + + const newPassword = "A-new-password-123!" + if err := store.ChangePassword(user.ID, SeedAdminPassword, newPassword); err != nil { + t.Fatalf("ChangePassword() error = %v", err) + } + if _, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("Authenticate(old password) error = %v, want ErrInvalidCredentials", err) + } + updatedUser, err := store.Authenticate(SeedAdminEmail, newPassword) + if err != nil { + t.Fatalf("Authenticate(new password) error = %v", err) + } + if updatedUser.MustChangePassword { + t.Error("MustChangePassword remains true after a password change") + } +} + +func TestSessionsPersistAcrossStoreRestartAndSlideExpiry(t *testing.T) { + now := time.Date(2026, time.September, 24, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + databasePath := filepath.Join(t.TempDir(), "webfilebrowser.db") + + store, err := Open(databasePath, Options{Clock: clock, SessionLifetime: 7 * 24 * time.Hour}) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + user, err := store.Authenticate(SeedAdminEmail, SeedAdminPassword) + if err != nil { + t.Fatal(err) + } + token, err := store.CreateSession(user.ID) + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + now = now.Add(6 * 24 * time.Hour) + reopenedStore, err := Open(databasePath, Options{Clock: clock, SessionLifetime: 7 * 24 * time.Hour}) + if err != nil { + t.Fatalf("Open() after restart error = %v", err) + } + t.Cleanup(func() { _ = reopenedStore.Close() }) + + if _, err := reopenedStore.LookupSession(token); err != nil { + t.Fatalf("LookupSession() after restart error = %v", err) + } + + now = now.Add(2 * 24 * time.Hour) + if _, err := reopenedStore.LookupSession(token); err != nil { + t.Fatalf("LookupSession() after sliding expiry error = %v", err) + } +} + +func openTestStore(t *testing.T, directory string) *Store { + t.Helper() + store, err := Open(filepath.Join(directory, "webfilebrowser.db"), Options{}) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} diff --git a/internal/web/app.go b/internal/web/app.go new file mode 100644 index 0000000..23108f9 --- /dev/null +++ b/internal/web/app.go @@ -0,0 +1,215 @@ +// Package web provides the login-gated HTTP surface for the application. +package web + +import ( + "errors" + "fmt" + "html/template" + "net/http" + "time" + + "webfilebrowser/internal/auth" +) + +const ( + loginPath = "/login" + logoutPath = "/logout" + changePasswordPath = "/change-password" + sessionCookieName = "webfilebrowser_session" +) + +var ( + loginTemplate = template.Must(template.New("login").Parse(` +Sign in +

Sign in

{{if .Error}}

{{.Error}}

{{end}} +
+
`)) + changePasswordTemplate = template.Must(template.New("change-password").Parse(` +Change password +

Change your password

You must choose a new password before continuing.

{{if .Error}}

{{.Error}}

{{end}} +
+
`)) +) + +// New returns an HTTP handler that requires a valid server-side session for +// every route other than the login endpoint. +func New(store *auth.Store) http.Handler { + return &application{store: store} +} + +type application struct { + store *auth.Store +} + +func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == loginPath { + app.handleLogin(writer, request) + return + } + + user, token, ok := app.currentUser(writer, request) + if !ok { + return + } + if request.URL.Path == logoutPath { + app.handleLogout(writer, request, token) + return + } + if user.MustChangePassword { + if request.URL.Path != changePasswordPath { + http.Redirect(writer, request, changePasswordPath, http.StatusSeeOther) + return + } + app.handleChangePassword(writer, request, user) + return + } + if request.URL.Path == changePasswordPath { + http.Redirect(writer, request, "/", http.StatusSeeOther) + return + } + if request.URL.Path == "/" { + app.handleHome(writer, request, user) + return + } + + http.NotFound(writer, request) +} + +func (app *application) handleLogin(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + renderLogin(writer, http.StatusOK, "") + case http.MethodPost: + if err := request.ParseForm(); err != nil { + renderLogin(writer, http.StatusBadRequest, "Unable to read sign-in details.") + return + } + user, err := app.store.Authenticate(request.FormValue("email"), request.FormValue("password")) + if err != nil { + renderLogin(writer, http.StatusUnauthorized, "Invalid email or password.") + return + } + token, err := app.store.CreateSession(user.ID) + if err != nil { + http.Error(writer, "Unable to create session.", http.StatusInternalServerError) + return + } + app.setSessionCookie(writer, token) + destination := "/" + if user.MustChangePassword { + destination = changePasswordPath + } + http.Redirect(writer, request, destination, http.StatusSeeOther) + default: + writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + } +} + +func (app *application) handleChangePassword(writer http.ResponseWriter, request *http.Request, user auth.User) { + switch request.Method { + case http.MethodGet: + renderChangePassword(writer, http.StatusOK, "") + case http.MethodPost: + if err := request.ParseForm(); err != nil { + renderChangePassword(writer, http.StatusBadRequest, "Unable to read password details.") + return + } + err := app.store.ChangePassword(user.ID, request.FormValue("current_password"), request.FormValue("new_password")) + if errors.Is(err, auth.ErrPasswordUnchanged) { + renderChangePassword(writer, http.StatusBadRequest, "Your new password must differ from the current password.") + return + } + if errors.Is(err, auth.ErrInvalidCredentials) { + renderChangePassword(writer, http.StatusBadRequest, "Your current password is incorrect.") + return + } + if err != nil { + http.Error(writer, "Unable to change password.", http.StatusInternalServerError) + return + } + http.Redirect(writer, request, "/", http.StatusSeeOther) + default: + writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + } +} + +func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) { + if request.Method != http.MethodPost { + writer.Header().Set("Allow", http.MethodPost) + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + return + } + if err := app.store.DeleteSession(token); err != nil { + http.Error(writer, "Unable to end session.", http.StatusInternalServerError) + return + } + http.SetCookie(writer, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(writer, request, loginPath, http.StatusSeeOther) +} + +func (app *application) handleHome(writer http.ResponseWriter, _ *http.Request, user auth.User) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(writer, "webFileBrowser

webFileBrowser

File browser is being prepared.

Signed in as %s.

", template.HTMLEscapeString(user.Email)) +} + +func (app *application) currentUser(writer http.ResponseWriter, request *http.Request) (auth.User, string, bool) { + cookie, err := request.Cookie(sessionCookieName) + if err != nil { + http.Redirect(writer, request, loginPath, http.StatusSeeOther) + return auth.User{}, "", false + } + user, err := app.store.LookupSession(cookie.Value) + if err != nil { + app.clearSessionCookie(writer) + http.Redirect(writer, request, loginPath, http.StatusSeeOther) + return auth.User{}, "", false + } + app.setSessionCookie(writer, cookie.Value) + return user, cookie.Value, true +} + +func (app *application) setSessionCookie(writer http.ResponseWriter, token string) { + http.SetCookie(writer, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + Expires: time.Now().Add(app.store.SessionLifetime()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (app *application) clearSessionCookie(writer http.ResponseWriter) { + http.SetCookie(writer, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func renderLogin(writer http.ResponseWriter, status int, message string) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.WriteHeader(status) + _ = loginTemplate.Execute(writer, struct{ Error string }{Error: message}) +} + +func renderChangePassword(writer http.ResponseWriter, status int, message string) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.WriteHeader(status) + _ = changePasswordTemplate.Execute(writer, struct{ Error string }{Error: message}) +} diff --git a/internal/web/app_test.go b/internal/web/app_test.go new file mode 100644 index 0000000..be07c6a --- /dev/null +++ b/internal/web/app_test.go @@ -0,0 +1,155 @@ +package web + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "webfilebrowser/internal/auth" +) + +func TestUnauthenticatedRequestsNeverReachProtectedRoutes(t *testing.T) { + _, handler := newTestApplication(t) + + for _, target := range []string{"/", "/files/media", "/download/media/report.pdf", "/stream/media/video.mp4", "/admin/users"} { + t.Run(target, func(t *testing.T) { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil)) + + if response.Code != http.StatusSeeOther { + t.Errorf("GET %s status = %d, want %d", target, response.Code, http.StatusSeeOther) + } + if location := response.Header().Get("Location"); location != loginPath { + t.Errorf("GET %s Location = %q, want %q", target, location, loginPath) + } + if strings.Contains(response.Body.String(), "File browser is being prepared") { + t.Errorf("GET %s returned protected content", target) + } + }) + } +} + +func TestLoginForcesPasswordChangeBeforeAccessingOtherRoutes(t *testing.T) { + _, handler := newTestApplication(t) + + loginResponse := postForm(handler, loginPath, url.Values{ + "email": {auth.SeedAdminEmail}, + "password": {auth.SeedAdminPassword}, + }, nil) + if loginResponse.Code != http.StatusSeeOther { + t.Fatalf("POST %s status = %d, want %d", loginPath, loginResponse.Code, http.StatusSeeOther) + } + if location := loginResponse.Header().Get("Location"); location != changePasswordPath { + t.Fatalf("POST %s Location = %q, want %q", loginPath, location, changePasswordPath) + } + cookie := responseCookie(t, loginResponse, sessionCookieName) + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode { + t.Errorf("session cookie security attributes = HttpOnly:%t Secure:%t SameSite:%v, want true:true:Lax", cookie.HttpOnly, cookie.Secure, cookie.SameSite) + } + + for _, target := range []string{"/", "/files/media", "/download/media/report.pdf", "/stream/media/video.mp4", "/admin/users"} { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, target, nil) + request.AddCookie(cookie) + handler.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || response.Header().Get("Location") != changePasswordPath { + t.Errorf("GET %s during forced change = (%d, %q), want (%d, %q)", target, response.Code, response.Header().Get("Location"), http.StatusSeeOther, changePasswordPath) + } + } + + samePasswordResponse := postForm(handler, changePasswordPath, url.Values{ + "current_password": {auth.SeedAdminPassword}, + "new_password": {auth.SeedAdminPassword}, + }, cookie) + if samePasswordResponse.Code != http.StatusBadRequest { + t.Errorf("POST %s with unchanged password status = %d, want %d", changePasswordPath, samePasswordResponse.Code, http.StatusBadRequest) + } + + changedPasswordResponse := postForm(handler, changePasswordPath, url.Values{ + "current_password": {auth.SeedAdminPassword}, + "new_password": {"A-new-password-123!"}, + }, cookie) + if changedPasswordResponse.Code != http.StatusSeeOther || changedPasswordResponse.Header().Get("Location") != "/" { + t.Errorf("POST %s status/location = (%d, %q), want (%d, %q)", changePasswordPath, changedPasswordResponse.Code, changedPasswordResponse.Header().Get("Location"), http.StatusSeeOther, "/") + } + + rootResponse := httptest.NewRecorder() + rootRequest := httptest.NewRequest(http.MethodGet, "/", nil) + rootRequest.AddCookie(cookie) + handler.ServeHTTP(rootResponse, rootRequest) + if rootResponse.Code != http.StatusOK { + t.Errorf("GET / after changing password status = %d, want %d", rootResponse.Code, http.StatusOK) + } +} + +func TestLogoutDeletesTheServerSession(t *testing.T) { + _, handler := newTestApplication(t) + cookie := loginSeedAdmin(t, handler) + + logoutResponse := postForm(handler, logoutPath, nil, cookie) + if logoutResponse.Code != http.StatusSeeOther || logoutResponse.Header().Get("Location") != loginPath { + t.Fatalf("POST %s status/location = (%d, %q), want (%d, %q)", logoutPath, logoutResponse.Code, logoutResponse.Header().Get("Location"), http.StatusSeeOther, loginPath) + } + + protectedResponse := httptest.NewRecorder() + protectedRequest := httptest.NewRequest(http.MethodGet, "/", nil) + protectedRequest.AddCookie(cookie) + handler.ServeHTTP(protectedResponse, protectedRequest) + if protectedResponse.Code != http.StatusSeeOther || protectedResponse.Header().Get("Location") != loginPath { + t.Errorf("GET / after logout status/location = (%d, %q), want (%d, %q)", protectedResponse.Code, protectedResponse.Header().Get("Location"), http.StatusSeeOther, loginPath) + } +} + +func newTestApplication(t *testing.T) (*auth.Store, http.Handler) { + t.Helper() + store, err := auth.Open(filepath.Join(t.TempDir(), "webfilebrowser.db"), auth.Options{}) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return store, New(store) +} + +func loginSeedAdmin(t *testing.T, handler http.Handler) *http.Cookie { + t.Helper() + response := postForm(handler, loginPath, url.Values{ + "email": {auth.SeedAdminEmail}, + "password": {auth.SeedAdminPassword}, + }, nil) + if response.Code != http.StatusSeeOther { + t.Fatalf("POST %s status = %d, want %d", loginPath, response.Code, http.StatusSeeOther) + } + return responseCookie(t, response, sessionCookieName) +} + +func postForm(handler http.Handler, target string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { + var body io.Reader + if values != nil { + body = bytes.NewBufferString(values.Encode()) + } + request := httptest.NewRequest(http.MethodPost, target, body) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if cookie != nil { + request.AddCookie(cookie) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func responseCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie { + t.Helper() + for _, cookie := range response.Result().Cookies() { + if cookie.Name == name { + return cookie + } + } + t.Fatalf("response did not set cookie %q", name) + return nil +}