---
phase: 02-authentication
plan: 05
type: execute
wave: 4
depends_on: [01, 02, 03, 04]
files_modified:
  - backend/internal/auth/ratelimit.go
  - backend/internal/auth/ratelimit_test.go
  - backend/templates/auth_login.templ
  - backend/internal/web/handlers_auth.go
  - backend/internal/web/handlers_auth_test.go
  - backend/internal/web/router.go
  - backend/cmd/web/main.go
  - backend/go.mod
  - backend/go.sum
autonomous: true
requirements: [AUTH-02, AUTH-03, AUTH-05, AUTH-07]
tags: [go, htmx, login, rate-limit, vertical-slice]
must_haves:
  truths:
    - "GET /login renders a full templ page with email + password form (D-19)"
    - "POST /login with valid creds rotates the session, sets a fresh cookie, redirects to / (D-10, D-21)"
    - "POST /login with wrong password OR unknown email returns the SAME generic error 'Invalid email or password' (D-20)"
    - "POST /login on the 6th attempt within 60s per (lower(email)+clientIP) returns 429 with HTMX-swap error fragment (D-16, D-18, AUTH-07)"
    - "Rate limiter uses golang.org/x/time/rate with injectable now() so tests are deterministic"
    - "Rate-limiter janitor goroutine evicts entries idle > 10 min (D-16)"
    - "client IP is read from r.RemoteAddr after chimw.RealIP has already rewritten it (Phase 1 stack); IP is part of the rate-limit key only and is NOT persisted (D-17)"
    - "Authed users hitting GET /login are bounced to / (D-23 RedirectIfAuthed)"
  artifacts:
    - path: backend/internal/auth/ratelimit.go
      provides: "LimiterStore with per-key Limiter map, injectable clock, janitor goroutine"
      contains: "rate.NewLimiter"
    - path: backend/internal/auth/ratelimit_test.go
      provides: "Burst test, per-key isolation, janitor eviction — all with fake clock"
    - path: backend/templates/auth_login.templ
      provides: "LoginPage + LoginFormFragment"
    - path: backend/internal/web/handlers_auth.go
      provides: "LoginPageHandler + LoginPostHandler appended"
  key_links:
    - from: backend/internal/web/handlers_auth.go
      to: backend/internal/auth/ratelimit.go
      via: "LimiterStore.Allow(lower(email)+\":\"+ip)"
      pattern: "LimiterStore"
    - from: backend/internal/web/handlers_auth.go
      to: backend/internal/auth/password.go
      via: "auth.Verify(user.PasswordHash, supplied)"
      pattern: "auth\\.Verify"
    - from: backend/internal/web/handlers_auth.go
      to: backend/internal/auth/session.go
      via: "Store.Rotate on auth-success (D-10)"
      pattern: "Store\\.Rotate|store\\.Rotate"
---

<objective>
Deliver the LOGIN vertical slice end-to-end: templ form -> POST handler -> rate-limit check -> user lookup -> argon2 verify -> session rotate -> cookie -> redirect. Includes the in-memory token-bucket rate limiter (5/min per email+IP, burst 5) with injectable clock and janitor.

Purpose: after Plan 04 a user can sign up but not return. This plan closes the loop — sign out, sign back in. Plus AUTH-07 rate-limit lands as part of the same vertical slice so the login flow ships hardened from day one.
Output: working login flow + rate limiter, all real-DB tested.
</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
@backend/internal/auth/password.go
@backend/internal/auth/session.go
@backend/internal/auth/middleware.go
@backend/internal/web/handlers_auth.go
@backend/internal/web/router.go
@backend/templates/auth_signup.templ

<interfaces>
Rate limiter (new in this plan):

```
type LimiterStore struct {
    mu      sync.Mutex
    limits  map[string]*entry
    rate    rate.Limit
    burst   int
    idleTTL time.Duration
    now     func() time.Time
}

type entry struct { lim *rate.Limiter; lastSeen time.Time }

func NewLimiterStore() *LimiterStore                 // rate=5/min, burst=5, idleTTL=10min, now=time.Now
func (s *LimiterStore) Allow(key string) bool        // uses AllowN(s.now(), 1) — Pattern 8
func (s *LimiterStore) StartJanitor(interval time.Duration, stop <-chan struct{})  // goroutine
```

Templates:
- LoginPage(form LoginForm, errs LoginErrors)
- LoginFormFragment(form LoginForm, errs LoginErrors)

