diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4c55a3e..141f0f5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -58,6 +58,16 @@ Plans: **User-in-loop:** Approve the `users` and `sessions` table schemas (columns, indexes, deletion semantics) before sqlc generation. Approve hash algorithm choice. +**Plans:** 7 plans +Plans: +- [ ] 02-01-PLAN.md — Schema + sqlc + auth-package skeleton (citext + users + sessions, test DB harness) +- [ ] 02-02-PLAN.md — argon2id password hashing (TDD: Hash/Verify with PHC encoding) +- [ ] 02-03-PLAN.md — Session store + cookie + ResolveSession/RequireAuth/RedirectIfAuthed middleware +- [ ] 02-04-PLAN.md — Signup vertical slice (form → validate → hash → InsertUser → session → cookie → redirect) +- [ ] 02-05-PLAN.md — Login vertical slice + in-memory rate limiter (AUTH-07) +- [ ] 02-06-PLAN.md — Logout + protect GET / + layout header logout button +- [ ] 02-07-PLAN.md — Mount gorilla/csrf + @ui.CSRFField templ helper across every form (AUTH-06) + ### Phase 3: Tablos CRUD **Goal:** A logged-in user can list, create, view, edit, and delete their tablos end-to-end through HTMX-driven flows. **Mode:** mvp diff --git a/.planning/phases/02-authentication/02-07-PLAN.md b/.planning/phases/02-authentication/02-07-PLAN.md new file mode 100644 index 0000000..3b9798d --- /dev/null +++ b/.planning/phases/02-authentication/02-07-PLAN.md @@ -0,0 +1,288 @@ +--- +phase: 02-authentication +plan: 07 +type: execute +wave: 6 +depends_on: [01, 03, 04, 05, 06] +files_modified: + - backend/internal/auth/csrf.go + - backend/internal/web/ui/csrf_field.templ + - backend/internal/web/router.go + - backend/internal/web/handlers_auth.go + - backend/internal/web/handlers_auth_test.go + - backend/internal/web/handlers.go + - backend/templates/auth_login.templ + - backend/templates/auth_signup.templ + - backend/templates/auth_form_errors.templ + - backend/templates/layout.templ + - backend/templates/index.templ + - backend/cmd/web/main.go + - backend/.env.example + - backend/go.mod + - backend/go.sum +autonomous: true +requirements: [AUTH-06] +tags: [go, csrf, gorilla, htmx, templ, security-hardening] +must_haves: + truths: + - "gorilla/csrf v1.7.3 middleware is mounted on the chi stack AFTER ResolveSession and BEFORE any route group (D-14, D-24)" + - "csrf.Protect is configured with Secure(env != \"dev\"), SameSite(csrf.SameSiteLaxMode), Path(\"/\"), FieldName(\"_csrf\"), RequestHeader(\"X-CSRF-Token\") (D-14)" + - "The csrf authentication key is a 32-byte value loaded from env SESSION_SECRET; main.go fails fast if it is missing or wrong length (D-15 \"key MUST come from env\")" + - "A reusable templ component ui.CSRFField(token) renders (D-15)" + - "Every templ form (signup, login, login fragment, signup fragment, layout logout) embeds @ui.CSRFField(token) as the first child of
" + - "GET handlers that render forms (signup page, login page, fragments, layout) thread csrf.Token(r) into the templ Layout/page calls" + - "POST /signup, POST /login, POST /logout without a valid _csrf token return 403 Forbidden (gorilla/csrf default response)" + - "POST /signup, POST /login, POST /logout WITH a valid _csrf token continue to the normal flow (200/303 etc.)" + - "Handlers read form fields via r.PostFormValue ONLY (never re-reading r.Body) so they coexist with gorilla/csrf's body consumption (Pitfall 1)" + artifacts: + - path: backend/internal/auth/csrf.go + provides: "Mount(env string, secret []byte) func(http.Handler) http.Handler helper that constructs csrf.Protect with the locked options" + contains: "csrf.Protect" + - path: backend/internal/web/ui/csrf_field.templ + provides: "ui.CSRFField(token string) templ component" + contains: "templ CSRFField" + - path: backend/internal/web/router.go + provides: "r.Use(auth.Mount(env, csrfKey)) inserted after ResolveSession and before route groups" + - path: backend/cmd/web/main.go + provides: "Loads SESSION_SECRET from env (hex-decode 32 bytes); fails fast on missing/short key; passes []byte secret + env into NewRouter" + - path: backend/.env.example + provides: "Documents SESSION_SECRET with `openssl rand -hex 32` instruction" + key_links: + - from: backend/internal/web/router.go + to: backend/internal/auth/csrf.go + via: "r.Use(auth.Mount(env, csrfKey)) immediately after ResolveSession" + pattern: "auth\\.Mount|csrf\\.Protect" + - from: backend/templates/layout.templ + to: backend/internal/web/ui/csrf_field.templ + via: "@ui.CSRFField(csrfToken) inside the logout form when user != nil" + pattern: "ui\\.CSRFField|CSRFField\\(" + - from: backend/internal/web/handlers_auth.go + to: backend/internal/web/ui/csrf_field.templ + via: "csrf.Token(r) -> templ page/fragment arg -> @ui.CSRFField(token)" + pattern: "csrf\\.Token\\(r\\)" +--- + + +Mount gorilla/csrf on the chi stack and wire every templ form to embed the hidden _csrf field. Close AUTH-06. + +Purpose: AUTH-06 closure. Before this plan, POST /signup, POST /login, POST /logout work without a CSRF check — meaning a malicious cross-origin or stale-tab POST could trigger state changes. After this plan, every state-changing request requires a valid token, and every form ships one by construction. SameSite=Lax provides an interim defense before this lands; gorilla/csrf is the real fix. +Output: csrf-protected POST routes, a reusable @ui.CSRFField templ component, env-driven csrf key, and integration tests proving 403-on-missing / 200-on-valid for all three POST routes. + + + +@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md +@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/02-authentication/02-CONTEXT.md +@.planning/phases/02-authentication/02-RESEARCH.md +@.planning/phases/02-authentication/02-04-PLAN.md +@.planning/phases/02-authentication/02-05-PLAN.md +@.planning/phases/02-authentication/02-06-PLAN.md +@backend/internal/web/router.go +@backend/internal/web/handlers_auth.go +@backend/internal/web/handlers.go +@backend/templates/layout.templ +@backend/templates/auth_login.templ +@backend/templates/auth_signup.templ +@backend/templates/auth_form_errors.templ +@backend/templates/index.templ +@backend/cmd/web/main.go +@backend/.env.example + + +New / changed surfaces this plan introduces: + +- `backend/internal/auth/csrf.go`: + - `func Mount(env string, key []byte) func(http.Handler) http.Handler` — wraps `csrf.Protect(key, csrf.Secure(env != "dev"), csrf.SameSite(csrf.SameSiteLaxMode), csrf.Path("/"), csrf.FieldName("_csrf"), csrf.RequestHeader("X-CSRF-Token"))`. Returned middleware is what gets passed to `r.Use(...)`. + +- `backend/internal/web/ui/csrf_field.templ`: + - `templ CSRFField(token string) { }` + - Lives alongside the existing Button/Card/Badge components established in Phase 1. + +- Every templ form-rendering helper acquires a `csrfToken string` arg: + - `templ LoginPage(token string, errors map[string]string)` + - `templ LoginFormFragment(token string, errors map[string]string)` + - `templ SignupPage(token string, errors map[string]string)` + - `templ SignupFormFragment(token string, errors map[string]string)` + - `templ Layout(title string, user *auth.User, csrfToken string)` — the logout form needs the token even though the page itself may be the Index. + - `templ Index(user *auth.User, csrfToken string)` + +- Handlers thread `csrf.Token(r)` into every page/fragment render call: + - `LoginPageHandler`, `SignupPageHandler`, `IndexHandler` and the error-fragment branches of `LoginPostHandler` / `SignupPostHandler`. + +- `cmd/web/main.go` loads `SESSION_SECRET` (hex-encoded 32 bytes) → `hex.DecodeString` → []byte; logs a fatal error if missing or len != 32. Pass the []byte and `env` string into `NewRouter`. + +- `NewRouter` signature acquires `csrfKey []byte` and `env string` params (if not already present from earlier plans) and calls `r.Use(auth.Mount(env, csrfKey))` immediately after `r.Use(auth.ResolveSession(store, cookieName))`. + + + + + + + Task 1: Mount gorilla/csrf, add ui.CSRFField, wire every form, env-driven key + + backend/internal/web/router.go + backend/internal/web/handlers_auth.go + backend/internal/web/handlers.go + backend/templates/layout.templ + backend/templates/auth_login.templ + backend/templates/auth_signup.templ + backend/templates/auth_form_errors.templ + backend/templates/index.templ + backend/cmd/web/main.go + backend/.env.example + backend/internal/web/ui/ (existing Button/Card/Badge components — match the directory layout and templ file style) + .planning/phases/02-authentication/02-RESEARCH.md (Pattern 7 — gorilla/csrf integration; Pitfall 1 — body consumption; Pitfall 2 — token absent on GET-rendered forms; Pitfall 7 — middleware order) + .planning/phases/02-authentication/02-CONTEXT.md (D-14, D-15, D-24) + .planning/phases/02-authentication/02-06-PLAN.md (Layout signature change to *auth.User; this plan extends it with csrfToken) + + + Tests added to backend/internal/web/handlers_auth_test.go (extending Plans 04/05/06 suite): + + - TestCSRF_LoginMissingToken: POST /login with valid email+password but NO `_csrf` field → 403. No session row created. + - TestCSRF_LoginValidToken: GET /login first to receive the csrf cookie; parse the cookie + render-extracted token; POST /login with `_csrf` set → 200/303 (normal flow). Pre-seeded user with known argon2 password. + - TestCSRF_SignupMissingToken: POST /signup without `_csrf` → 403. SELECT COUNT(*) FROM users WHERE email = $1 == 0. + - TestCSRF_SignupValidToken: GET /signup → POST /signup with valid token → 303 to /, user row inserted. + - TestCSRF_LogoutMissingToken: pre-seed session; POST /logout WITH session cookie but WITHOUT _csrf → 403. SELECT COUNT(*) FROM sessions WHERE id = $1 == 1 (NOT deleted). + - TestCSRF_LogoutValidToken: full GET / → POST /logout with token → 303, session row deleted. + - TestCSRF_HeaderFallback: POST /login with `X-CSRF-Token` header (no form field) → token accepted (verifies `csrf.RequestHeader("X-CSRF-Token")` wiring for future HTMX hx-headers usage). + - TestForms_ContainCSRFField (templ smoke): render LoginPage("abc", nil), SignupPage("abc", nil), Layout("X", &auth.User{Email:"x@y"}, "abc") to a buffer and assert each contains `name="_csrf"` AND `value="abc"`. Login fragment and signup fragment also covered. + - TestRouter_CSRFMountedAfterResolveSession: a unit-level inspection — assert that NewRouter wires `auth.ResolveSession` before `auth.Mount` (D-24). Implementation: render a request that proves order, OR (simpler) read router.go source via the test using os.ReadFile and assert the substring `ResolveSession` appears before `auth.Mount` / `csrf.Protect`. Choose the source-file scan — deterministic and cheap. + - TestMain_FailsFastOnMissingSecret: this is a build-time / startup-time assertion — added as a `TestLoadCSRFKey_*` set in a new `backend/cmd/web/main_test.go` (or `backend/internal/web/csrf_key_test.go` if main.go is too small to support a separate test target). Cases: missing env → error; len != 32 → error; valid hex 64-char → returns []byte{32}. + + + 1. `go get github.com/gorilla/csrf@v1.7.3` (run inside backend/). Confirm go.sum updated. + + 2. Create `backend/internal/auth/csrf.go`: + - Package `auth`. Import `github.com/gorilla/csrf` and `net/http`. + - Export `func Mount(env string, key []byte) func(http.Handler) http.Handler` that returns + `csrf.Protect(key, csrf.Secure(env != "dev"), csrf.SameSite(csrf.SameSiteLaxMode), csrf.Path("/"), csrf.FieldName("_csrf"), csrf.RequestHeader("X-CSRF-Token"))`. + - Export `func LoadKeyFromEnv() ([]byte, error)` that reads `SESSION_SECRET`, hex-decodes, validates `len == 32`. Return sentinel error `ErrCSRFKeyInvalid` for empty or wrong-length input. `cmd/web/main.go` calls this and `log.Fatal`s on error. + + 3. Create `backend/internal/web/ui/csrf_field.templ`: + - `package ui` + - `templ CSRFField(token string) { }` + - Run `templ generate` after writing. + + 4. Update `backend/templates/layout.templ`: + - Signature: `templ Layout(title string, user *auth.User, csrfToken string)`. + - Inside the logout ``, FIRST child is `@ui.CSRFField(csrfToken)`. The button stays as the second child. + - Import path: align with how Button/Card/Badge are imported in existing templates. + + 5. Update `backend/templates/auth_login.templ`: + - `LoginPage(token string, errors map[string]string)` and `LoginFormFragment(token string, errors map[string]string)`. + - Each `` has `@ui.CSRFField(token)` as the first child. + - `LoginPage` wraps `@Layout("Sign in", nil, token)`. + + 6. Update `backend/templates/auth_signup.templ`: + - `SignupPage(token string, errors map[string]string)` and `SignupFormFragment(token string, errors map[string]string)`. + - Same pattern: `@ui.CSRFField(token)` first child of every form; `@Layout("Sign up", nil, token)`. + + 7. Update `backend/templates/auth_form_errors.templ` if it owns a `` shell: thread `token string` through and embed `@ui.CSRFField(token)`. If it only renders error markup, no change needed. + + 8. Update `backend/templates/index.templ`: + - `Index(user *auth.User, csrfToken string)` → wraps `@Layout("Xtablo", user, csrfToken)`. + + 9. Update `backend/internal/web/handlers.go`: + - `IndexHandler()` reads `auth.Authed(r.Context())` for user AND `csrf.Token(r)` for the token, then `templates.Index(user, csrf.Token(r)).Render(...)`. + + 10. Update `backend/internal/web/handlers_auth.go` — every page/fragment render call now passes `csrf.Token(r)`: + - `SignupPageHandler`: `templates.SignupPage(csrf.Token(r), nil).Render(...)`. + - `SignupPostHandler`: on validation error / duplicate-email branch, render `SignupPage` or `SignupFormFragment` with `csrf.Token(r)`. Success branch (303 redirect) does not render templ. + - `LoginPageHandler`: same pattern with `LoginPage`. + - `LoginPostHandler`: same pattern with `LoginPage` / `LoginFormFragment` on error branches. + - `LogoutHandler`: no templ render; unaffected except its form must now arrive with `_csrf` (handled in layout.templ). + - **Pitfall 1:** Audit every handler in this file. They MUST read form values via `r.PostFormValue(...)` only — never `io.ReadAll(r.Body)` or `json.NewDecoder(r.Body)`. Existing Plans 04/05 already use `r.PostFormValue` per their action sections; this is a regression guard. + + 11. Update `backend/internal/web/router.go`: + - After `r.Use(auth.ResolveSession(store, cookieName))` and BEFORE any route group: `r.Use(auth.Mount(env, csrfKey))`. + - The protected and public route groups stay as in Plan 06; csrf.Protect runs across both. + - Add a comment above each `r.Use` referencing D-24 locked order. + + 12. Update `backend/cmd/web/main.go`: + - Load `SESSION_SECRET` via `auth.LoadKeyFromEnv()`. On error, `log.Fatalf("invalid SESSION_SECRET: %v ; generate with `openssl rand -hex 32`", err)`. + - Pass `[]byte` key and `env` string into `NewRouter`. + - Update `NewRouter` signature accordingly (if not already accepting these params from Plan 04/06 — read router.go first, extend additively). + + 13. Update `backend/.env.example`: + - Add `SESSION_SECRET=` line with a comment: `# 32 random bytes hex-encoded — generate with: openssl rand -hex 32`. + - Add a placeholder or leave blank value; clearly NOT a real key. + + 14. Run `cd backend && templ generate && go build ./...` and fix any signature drift in call sites (Phase 1 + Plans 04..06 tests may reference old `Layout` / `Index` signatures). + + Anti-pattern guards: + - `csrf.Mount` must be AFTER `auth.ResolveSession` (D-24, Pitfall 7). + - Every `` in `.templ` files MUST contain `@ui.CSRFField(token)` as a literal first child (Pitfall 2). Acceptance grep enforces. + - Handlers MUST NOT touch `r.Body` directly (Pitfall 1). + - csrf key MUST come from env, not a compile-time constant (D-15). + - Cookie name for csrf is gorilla's default `_gorilla_csrf` — do NOT override; D-12 only governs the session cookie name. + + + cd backend && templ generate && go build ./... && TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/web/ ./internal/auth/ ./templates/ ./cmd/web/ -count=1 -run "TestCSRF|TestForms_ContainCSRFField|TestRouter_CSRFMountedAfterResolveSession|TestLoadCSRFKey|TestSignup|TestLogin|TestLogout|TestProtected|TestLayout" 2>&1 | tee /tmp/p07.log | tail -80 && ! grep -E "^FAIL|^--- FAIL" /tmp/p07.log + + + - `go.mod` contains `github.com/gorilla/csrf v1.7.3`. + - `backend/internal/auth/csrf.go` exists; `grep -c "func Mount" backend/internal/auth/csrf.go == 1`; `grep -c "csrf.Protect" backend/internal/auth/csrf.go == 1`; `grep -c "csrf.Secure" backend/internal/auth/csrf.go == 1`; `grep -c "csrf.SameSiteLaxMode" backend/internal/auth/csrf.go == 1`; `grep -c "LoadKeyFromEnv" backend/internal/auth/csrf.go == 1`. + - `backend/internal/web/ui/csrf_field.templ` exists; `grep -c "templ CSRFField" backend/internal/web/ui/csrf_field.templ == 1`; `grep -c 'name="_csrf"' backend/internal/web/ui/csrf_field.templ == 1`. + - Every templ file with a `` contains `@ui.CSRFField(`. Concretely, for each f in {layout.templ, auth_login.templ, auth_signup.templ}: `grep -v '^//' $f | awk '//' | grep -c 'ui.CSRFField\|CSRFField(' >= 1`. + - `backend/internal/web/router.go` has BOTH `auth.ResolveSession` and `auth.Mount` (or `csrf.Protect`) — and ResolveSession appears on an earlier line: `awk '/auth\.ResolveSession/{r=NR} /auth\.Mount|csrf\.Protect/{c=NR} END{exit !(r>0 && c>0 && r= 1`. + - `backend/.env.example` mentions `SESSION_SECRET`: `grep -c "SESSION_SECRET" backend/.env.example >= 1`. + - `cd backend && templ generate` exits 0. + - `cd backend && go build ./...` exits 0. + - `cd backend && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web/ ./internal/auth/ ./cmd/web/ -run "TestCSRF|TestForms_ContainCSRFField|TestRouter_CSRFMountedAfterResolveSession|TestLoadCSRFKey" -count=1` exits 0 with all named tests PASS. + - Existing Plan 04/05/06 tests (TestSignup_*, TestLogin_*, TestLogout_*, TestProtected_*, TestLayout_*) still PASS — they must be updated to fetch a valid `_csrf` token (from GET response cookie + form-rendered hidden input) and include it on every POST. The plan executor MUST update those tests in-place rather than disable them. + - Grep gate forbidding bare body reads in auth handlers: `grep -E "io.ReadAll\\(r\\.Body|json\\.NewDecoder\\(r\\.Body" backend/internal/web/handlers_auth.go | grep -v '^#' | wc -l` returns 0. + + AUTH-06 closed. Every state-changing route in Phase 2 (POST /signup, POST /login, POST /logout) requires a valid CSRF token; every form ships one by construction via @ui.CSRFField. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Cross-origin attacker → user's authenticated browser → POST | gorilla/csrf double-submit + SameSite=Lax cuts both same-site and cross-site CSRF | +| Form submission → csrf.Protect → handler | Middleware must run BEFORE handler; ordering is load-bearing (D-24, Pitfall 7) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-2-08 | Tampering | CSRF on POST /signup, POST /login, POST /logout | mitigate | gorilla/csrf v1.7.3 mounted on the chi stack with `csrf.Secure(env != "dev")` + `csrf.SameSiteLaxMode`; every templ form ships `@ui.CSRFField(token)` as its first child. Verified by TestCSRF_*MissingToken (403) and TestCSRF_*ValidToken (200/303). | +| T-2-08a | Tampering | Hidden CSRF input missing from a future form | mitigate | Acceptance grep gates require `@ui.CSRFField` inside every `` in templ files. Reusable `ui.CSRFField` component makes the correct path the path of least resistance. | +| T-2-08b | Information disclosure | csrf key stored in repo or compile-time constant | mitigate | `auth.LoadKeyFromEnv()` reads `SESSION_SECRET` from env; main.go fails fast on missing/short key. `.env.example` documents the value without a real key. | +| T-2-08c | Spoofing | Body consumed by csrf middleware → handler sees empty form | mitigate | Handlers exclusively use `r.PostFormValue(...)`; acceptance grep forbids `r.Body` reads in handlers_auth.go. Verified by existing Plan 04/05 tests passing after the csrf mount lands. | +| T-2-08d | Tampering | csrf.Protect mounted before ResolveSession (wrong order) | mitigate | D-24 locked order; router.go acceptance assertion uses `awk` line-number compare to enforce `ResolveSession` appears before `auth.Mount`. | + + + +- TestCSRF_LoginMissingToken / TestCSRF_LoginValidToken pass. +- TestCSRF_SignupMissingToken / TestCSRF_SignupValidToken pass. +- TestCSRF_LogoutMissingToken / TestCSRF_LogoutValidToken pass. +- TestCSRF_HeaderFallback passes (X-CSRF-Token header works for future HTMX hx-headers usage). +- TestForms_ContainCSRFField passes — every form-rendering templ contains the hidden field. +- TestRouter_CSRFMountedAfterResolveSession passes — middleware order is correct. +- TestLoadCSRFKey_Missing / TestLoadCSRFKey_WrongLength / TestLoadCSRFKey_Valid pass. +- All Plan 04/05/06 tests still pass after being updated to acquire and submit a valid `_csrf` token. +- Manual browser walkthrough: sign up → /, click Log out → /login, log back in. Every form submission has a `_csrf` hidden input visible in DevTools. + + + +- AUTH-06 closed: all state-changing POSTs require a valid CSRF token; missing/invalid → 403. +- The reusable `ui.CSRFField` component is the canonical way to embed CSRF in future templ forms. +- SESSION_SECRET is documented and loaded from env; missing key fails the binary fast at startup. +- Middleware order (`ResolveSession` → `csrf.Protect` → route groups) matches D-24 exactly. + + + +Create `.planning/phases/02-authentication/02-07-SUMMARY.md` recording: +- Final list of templ files audited and the form-by-form CSRF-field placement +- Confirmation that all Plan 04/05/06 tests were updated (or already were CSRF-aware) +- Any decisions on token surfacing in HTMX hx-headers (left for a future polish pass; currently the form field is the canonical path) +- Snapshot of the final router middleware chain (single-source-of-truth diff base for Phase 3 / future security hardening) + diff --git a/.planning/phases/02-authentication/02-VALIDATION.md b/.planning/phases/02-authentication/02-VALIDATION.md index 0b91d74..06704c0 100644 --- a/.planning/phases/02-authentication/02-VALIDATION.md +++ b/.planning/phases/02-authentication/02-VALIDATION.md @@ -42,7 +42,13 @@ Filled by planner as PLAN.md tasks are emitted. Each task with `type: execute` m | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| (to be filled by planner) | | | | | | | | | ⬜ pending | +| 01-T1 | 01 | 1 | AUTH-01,AUTH-02 | T-2-08b | Schema, sqlc gen, test harness | integration | `cd backend && go build ./... && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/db/... -count=1` | ⬜ | ⬜ pending | +| 02-T1 | 02 | 2 | AUTH-01 | T-2-01 | argon2id PHC Hash/Verify, malformed-PHC + version-mismatch rejection (RED→GREEN→REFACTOR) | unit (tdd) | `cd backend && go test ./internal/auth -run "TestPassword_" -count=1` | ⬜ | ⬜ pending | +| 03-T1 | 03 | 2 | AUTH-02,AUTH-03,AUTH-05 | T-2-04,T-2-05,T-2-06 | Session create/lookup/rotate/extend; cookie attrs; ResolveSession/RequireAuth/RedirectIfAuthed | integration | `cd backend && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/auth -run "TestSession_\|TestCookie_\|TestResolveSession\|TestRequireAuth\|TestRedirectIfAuthed" -count=1` | ⬜ | ⬜ pending | +| 04-T1 | 04 | 3 | AUTH-01,AUTH-03,AUTH-05 | T-2-01,T-2-03 | Signup E2E: validate → argon2 → InsertUser → Store.Create → 303; RedirectIfAuthed wraps GET /signup | integration | `cd backend && templ generate && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web -run "TestSignup_" -count=1` | ⬜ | ⬜ pending | +| 05-T1 | 05 | 4 | AUTH-02,AUTH-03,AUTH-05,AUTH-07 | T-2-02,T-2-03 | Login E2E: rate-check → Verify → Rotate → cookie → 303; generic error string; rate limiter burst+per-key+janitor with injectable clock | integration | `cd backend && templ generate && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web ./internal/auth -run "TestLogin_\|TestRateLimit_" -count=1` | ⬜ | ⬜ pending | +| 06-T1 | 06 | 5 | AUTH-04,AUTH-05 | T-2-07,T-2-10 | Logout deletes row + clears cookie; / now protected; Layout shows logout button only when authed | integration | `cd backend && templ generate && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web ./templates -run "TestLogout_\|TestProtected_\|TestLayout_" -count=1` | ⬜ | ⬜ pending | +| 07-T1 | 07 | 6 | AUTH-06 | T-2-08,T-2-08a,T-2-08b,T-2-08c,T-2-08d | gorilla/csrf mounted after ResolveSession; @ui.CSRFField in every form; SESSION_SECRET from env; 403 on missing token, 200/303 on valid | integration | `cd backend && templ generate && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web ./internal/auth ./cmd/web -run "TestCSRF_\|TestForms_ContainCSRFField\|TestRouter_CSRFMountedAfterResolveSession\|TestLoadCSRFKey" -count=1` | ⬜ | ⬜ pending | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*