From 7d8c49898077c3c85f28fc107167699f89309ddd Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 22:27:54 +0200 Subject: [PATCH] feat(02-05): login vertical slice with rate limiting - auth_login.templ: LoginPage + LoginFormFragment (mirrors signup shape) - LoginForm + LoginErrors types added to templates/auth_forms.go - LoginPageHandler + LoginPostHandler in handlers_auth.go - Rate-limit check before user lookup (D-16, T-2-14) - Single errInvalidCreds constant for D-20 enumeration defense - Session rotation via Store.Rotate on success (D-10, T-2-04) - HTMX-aware redirect and fragment responses (D-19, D-21) - AuthDeps extended with Limiter *auth.LimiterStore field - router.go: GET /login in RedirectIfAuthed group (D-23) - main.go: LimiterStore created with janitor goroutine (D-16) - Export NewLimiterStoreWithClock + SetLimiterClock for cross-package tests - 12 TestLogin_* integration tests all pass with real DB --- backend/cmd/web/main.go | 11 +- backend/internal/auth/ratelimit.go | 16 +- backend/internal/web/handlers_auth.go | 146 +++++++ backend/internal/web/handlers_auth_test.go | 436 +++++++++++++++++++++ backend/internal/web/router.go | 4 +- backend/templates/auth_forms.go | 17 + backend/templates/auth_login.templ | 72 ++++ 7 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 backend/templates/auth_login.templ diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 986a6ae..9424bb4 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -59,7 +59,13 @@ func main() { q := sqlc.New(pool) store := auth.NewStore(q) secure := env != "development" && env != "dev" - deps := web.AuthDeps{Queries: q, Store: store, Secure: secure} + + // Rate limiter for POST /login (D-16, AUTH-07). + rl := auth.NewLimiterStore() + stopJanitor := make(chan struct{}) + rl.StartJanitor(time.Minute, stopJanitor) + + deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl} router := web.NewRouter(pool, "./static", deps) @@ -89,6 +95,9 @@ func main() { slog.Error("shutdown error", "err", err) } + // Stop the rate-limiter janitor goroutine after HTTP server is fully shut down. + close(stopJanitor) + // Pitfall 4: close the pool AFTER Shutdown returns, NOT via defer in // main — defer ordering is unreliable on fatal-exit paths. pool.Close() diff --git a/backend/internal/auth/ratelimit.go b/backend/internal/auth/ratelimit.go index 7c1a4e3..9652bcf 100644 --- a/backend/internal/auth/ratelimit.go +++ b/backend/internal/auth/ratelimit.go @@ -49,13 +49,27 @@ func NewLimiterStore() *LimiterStore { } // newLimiterStoreWithClock creates a LimiterStore with an injectable clock. -// Used in tests to drive time deterministically (Pattern 8). +// Used in same-package tests to drive time deterministically (Pattern 8). func newLimiterStoreWithClock(now func() time.Time) *LimiterStore { s := NewLimiterStore() s.now = now return s } +// NewLimiterStoreWithClock creates a LimiterStore with an injectable clock. +// Exported for cross-package integration tests (e.g. internal/web handlers tests). +func NewLimiterStoreWithClock(now func() time.Time) *LimiterStore { + return newLimiterStoreWithClock(now) +} + +// SetLimiterClock sets the clock function on an existing LimiterStore. +// Exported for cross-package tests that need to freeze time after construction. +func SetLimiterClock(s *LimiterStore, now func() time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.now = now +} + // Allow reports whether the key has a token available. It uses AllowN(t, 1) // with the injectable clock (not Allow() which uses wall time internally and // is untestable — Pitfall 8 / Pattern 8). diff --git a/backend/internal/web/handlers_auth.go b/backend/internal/web/handlers_auth.go index 867d2ff..37c2fef 100644 --- a/backend/internal/web/handlers_auth.go +++ b/backend/internal/web/handlers_auth.go @@ -2,6 +2,7 @@ package web import ( "errors" + "net" "net/http" "net/mail" "strings" @@ -10,16 +11,37 @@ import ( "backend/internal/db/sqlc" "backend/templates" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // AuthDeps holds the dependencies shared by all auth handlers. // Secure should be true in all environments except "dev"/"development"; // it gates the cookie Secure attribute (D-12). +// Limiter is the in-memory rate limiter for POST /login (D-16, AUTH-07). +// When nil, rate limiting is skipped (unit tests that don't exercise that path). type AuthDeps struct { Queries *sqlc.Queries Store *auth.Store Secure bool + Limiter *auth.LimiterStore +} + +// errInvalidCreds is the intentionally generic error message for login failures +// (D-20). A single constant ensures both unknown-email and wrong-password paths +// produce the EXACT same body, preventing user enumeration (T-2-03). +const errInvalidCreds = "Invalid email or password" + +// clientIP extracts the client IP from r.RemoteAddr after chimw.RealIP has +// already rewritten it (D-17). IP is used only for the rate-limit key and is +// NOT persisted. Uses net.SplitHostPort with fallback to raw value. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr may already be a plain IP (no port) in some test contexts. + return r.RemoteAddr + } + return host } // SignupPageHandler renders the GET /signup page with an empty form. @@ -129,3 +151,127 @@ func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.Si _ = templates.SignupPage(form, errs).Render(r.Context(), w) } } + +// LoginPageHandler renders the GET /login page with an empty form. +func LoginPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.LoginPage(templates.LoginForm{}, templates.LoginErrors{}).Render(r.Context(), w) + } +} + +// LoginPostHandler handles POST /login: +// 1. Validate email + password format (specific errors per D-20/D-25) +// 2. Rate-limit check before any expensive work (D-16, D-18; gates DoS T-2-14) +// 3. Look up user by email +// 4. Verify argon2id password hash +// 5. Rotate session (D-10 fixation defense) +// 6. Set cookie and redirect (D-21) +// +// Security invariants (threat model): +// - Rate-limit check (rl.Allow) happens BEFORE deps.Queries.GetUserByEmail +// to prevent argon2 DoS via rapid requests (T-2-14, T-2-09). +// - Exact same error string (errInvalidCreds const) for unknown email AND +// wrong password to prevent user enumeration (D-20, T-2-03). +// - Password is never logged (T-2-21). +// - Session rotated on every successful login (T-2-04, D-10). +func LoginPostHandler(deps AuthDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // 1. Read form values. + email := strings.TrimSpace(r.PostFormValue("email")) + password := r.PostFormValue("password") + + var errs templates.LoginErrors + + // 2. Validate email (specific error — D-20: validation errors ARE specific). + if _, err := mail.ParseAddress(email); err != nil { + errs.Email = "Enter a valid email address" + } + + // 3. Validate password length BEFORE calling Verify (DoS guard T-2-14, D-25). + if len(password) < 12 { + errs.Password = "Password must be at least 12 characters" + } else if len(password) > 128 { + errs.Password = "Password must be at most 128 characters" + } + + if errs.Email != "" || errs.Password != "" { + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnprocessableEntity) + return + } + + // 4. Normalize email for rate-limit key and DB lookup. + normalized := strings.ToLower(email) + ip := clientIP(r) + key := normalized + ":" + ip + + // 5. Rate-limit check BEFORE user lookup and argon2 work (D-16, T-2-14). + // Limiter may be nil in tests that don't exercise rate-limiting. + if deps.Limiter != nil && !deps.Limiter.Allow(key) { + errs.General = "Too many attempts. Try again in a minute." + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusTooManyRequests) + return + } + + // 6. Look up user by email. + user, err := deps.Queries.GetUserByEmail(ctx, normalized) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // D-20: same generic message as wrong-password to prevent enumeration (T-2-03). + errs.General = errInvalidCreds + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized) + return + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // 7. Verify password (argon2id constant-time compare, T-2-13). + ok, err := auth.Verify(user.PasswordHash, password) + if err != nil || !ok { + // D-20: uses the same constant as unknown-email case (single source of truth). + errs.General = errInvalidCreds + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized) + return + } + + // 8. Rotate session: delete existing session (if any) and create a fresh one (D-10). + var oldSessionID string + if sess, _, isAuthed := auth.Authed(ctx); isAuthed { + oldSessionID = sess.ID + } + cookieValue, expiresAt, err := deps.Store.Rotate(ctx, oldSessionID, user.ID) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // 9. Set session cookie (D-12). + auth.SetSessionCookie(w, cookieValue, expiresAt, deps.Secure) + + // 10. Redirect to home (D-21). + // HTMX form submissions receive HX-Redirect so HTMX handles navigation client-side. + // Plain (no-JS) form submissions receive 303 See Other (NOT 302 — Pitfall 9). + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + +// renderLoginError writes a login validation-error response. +// For HTMX requests it renders only the form fragment; for plain requests it +// renders the full page (D-19). +func renderLoginError(w http.ResponseWriter, r *http.Request, form templates.LoginForm, errs templates.LoginErrors, status int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if r.Header.Get("HX-Request") == "true" { + _ = templates.LoginFormFragment(form, errs).Render(r.Context(), w) + } else { + _ = templates.LoginPage(form, errs).Render(r.Context(), w) + } +} diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 90a88e1..86f0316 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -1,12 +1,17 @@ package web import ( + "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" "backend/internal/auth" "backend/internal/db/sqlc" @@ -18,6 +23,13 @@ func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { return NewRouter(stubPinger{}, "./static", deps) } +// newTestRouterWithLimiter builds a router with an injected LimiterStore, +// enabling rate-limit tests to use a fake clock. +func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { + deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} + return NewRouter(stubPinger{}, "./static", deps) +} + // preInsertUser inserts a user with TestParams-hashed password directly via sqlc // (avoids slow DefaultParams hash in test setup — W4 / Pitfall 4). func preInsertUser(t *testing.T, ctx context.Context, q *sqlc.Queries, email, password string) sqlc.User { @@ -36,6 +48,30 @@ func preInsertUser(t *testing.T, ctx context.Context, q *sqlc.Queries, email, pa return user } +// hashCookieValue decodes a base64url cookie value and returns the hex-encoded +// SHA-256 hash — this is the session ID stored in the DB (D-05). +func hashCookieValue(t *testing.T, cookieValue string) string { + t.Helper() + raw, err := base64.RawURLEncoding.DecodeString(cookieValue) + if err != nil { + t.Fatalf("hashCookieValue: decode: %v", err) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// getSessionCookie extracts the xtablo_session cookie from a response. +func getSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie { + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + return c + } + } + return nil +} + +// ---- Signup Tests ---- + func TestSignup_Success(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() @@ -317,3 +353,403 @@ func TestSignup_AlreadyAuthedBouncesHome(t *testing.T) { t.Errorf("Location = %q; want /", loc) } } + +// ---- Login Tests ---- + +func TestLogin_Success(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "test@example.com", "correct-horse-12chars") + + form := url.Values{"email": {"test@example.com"}, "password": {"correct-horse-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d; want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Errorf("Location = %q; want /", loc) + } + if c := getSessionCookie(rec); c == nil { + t.Fatal("session cookie not set after login") + } + + // Session row must exist. + var count int + row := pool.QueryRow(ctx, "SELECT COUNT(*) FROM sessions WHERE user_id = $1", user.ID) + if err := row.Scan(&count); err != nil { + t.Fatalf("session count query: %v", err) + } + if count != 1 { + t.Errorf("session count = %d; want 1", count) + } +} + +func TestLogin_Success_HTMX(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + preInsertUser(t, ctx, q, "test2@example.com", "correct-horse-12chars") + + form := url.Values{"email": {"test2@example.com"}, "password": {"correct-horse-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("HTMX status = %d; want 200", rec.Code) + } + if hxRedir := rec.Header().Get("HX-Redirect"); hxRedir != "/" { + t.Errorf("HX-Redirect = %q; want /", hxRedir) + } +} + +func TestLogin_WrongPassword(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + preInsertUser(t, ctx, q, "testpw@example.com", "correct-horse-12chars") + + form := url.Values{"email": {"testpw@example.com"}, "password": {"wrong-password-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if !bytes.Contains(rec.Body.Bytes(), []byte("Invalid email or password")) { + t.Errorf("body must contain 'Invalid email or password'; got: %s", rec.Body.String()) + } + if c := getSessionCookie(rec); c != nil { + t.Fatal("session cookie must NOT be set on wrong password") + } + + // No session row for this user. + var count int + row := pool.QueryRow(ctx, "SELECT COUNT(*) FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email = $1)", "testpw@example.com") + _ = row.Scan(&count) + if count != 0 { + t.Errorf("session count = %d; want 0 on wrong password", count) + } +} + +func TestLogin_UnknownEmail(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + form := url.Values{"email": {"nouser@example.com"}, "password": {"correct-horse-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + // D-20: exact same error string as wrong-password case. + if !bytes.Contains(rec.Body.Bytes(), []byte("Invalid email or password")) { + t.Errorf("body must contain 'Invalid email or password' for unknown email; got: %s", rec.Body.String()) + } + if c := getSessionCookie(rec); c != nil { + t.Fatal("session cookie must NOT be set on unknown email") + } +} + +func TestLogin_ValidationError_BadEmail(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + form := url.Values{"email": {"not-an-email"}, "password": {"correct-horse-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d; want 422", rec.Code) + } + if !strings.Contains(rec.Body.String(), "valid email") { + t.Errorf("body missing 'valid email'; got: %s", rec.Body.String()) + } +} + +func TestLogin_ValidationError_ShortPassword(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + form := url.Values{"email": {"testval@example.com"}, "password": {"shortpw12"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d; want 422", rec.Code) + } + if !strings.Contains(rec.Body.String(), "12") { + t.Errorf("body missing '12' boundary; got: %s", rec.Body.String()) + } +} + +func TestLogin_RotatesExistingSession(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "rotatetest@example.com", "correct-horse-12chars") + // Pre-create a session for this user. + oldCookieValue, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + + form := url.Values{"email": {"rotatetest@example.com"}, "password": {"correct-horse-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: oldCookieValue}) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d; want 303", rec.Code) + } + + // New cookie value must differ from old. + newCookie := getSessionCookie(rec) + if newCookie == nil { + t.Fatal("no session cookie after login") + } + if newCookie.Value == oldCookieValue { + t.Error("session cookie value must change on login (rotation)") + } + + // Old session row must be gone (rotation deletes it). + oldSessionID := hashCookieValue(t, oldCookieValue) + var count int + row := pool.QueryRow(ctx, "SELECT COUNT(*) FROM sessions WHERE id = $1", oldSessionID) + _ = row.Scan(&count) + if count != 0 { + t.Errorf("old session row still exists after rotation; want 0") + } + + // New session row must exist for the user. + row2 := pool.QueryRow(ctx, "SELECT COUNT(*) FROM sessions WHERE user_id = $1", user.ID) + _ = row2.Scan(&count) + if count != 1 { + t.Errorf("new session count = %d; want 1", count) + } +} + +func TestLogin_AlreadyAuthedBouncesHome(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + + user := preInsertUser(t, ctx, q, "authed@example.com", "correct-horse-12chars") + cookieValue, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue}) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d; want 303 (RedirectIfAuthed)", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Errorf("Location = %q; want /", loc) + } +} + +func TestLogin_RateLimit_6thAttemptReturns429(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + + // Frozen clock so all 6 attempts happen at the same instant. + t0 := time.Now() + rl := auth.NewLimiterStoreWithClock(func() time.Time { return t0 }) + router := newTestRouterWithLimiter(q, store, rl) + + preInsertUser(t, ctx, q, "ratelimit@example.com", "correct-horse-12chars") + + for i := 1; i <= 6; i++ { + form := url.Values{"email": {"ratelimit@example.com"}, "password": {"wrong-password-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // Set RemoteAddr to a known IP so chimw.RealIP won't change it. + req.RemoteAddr = "192.168.1.1:12345" + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if i < 6 { + // Should not be 429 for the first 5 attempts. + if rec.Code == http.StatusTooManyRequests { + t.Fatalf("attempt %d: got 429 early (before 6th attempt)", i) + } + } else { + // 6th attempt must be 429. + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("attempt %d: status = %d; want 429", i, rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("Too many")) { + t.Errorf("attempt %d: body missing 'Too many'; got: %s", i, rec.Body.String()) + } + } + } +} + +func TestLogin_RateLimit_6thAttemptHTMXNoFullPage(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + + t0 := time.Now() + rl := auth.NewLimiterStoreWithClock(func() time.Time { return t0 }) + router := newTestRouterWithLimiter(q, store, rl) + + preInsertUser(t, ctx, q, "ratelimithtmx@example.com", "correct-horse-12chars") + + for i := 1; i <= 6; i++ { + form := url.Values{"email": {"ratelimithtmx@example.com"}, "password": {"wrong-password-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + req.RemoteAddr = "192.168.1.2:12345" + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if i == 6 { + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("HTMX attempt 6: status = %d; want 429", rec.Code) + } + // For HTMX the response should be a fragment (no tag). + if bytes.Contains(rec.Body.Bytes(), []byte(" page") + } + } + } +} + +func TestLogin_RateLimit_KeyedByEmailPlusIP(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + + t0 := time.Now() + rl := auth.NewLimiterStoreWithClock(func() time.Time { return t0 }) + router := newTestRouterWithLimiter(q, store, rl) + + preInsertUser(t, ctx, q, "emailA@example.com", "correct-horse-12chars") + preInsertUser(t, ctx, q, "emailB@example.com", "correct-horse-12chars") + + // Exhaust emailA from IP1. + for i := 0; i < 6; i++ { + form := url.Values{"email": {"emailA@example.com"}, "password": {"wrong-password-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.1:1234" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + } + + // emailB from same IP1 should still be allowed (separate key). + form := url.Values{"email": {"emailB@example.com"}, "password": {"wrong-password-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.1:1234" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code == http.StatusTooManyRequests { + t.Error("emailB must not be rate-limited when only emailA was exhausted (key isolation)") + } +} + +func TestLogin_RateLimit_AppliesBeforeUserLookup(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + q := sqlc.New(pool) + store := auth.NewStore(q) + + t0 := time.Now() + rl := auth.NewLimiterStoreWithClock(func() time.Time { return t0 }) + router := newTestRouterWithLimiter(q, store, rl) + + // Use an email that does NOT exist in the DB. + for i := 0; i < 6; i++ { + form := url.Values{"email": {"nonexistent@example.com"}, "password": {"wrong-password-12chars"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "10.0.0.2:1234" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if i == 5 { + // 6th attempt: must be 429 even though email doesn't exist. + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("6th attempt for unknown email: status = %d; want 429 (rate gate before user lookup)", rec.Code) + } + } + } +} diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index f271d63..e44dbc8 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -48,12 +48,14 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps) http.Handler { r.Group(func(r chi.Router) { r.Use(auth.RedirectIfAuthed) r.Get("/signup", SignupPageHandler()) + r.Get("/login", LoginPageHandler()) }) - // Signup POST is intentionally outside the RedirectIfAuthed group: + // Signup and login POSTs are intentionally outside the RedirectIfAuthed group: // an authed user submitting the form directly should still get a useful // response; the GET guard handles the common case. r.Post("/signup", SignupPostHandler(deps)) + r.Post("/login", LoginPostHandler(deps)) r.Get("/", IndexHandler()) r.Get("/healthz", HealthzHandler(pinger)) diff --git a/backend/templates/auth_forms.go b/backend/templates/auth_forms.go index f3e6d83..4b80db6 100644 --- a/backend/templates/auth_forms.go +++ b/backend/templates/auth_forms.go @@ -15,3 +15,20 @@ type SignupErrors struct { Password string General string } + +// LoginForm carries the submitted email value back to the template so the +// email field can be repopulated on validation failure. +// Password is intentionally never echoed back to the client (T-2-21, D-25). +type LoginForm struct { + Email string +} + +// LoginErrors holds per-field and general error messages for the login form. +// A field with an empty string means "no error for this field". +// Note: the general error for credential failures uses the intentionally generic +// string "Invalid email or password" to prevent user enumeration (D-20). +type LoginErrors struct { + Email string + Password string + General string +} diff --git a/backend/templates/auth_login.templ b/backend/templates/auth_login.templ new file mode 100644 index 0000000..8cb4935 --- /dev/null +++ b/backend/templates/auth_login.templ @@ -0,0 +1,72 @@ +package templates + +import "backend/internal/web/ui" + +// LoginPage renders the full /login page wrapped in the base Layout. +// It delegates the form section to LoginFormFragment so HTMX can swap just the +// form on validation errors without re-rendering the surrounding shell. +templ LoginPage(form LoginForm, errs LoginErrors) { + @Layout("Sign in") { +
+ @ui.Card(nil) { +
+

Sign in to your account

+ @LoginFormFragment(form, errs) +
+ } +
+ } +} + +// LoginFormFragment is the bare form used for HTMX swaps. +// hx-post targets this component itself so the form can be replaced inline +// on validation failure (D-19, D-20). +// The outer id="login-form" must match the hx-target on this element. +templ LoginFormFragment(form LoginForm, errs LoginErrors) { +
+ + @GeneralError(errs.General) +
+ + + @FieldError(errs.Email) +
+
+ + + @FieldError(errs.Password) +
+ @ui.Button(ui.ButtonProps{ + Label: "Sign in", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) +
+}