Handlers added to handlers_auth.go:
- LoginPageHandler() http.HandlerFunc
- LoginPostHandler(deps AuthDeps, rl *auth.LimiterStore) http.HandlerFunc

AuthDeps extended with optional Now func() time.Time for tests (or pass via LimiterStore constructor — planner picks). Router accepts rl in NewRouter or constructs it internally and starts the janitor.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: LimiterStore (in-memory token bucket with injectable clock + janitor)</name>
  <read_first>
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 8; "Why AllowN(t, n) over Allow()"; Pitfall 11)
    .planning/phases/02-authentication/02-CONTEXT.md (D-16, D-17, D-18)
    backend/internal/auth/types.go
  </read_first>
  <behavior>
    Unit tests (no DB) in backend/internal/auth/ratelimit_test.go using an injected fakeNow:
    - TestRateLimit_BurstAllowsFiveThenDenies: with rate=5/min, burst=5, now=t0: Allow("k") returns true five consecutive times; the sixth returns false. Pattern 8.
    - TestRateLimit_RefillsAfter12s: rate.Every(12s) — at t0+12s the previously-saturated key gets one more Allow=true.
    - TestRateLimit_PerKeyIsolation: Allow("a") six times saturates a; Allow("b") at the same fakeNow still returns true (separate Limiter).
    - TestRateLimit_JanitorEvictsIdle: insert two entries at t0, advance fakeNow to t0+11min, call s.cleanupNow() (test-only export) — both entries removed; verify via s.size() or by inspecting the unexported map via a same-package test.
    - TestRateLimit_ConcurrentAllowDoesNotPanic: 100 goroutines calling Allow on overlapping keys; no data race (run with -race).
  </behavior>
  <action>
    Add dep: `cd backend &amp;&amp; go get golang.org/x/time@v0.15.0`.

    Create backend/internal/auth/ratelimit.go per RESEARCH Pattern 8 verbatim:
    - Imports: sync, time, golang.org/x/time/rate.
    - type entry struct { lim *rate.Limiter; lastSeen time.Time }.
    - type LimiterStore struct { mu sync.Mutex; limits map[string]*entry; rate rate.Limit; burst int; idleTTL time.Duration; now func() time.Time }.
    - NewLimiterStore() *LimiterStore: limits=make(...); rate=rate.Every(12*time.Second); burst=5; idleTTL=10*time.Minute; now=time.Now.
    - (s *LimiterStore) Allow(key string) bool: lock; e, ok := s.limits[key]; if !ok { e = &entry{lim: rate.NewLimiter(s.rate, s.burst)}; s.limits[key] = e }; t := s.now(); e.lastSeen = t; return e.lim.AllowN(t, 1).
    - (s *LimiterStore) StartJanitor(interval time.Duration, stop <-chan struct{}): go func() { tick := time.NewTicker(interval); defer tick.Stop(); for { select { case <-stop: return; case <-tick.C: s.cleanupNow() } } }().
    - (s *LimiterStore) cleanupNow(): lock; cutoff := s.now().Add(-s.idleTTL); for k, e := range s.limits { if e.lastSeen.Before(cutoff) { delete(s.limits, k) } }.
    - Test-only export (in ratelimit_test.go since same package): expose size() method or call s.cleanupNow directly.

    Anti-pattern guards: do NOT use Allow() without args (Pitfall 8 — untestable). Do NOT share one *rate.Limiter across keys (Pitfall: that's a global limit). Do NOT omit the janitor (Pitfall 11 — memory leak).
  </action>
  <verify>
    <automated>cd backend &amp;&amp; go test ./internal/auth/ -run TestRateLimit -count=1 -race -v 2>&amp;1 | tee /tmp/p05t1.log | tail -25 &amp;&amp; grep -q "PASS" /tmp/p05t1.log &amp;&amp; ! grep -E "^--- FAIL|DATA RACE" /tmp/p05t1.log</automated>
  </verify>
  <acceptance_criteria>
    - ratelimit.go exists; grep -c "AllowN" backend/internal/auth/ratelimit.go &gt;= 1.
    - grep -c "\\.Allow()" backend/internal/auth/ratelimit.go == 0 (anti-pattern: must use AllowN).
    - grep -c "rate.NewLimiter" backend/internal/auth/ratelimit.go &gt;= 1.
    - grep -c "func.*StartJanitor" backend/internal/auth/ratelimit.go == 1.
    - go.mod contains `golang.org/x/time v0.15.0`.
    - All five TestRateLimit_* tests pass with -race.
    - Burst test asserts exactly 5 trues followed by a false at the same fake timestamp.
  </acceptance_criteria>
  <done>Rate limiter is deterministic, race-clean, with bounded memory via janitor.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: LoginPage + LoginPostHandler + router wiring + integration tests</name>
  <read_first>
    backend/templates/auth_signup.templ (mirror its shape)
    backend/internal/web/handlers_auth.go (existing SignupPostHandler — extend, do not duplicate the renderError pattern)
    backend/internal/auth/ratelimit.go (Task 1)
    backend/internal/auth/password.go
    backend/internal/auth/session.go
    backend/internal/auth/middleware.go
    backend/internal/web/router.go
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 6, Pattern 9, Handler skeleton; Pitfall 7, Pitfall 9)
    .planning/phases/02-authentication/02-CONTEXT.md (D-19, D-20, D-21, D-23)
  </read_first>
  <behavior>
    Tests in backend/internal/web/handlers_auth_test.go (extending the file from Plan 04). All use setupTestDB. Pre-seed each test with InsertUser of email=test@example.com and a known argon2 hash of password "correct-horse-12chars" using auth.TestParams (so tests stay fast).
    - TestLogin_Success: POST /login with matching creds -> 303 + Location: / (or HX-Redirect on HTMX) + Set-Cookie present. SELECT COUNT(*) FROM sessions WHERE user_id = test_user.id == 1.
    - TestLogin_Success_HTMX: HX-Request: true -> 200 + HX-Redirect: /.
    - TestLogin_WrongPassword: correct email, wrong password -> 401 (or 200; planner picks status — but the body MUST contain the EXACT string "Invalid email or password"). No Set-Cookie. No session row created.
    - TestLogin_UnknownEmail: email that does not exist -> same 401 (or 200) + EXACT same body string "Invalid email or password" (D-20 enumeration defense). Use bytes.Contains assertion.
    - TestLogin_ValidationError_BadEmail: email="not-an-email" -> 422 with field-specific "valid email" message (validation errors ARE specific per D-20).
    - TestLogin_ValidationError_ShortPassword: password length 10 -> 422 with "12" in body.
    - TestLogin_RotatesExistingSession: pre-create session A for user; POST /login with cookie A -> response Set-Cookie has a DIFFERENT value than A; SELECT COUNT(*) FROM sessions WHERE id = sha256_hex(decode(cookieA)) == 0; new row exists for user (D-10 fixation defense, Pitfall 5).
    - TestLogin_AlreadyAuthedBouncesHome: GET /login with valid session cookie -> 303 Location: / (RedirectIfAuthed).
    - TestLogin_RateLimit_6thAttemptReturns429: POST /login six times in <1s with the same email+IP and wrong password. Inject a fake clock via NewLimiterStore() + manually setting limiterStore.now to a fixed time. The 6th response is 429 (http.StatusTooManyRequests) with body containing "Too many attempts". When sent over HTMX (HX-Request: true), the response body is the LoginFormFragment with the error injected (no full page). Verified by absence of &lt;html&gt; tag in the HTMX-variant assertion.
    - TestLogin_RateLimit_KeyedByEmailPlusIP: Six wrong-password POSTs for emailA from IP1; the seventh attempt for emailB from IP1 is allowed (not 429). Confirms key isolation.
    - TestLogin_RateLimit_AppliesBeforeUserLookup: with rate-limited key, response is 429 even when the supplied email doesn't exist in the users table — i.e., the rate limit gates the work, not the other way around. (Optional but recommended; documented.)
    - TestLogin_DoesNotLogPassword: with -v output, assert the test logger captured no line containing the password literal. (Use a captured slog handler.)
  </behavior>
  <action>
    Create backend/templates/auth_login.templ mirroring auth_signup.templ:
    - templ LoginPage(form LoginForm, errs LoginErrors) wrapped in @Layout("Sign in").
    - templ LoginFormFragment(form LoginForm, errs LoginErrors) with id="login-form", hx-post="/login", hx-target="#login-form", hx-swap="outerHTML".
    - Reuse @FieldError and @GeneralError from auth_form_errors.templ.
    - Comment placeholder for CSRF (Plan 07).
    Define LoginForm, LoginErrors types alongside SignupForm/SignupErrors per the Plan 04 decision (same file/package).

    Extend backend/internal/web/handlers_auth.go:
    - LoginPageHandler() http.HandlerFunc — renders templates.LoginPage(empty, empty).
    - LoginPostHandler(deps AuthDeps, rl *auth.LimiterStore) http.HandlerFunc:
      1. email := strings.TrimSpace(r.PostFormValue("email")); password := r.PostFormValue("password").
      2. Validate (specific errors): mail.ParseAddress; len(password) in [12, 128]. On err -> renderLoginError(w, r, form, errs, 422).
      3. normalized := strings.ToLower(email). ip := clientIP(r) — define clientIP(r) once: read r.RemoteAddr, split host:port via net.SplitHostPort with fallback to raw value (chimw.RealIP has already rewritten it per D-17).
      4. key := normalized + ":" + ip. if !rl.Allow(key) -> w.WriteHeader(http.StatusTooManyRequests); render LoginFormFragment / LoginPage with errs.General = "Too many attempts. Try again in a minute."; return.
      5. user, err := deps.Queries.GetUserByEmail(ctx, normalized). On any err (incl. ErrNoRows) -> renderLoginError with errs.General = "Invalid email or password" (D-20); status: 200 with the fragment (or 401 if not HTMX — planner picks; document choice). Pitfall: do NOT short-circuit Verify on user-not-found (timing leak); we accept one indexed SELECT miss as the timing channel, deemed acceptable per RESEARCH security-domain table.
      6. ok, err := auth.Verify(user.PasswordHash, password). If err != nil OR !ok -> same renderLoginError with EXACT same string "Invalid email or password".
      7. Rotate any existing session: oldID := ""; if sess, _, ok := auth.Authed(r.Context()); ok { oldID = sess.ID }. cookieValue, expiresAt, err := deps.Store.Rotate(ctx, oldID, user.ID).
      8. auth.SetSessionCookie(w, cookieValue, expiresAt, deps.Secure).
      9. Redirect: HX-Redirect: / or 303 Location: / (D-21).
    - renderLoginError analogous to renderSignupError.

    Update backend/internal/web/router.go:
    - Extend AuthDeps OR pass rl as a separate arg to NewRouter — planner picks; recommendation: extend AuthDeps with `Limiter *auth.LimiterStore`. Update Plan 04 calls accordingly (NewRouter signature backwards-compatible if Limiter is allowed to be nil — handlers check and skip rate-limit in that case, but production main always wires it).
    - Inside the RedirectIfAuthed group (already created by Plan 04) add r.Get("/login", LoginPageHandler()).
    - Outside the group add r.Post("/login", LoginPostHandler(deps, deps.Limiter)).

    Update backend/cmd/web/main.go:
    - rl := auth.NewLimiterStore(); stopCh := make(chan struct{}); rl.StartJanitor(time.Minute, stopCh); pass rl in AuthDeps. On shutdown (after srv.Shutdown), close(stopCh).

    Anti-pattern guards: do NOT log the email or password on any failure path. Do NOT use http.StatusFound (302) — must be 303 (Pitfall 9). Do NOT vary the error STRING between unknown-email and wrong-password (D-20 — TestLogin_UnknownEmail and TestLogin_WrongPassword both assert the exact same body). Do NOT call Verify before the rate-limit check (DoS — argon2 work happens after rate gate per RESEARCH Handler skeleton).
  </action>
  <verify>
    <automated>cd backend &amp;&amp; templ generate &amp;&amp; TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/web/ ./internal/auth/ ./templates/ -run "TestLogin|TestRateLimit|TestSignup|TestHealthz|TestIndex|TestDemoTime|TestRequestID|TestSlog|TestPassword|TestSession|TestCookie|TestResolveSession|TestRequireAuth|TestRedirectIfAuthed" -count=1 2>&amp;1 | tee /tmp/p05t2.log | tail -50 &amp;&amp; ! grep -E "^FAIL|^--- FAIL" /tmp/p05t2.log</automated>
  </verify>
  <acceptance_criteria>
    - auth_login.templ exists with LoginPage and LoginFormFragment; templ generate exits 0.
    - handlers_auth.go: grep -c "LoginPostHandler\|LoginPageHandler" &gt;= 2.
    - handlers_auth.go contains EXACTLY ONE literal string "Invalid email or password" (single source of truth for D-20). Verified: grep -c '"Invalid email or password"' backend/internal/web/handlers_auth.go == 1.
    - handlers_auth.go: grep -c "store.Rotate\|Store.Rotate\|deps.Store.Rotate" &gt;= 1.
    - handlers_auth.go: grep -c "auth.Verify" &gt;= 1.
    - handlers_auth.go: grep -c "rl.Allow\|deps.Limiter.Allow\|Allow(" &gt;= 1.
    - In handlers_auth.go the rl.Allow(...) call appears at a source-line number LESS than the deps.Queries.GetUserByEmail(...) call (rate-limit happens BEFORE user lookup). Verified by: awk '/rl\\.Allow|Limiter\\.Allow/{a=NR} /GetUserByEmail/{b=NR} END {exit !(a<b)}' backend/internal/web/handlers_auth.go.
    - router.go: GET /login is inside the RedirectIfAuthed group; POST /login is outside.
    - cmd/web/main.go: grep -c "NewLimiterStore\|StartJanitor" &gt;= 2.
    - cd backend &amp;&amp; TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web/ -run TestLogin -count=1 exits 0 with all 8+ TestLogin_* tests PASS.
    - TestLogin_RotatesExistingSession passes (rotation works end-to-end through the HTTP handler).
    - TestLogin_RateLimit_6thAttemptReturns429 passes with the rate limiter fed an injected clock.
    - All previous phase 2 tests still pass (regression check).
  </acceptance_criteria>
  <done>Login works, rotates sessions, returns generic errors for both unknown-email and wrong-password, and returns 429 on the 6th attempt. AUTH-02, AUTH-07 closed.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Client form -> handler | Validated server-side before any expensive crypto or DB work |
