xtablo-source/backend/internal/web/handlers_auth.go
2026-05-15 21:03:30 +02:00

343 lines
13 KiB
Go

package web
import (
"errors"
"log/slog"
"net"
"net/http"
"net/mail"
"strings"
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/templates"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// 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
DB TxBeginner
OAuth auth.OAuthConfig
GoogleTokenExchanger auth.CodeExchanger
GoogleVerifier auth.IDTokenVerifier
}
// 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.
func SignupPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r)).Render(r.Context(), w)
}
}
// SignupPostHandler handles POST /signup: validate → hash → insert → create session → redirect.
//
// Security invariants (threat model):
// - Password length is validated BEFORE calling auth.Hash to prevent long-password DoS (T-2-14).
// - The raw password is never passed to any template (T-2-01).
// - Email is not logged on validation errors (T-2-18).
// - Duplicate email is detected via pgconn error code 23505 (T-2-19).
// - A fresh session token is created on every signup (T-2-04).
// - Form values are read via r.PostFormValue ONLY (never r.Body) so gorilla/csrf
// body consumption does not interfere (Pitfall 1, T-2-08c).
func SignupPostHandler(deps AuthDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Read form values via r.PostFormValue (Pitfall 1: never read r.Body directly).
email := strings.TrimSpace(r.PostFormValue("email"))
password := r.PostFormValue("password")
var errs templates.SignupErrors
// 2. Validate email.
if _, err := mail.ParseAddress(email); err != nil {
errs.Email = "Enter a valid email address"
}
// 3. Validate password length BEFORE calling Hash (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 != "" {
// Re-populate the email field but NOT the password (T-2-01).
renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity)
return
}
// 4. Normalize email (lowercase) before insert; citext handles case-insensitive
// uniqueness at DB level but we store canonical lowercase for consistency (D-01).
normalized := strings.ToLower(email)
// 5. Hash password with production cost parameters.
hash, err := auth.Hash(password, auth.DefaultParams)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// 6. Insert user row.
user, err := deps.Queries.InsertUser(ctx, sqlc.InsertUserParams{
Email: normalized,
PasswordHash: pgtype.Text{String: hash, Valid: true},
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if socialOnly, socialErr := deps.Queries.IsSocialOnlyUserByEmail(ctx, normalized); socialErr == nil && socialOnly {
errs.Email = "An account already exists for this email. Sign in with your provider."
renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity)
return
}
// Unique-constraint violation on email (T-2-19).
// Specific error message is acceptable on signup per CONTEXT.md specifics.
errs.Email = "That email is already in use."
renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity)
return
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// 7. Create session (D-10: fresh token on every signup auto-login).
cookieValue, expiresAt, err := deps.Store.Create(ctx, user.ID)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// 8. Set session cookie (D-12).
auth.SetSessionCookie(w, cookieValue, expiresAt, deps.Secure)
// 9. Redirect to home (D-11, 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)
}
}
// renderSignupError writes a validation-error response.
// For HTMX requests it renders only the form fragment; for plain requests it
// renders the full page (D-19, D-25).
func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if r.Header.Get("HX-Request") == "true" {
_ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
} else {
_ = templates.SignupPage(form, errs, csrf.Token(r)).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{}, csrf.Token(r)).Render(r.Context(), w)
}
}
// LoginPostHandler handles POST /login:
// 1. Rate-limit check before any other work — even invalid inputs consume tokens (D-16, D-18; gates DoS T-2-14)
// 2. Validate email + password format (specific errors per D-20/D-25)
// 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).
// - Form values are read via r.PostFormValue ONLY (never r.Body) so gorilla/csrf
// body consumption does not interfere (Pitfall 1, T-2-08c).
func LoginPostHandler(deps AuthDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Read form values via r.PostFormValue (Pitfall 1: never read r.Body directly).
email := strings.TrimSpace(r.PostFormValue("email"))
password := r.PostFormValue("password")
// 2. Rate-limit check FIRST — even on invalid input, to prevent enumeration
// via timing differences on malformed payloads (D-16, T-2-14).
// Key uses lowercased raw email + IP so malformed inputs consume rate tokens.
// Limiter may be nil in tests that don't exercise rate-limiting.
ip := clientIP(r)
key := strings.ToLower(email) + ":" + ip
if deps.Limiter != nil && !deps.Limiter.Allow(key) {
var rateLimitErrs templates.LoginErrors
rateLimitErrs.General = "Too many attempts. Try again in a minute."
renderLoginError(w, r, templates.LoginForm{Email: email}, rateLimitErrs, http.StatusTooManyRequests)
return
}
var errs templates.LoginErrors
// 3. Validate email (specific error — D-20: validation errors ARE specific).
if _, err := mail.ParseAddress(email); err != nil {
errs.Email = "Enter a valid email address"
}
// 4. 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
}
// 5. Normalize email for DB lookup.
normalized := strings.ToLower(email)
// 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).
if !user.PasswordHash.Valid {
// Social-only accounts have no local password. Keep the same generic
// credential failure used for unknown email and wrong password.
errs.General = errInvalidCreds
renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized)
return
}
ok, err := auth.Verify(user.PasswordHash.String, 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, csrf.Token(r)).Render(r.Context(), w)
} else {
_ = templates.LoginPage(form, errs, csrf.Token(r)).Render(r.Context(), w)
}
}
// LogoutHandler handles POST /logout: deletes the session row and clears the
// cookie, then redirects to /login.
//
// Security invariants:
// - Only reachable via the RequireAuth-gated protected group (D-23).
// - Defense-in-depth: if somehow reached unauthenticated, redirects to /login.
// - Store.Delete hard-deletes the session row (D-06, T-2-07).
// - ClearSessionCookie sets Max-Age=-1 to expire the browser cookie (D-06).
// - HTMX requests receive 200 + HX-Redirect; plain requests receive 303 (D-22).
// - CSRF token validated by gorilla/csrf middleware before this handler runs (AUTH-06).
func LogoutHandler(deps AuthDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Defense-in-depth: RequireAuth already gates this route, but guard here
// too so the handler never panics on a nil session.
sess, _, ok := auth.Authed(r.Context())
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Hard-delete the session row from the DB (D-06, T-2-07).
if err := deps.Store.Delete(r.Context(), sess.ID); err != nil {
slog.Default().Error("logout: delete session", "session_id", sess.ID, "err", err)
// Continue and clear the cookie even on delete error — partial
// invalidation is better than leaving the cookie intact.
}
// Expire the browser cookie (D-06: Max-Age=-1).
auth.ClearSessionCookie(w, deps.Secure)
// HTMX-aware redirect (D-23).
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}