go-htmx-gsd #1
3 changed files with 305 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
288
.planning/phases/02-authentication/02-07-PLAN.md
Normal file
288
.planning/phases/02-authentication/02-07-PLAN.md
Normal file
|
|
@ -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 <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)"
|
||||
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\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
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))`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Mount gorilla/csrf, add ui.CSRFField, wire every form, env-driven key</name>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
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}.
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 `<form method="POST">` contains `@ui.CSRFField(`. Concretely, for each f in {layout.templ, auth_login.templ, auth_signup.templ}: `grep -v '^//' $f | awk '/<form/,/<\/form>/' | 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<c)}' backend/internal/web/router.go` exits 0.
|
||||
- `cmd/web/main.go` calls `auth.LoadKeyFromEnv` and `log.Fatal` (or equivalent) on error: `grep -c "LoadKeyFromEnv" backend/cmd/web/main.go == 1`; `grep -c "SESSION_SECRET" backend/cmd/web/main.go >= 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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
</verification>
|
||||
|
||||
<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 (`ResolveSession` → `csrf.Protect` → route groups) matches D-24 exactly.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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)
|
||||
</output>
|
||||
|
|
@ -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*
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue