go-htmx-gsd #1
7 changed files with 699 additions and 3 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
72
backend/templates/auth_login.templ
Normal file
72
backend/templates/auth_login.templ
Normal 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>
|
||||
}
|
||||
Loading…
Reference in a new issue