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
This commit is contained in:
Arthur Belleville 2026-05-14 22:27:54 +02:00
parent b5c20c7892
commit 7d8c498980
No known key found for this signature in database
7 changed files with 699 additions and 3 deletions

View file

@ -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()

View file

@ -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).

View file

@ -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)
}
}

View file

@ -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 <html> tag).
if bytes.Contains(rec.Body.Bytes(), []byte("<html")) {
t.Error("HTMX rate-limit response must not contain full <html> 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)
}
}
}
}

View file

@ -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))

View file

@ -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
}

View file

@ -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") {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold">Sign in to your account</h1>
@LoginFormFragment(form, errs)
</div>
}
</div>
}
}
// 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) {
<form
id="login-form"
method="POST"
action="/login"
hx-post="/login"
hx-target="#login-form"
hx-swap="outerHTML"
class="space-y-5"
>
<!-- CSRF field added in Plan 07 -->
@GeneralError(errs.General)
<div>
<label for="email" class="block text-sm font-medium text-slate-700">Email address</label>
<input
id="email"
type="email"
name="email"
value={ form.Email }
required
autocomplete="email"
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
placeholder="you@example.com"
/>
@FieldError(errs.Email)
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700">Password</label>
<input
id="password"
type="password"
name="password"
required
autocomplete="current-password"
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
placeholder="Your password"
/>
@FieldError(errs.Password)
</div>
@ui.Button(ui.ButtonProps{
Label: "Sign in",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</form>
}