- SUMMARY.md: login vertical slice, rate limiter design decisions, 12 test results - STATE.md: advance to 5/7 plans, add decisions, metrics row - ROADMAP.md: mark 02-05 complete (5/7 plans) - REQUIREMENTS.md: mark AUTH-07 complete (rate limit delivered)
7 KiB
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-authentication | 05 | auth |
|
|
|
|
|
|
Phase 02 Plan 05: Login + Rate Limit Summary
One-liner: Token-bucket rate limiter (rate.Every(12s), burst=5) keyed on lower(email)+":"+clientIP with injectable clock and janitor goroutine; login handler validates, rate-gates, verifies argon2id, rotates session, and redirects — all tested end-to-end against a real Postgres schema.
What Was Built
Task 1: LimiterStore (RED → GREEN)
backend/internal/auth/ratelimit.go implements the in-memory token-bucket rate limiter per D-16:
- Rate:
rate.Every(12*time.Second)= 5 tokens/minute, burst=5 - Key isolation: Each
(lower(email)+":"+ip)key gets its own*rate.Limiter - Clock injection:
now func() time.Timefield enables deterministic tests without real sleeps - Janitor:
StartJanitor(interval, stop)goroutine evicts entries idle >idleTTL(10min default) viacleanupNow() - Anti-patterns avoided:
AllowN(t, 1)used (never.Allow()— Pitfall 8); separate Limiter per key (not shared global) - Exports for cross-package tests:
NewLimiterStoreWithClock,SetLimiterClock
5 unit tests (all pass with -race): burst exhaustion, 12s refill, per-key isolation, janitor eviction, concurrent access.
Task 2: Login vertical slice (RED → GREEN)
Templates (backend/templates/auth_login.templ):
LoginPagewrapsLoginFormFragmentin the base LayoutLoginFormFragmenthasid="login-form",hx-post="/login",hx-target="#login-form",hx-swap="outerHTML"- Reuses
@GeneralErrorand@FieldErrorfrom auth_form_errors.templ - CSRF field placeholder comment (Plan 07)
Handler (backend/internal/web/handlers_auth.go):
LoginPageHandler()— renders empty LoginPageLoginPostHandler(deps AuthDeps)— full login flow:- Email + password format validation (specific errors, D-25)
- Rate-limit check via
deps.Limiter.Allow(key)BEFORE any DB/argon2 work (D-16, T-2-14 ordering) GetUserByEmaillookupauth.Verify(user.PasswordHash, password)argon2id checkdeps.Store.Rotate(ctx, oldSessionID, user.ID)— session rotation (D-10)- Cookie set + redirect (303 or HX-Redirect)
errInvalidCredsconstant = single occurrence of "Invalid email or password" (D-20, T-2-03)clientIP(r)helper:net.SplitHostPort(r.RemoteAddr)with fallback to raw value
Router (backend/internal/web/router.go):
GET /logininsideRedirectIfAuthedgroup (D-23)POST /loginoutside the group (same pattern as signup)
Main (backend/cmd/web/main.go):
rl := auth.NewLimiterStore()+stopJanitor := make(chan struct{})+rl.StartJanitor(time.Minute, stopJanitor)close(stopJanitor)aftersrv.Shutdown()returnsAuthDeps{..., Limiter: rl}passed to NewRouter
AuthDeps extended: Limiter *auth.LimiterStore field (nil-safe — handlers skip rate-limit when nil)
Test Coverage
12 TestLogin_* integration tests against real DB schema:
| Test | Verifies |
|---|---|
| TestLogin_Success | 303 + Location:/ + Set-Cookie + session row |
| TestLogin_Success_HTMX | 200 + HX-Redirect:/ |
| TestLogin_WrongPassword | body contains errInvalidCreds, no cookie, no session row |
| TestLogin_UnknownEmail | identical body to wrong-password (D-20 enumeration defense) |
| TestLogin_ValidationError_BadEmail | 422 + "valid email" message |
| TestLogin_ValidationError_ShortPassword | 422 + "12" in body |
| TestLogin_RotatesExistingSession | new cookie value, old session row deleted, new row exists |
| TestLogin_AlreadyAuthedBouncesHome | GET /login with valid cookie → 303 to / |
| TestLogin_RateLimit_6thAttemptReturns429 | 429 on 6th attempt with frozen clock |
| TestLogin_RateLimit_6thAttemptHTMXNoFullPage | HTMX 429 is fragment (no <html>) |
| TestLogin_RateLimit_KeyedByEmailPlusIP | emailA exhausted does not block emailB |
| TestLogin_RateLimit_AppliesBeforeUserLookup | 429 even when email not in DB |
Decisions Made
- Status 401 for credential failures (non-HTMX): semantically correct; both unknown-email and wrong-password return same status+body (D-20). HTMX path returns 200 with HX-Redirect on success, fragment on error.
- errInvalidCreds const (not inline string): satisfies the
grep -c == 1acceptance criterion while keeping the message in one place (D-20). - NewLimiterStoreWithClock exported: avoids duplicating a test-helper shim in the web package; cross-package tests can inject their own clock.
- golang.org/x/time stays "indirect": no direct
import "golang.org/x/time/rate"in a cmd or main package — go mod marks it indirect. No functional impact.
Deviations from Plan
Auto-fixed Issues
1. [Rule 2 - Missing] errInvalidCreds constant for D-20 grep gate
- Found during: Task 2 acceptance criteria check
- Issue: Plan acceptance criterion requires
grep -c '"Invalid email or password"' == 1; two assignment sites existed before constant was extracted - Fix: Introduced
const errInvalidCreds = "Invalid email or password"and both sites use the constant - Files modified:
backend/internal/web/handlers_auth.go - Commit:
7d8c498
Self-Check
Created files exist:
- backend/internal/auth/ratelimit.go: FOUND
- backend/internal/auth/ratelimit_test.go: FOUND
- backend/templates/auth_login.templ: FOUND