| In-memory limiter map | Bounded by janitor; key includes client IP from chimw.RealIP-rewritten r.RemoteAddr |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-2-02 | Spoofing | Credential stuffing | mitigate | LimiterStore caps at 5/min per (email+IP); 6th attempt returns 429 with HTMX-aware fragment. Verified by TestLogin_RateLimit_6thAttemptReturns429. |
| T-2-03 | Information disclosure | User enumeration via login error | mitigate | Exactly one literal "Invalid email or password" string; same status and body for unknown-email vs wrong-password. Verified by acceptance grep gate + TestLogin_WrongPassword/UnknownEmail asserting identical body. |
| T-2-04 | Spoofing | Session fixation on login | mitigate | Store.Rotate deletes any existing session and creates a fresh one (D-10). Verified by TestLogin_RotatesExistingSession. |
| T-2-09 | Spoofing | Brute-force password guess | mitigate | argon2id ~250ms per Verify + 5/min rate limit. Acceptance ensures Verify runs only after Allow returns true. |
| T-2-14 | DoS | Argon2 abuse via 6+ rapid attempts | mitigate | Rate limit gates BEFORE auth.Verify (acceptance line-order grep). |
| T-2-21 | Information disclosure | Email/password in logs | mitigate | No slog calls reference r.PostFormValue("email") or password; verified by handler-test capturing slog output and asserting absence (TestLogin_DoesNotLogPassword). |
</threat_model>

<verification>
- All Plan 05 tests pass.
- Manual browser walkthrough: sign up new user, hit /login from another browser, log in -> redirected to /; submit 6 wrong passwords -> 6th shows 429 inline error fragment.
- Phase 4 + Phase 1 regression tests still pass.
- go test -race ./internal/auth/... exits 0.
- Wall-time budget: `cd backend && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web -run "TestLogin_" -count=1` completes in ≤30s. Per-test setup pre-seeds the user row via a direct sqlc `InsertUser` using a hash computed once with `auth.TestParams` (not `DefaultParams`); production handler still calls `auth.Verify` against the stored hash, so the one real argon2 call per Verify-success path remains. Production code is untouched — no `AuthDeps.HashParams` knob.
</verification>

<success_criteria>
- AUTH-02 closed: login issues a server-managed session.
- AUTH-07 closed: rate limit on (email+IP) demonstrated by test.
- D-20 enforced by a single-source-of-truth grep gate on the error string.
</success_criteria>

<output>
Create .planning/phases/02-authentication/02-05-SUMMARY.md recording: exact rate value (rate.Every(12s), burst=5), clientIP helper location and SplitHostPort fallback, choice of status code for invalid creds (401 vs 200 fragment), manual UAT notes.
</output>
