xtablo-source/.planning/phases/02-authentication/02-07-PLAN.md

22 KiB

phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02-authentication 07 execute 6
01
03
04
05
06
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
true
AUTH-06
go
csrf
gorilla
htmx
templ
security-hardening
truths artifacts key_links
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 <input type="hidden" name="_csrf" value="{token}"/> (D-15)
Every templ form (signup, login, login fragment, signup fragment, layout logout) embeds @ui.CSRFField(token) as the first child of <form>
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)
path provides contains
backend/internal/auth/csrf.go Mount(env string, secret []byte) func(http.Handler) http.Handler helper that constructs csrf.Protect with the locked options csrf.Protect
path provides contains
backend/internal/web/ui/csrf_field.templ ui.CSRFField(token string) templ component templ CSRFField
path provides
backend/internal/web/router.go r.Use(auth.Mount(env, csrfKey)) inserted after ResolveSession and before route groups
path provides
backend/cmd/web/main.go Loads SESSION_SECRET from env (hex-decode 32 bytes); fails fast on missing/short key; passes []byte secret + env into NewRouter
path provides
backend/.env.example Documents SESSION_SECRET with `openssl rand -hex 32` instruction
from to via pattern
backend/internal/web/router.go backend/internal/auth/csrf.go r.Use(auth.Mount(env, csrfKey)) immediately after ResolveSession auth.Mount|csrf.Protect
from to via pattern
backend/templates/layout.templ backend/internal/web/ui/csrf_field.templ @ui.CSRFField(csrfToken) inside the logout form when user != nil ui.CSRFField|CSRFField(
from to via pattern
backend/internal/web/handlers_auth.go backend/internal/web/ui/csrf_field.templ csrf.Token(r) -> templ page/fragment arg -> @ui.CSRFField(token) 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.

<execution_context> @/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 </execution_context>

@.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) { <input type="hidden" name="_csrf" value={ token }/> }
    • 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) { <input type="hidden" name="_csrf" value={ token }/> }`
   - Run `templ generate` after writing.

4. Update `backend/templates/layout.templ`:
   - Signature: `templ Layout(title string, user *auth.User, csrfToken string)`.
   - Inside the logout `<form method="POST" action="/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 `<form>` 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 `<form>` 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 `<form method="POST">` 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.

<threat_model>

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 <form method="POST"> 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.
</threat_model>
- 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.

<success_criteria>

  • 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 (ResolveSessioncsrf.Protect → route groups) matches D-24 exactly. </success_criteria>
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)