350 lines
14 KiB
Go
350 lines
14 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(deps AuthDeps) 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), providerButtons(deps)).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, providerButtons(deps), 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, providerButtons(deps), 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, providerButtons(deps), 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, providers templates.AuthProviderButtons, 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), providers).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
// LoginPageHandler renders the GET /login page with an empty form.
|
|
func LoginPageHandler(deps AuthDeps) 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), providerButtons(deps)).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, providerButtons(deps), 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, providerButtons(deps), 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, providerButtons(deps), 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, providerButtons(deps), 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, providerButtons(deps), 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, providers templates.AuthProviderButtons, 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), providers).Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func providerButtons(deps AuthDeps) templates.AuthProviderButtons {
|
|
providers := templates.EmptyAuthProviderButtons()
|
|
providers.Google.Configured = deps.OAuth.Google.Configured()
|
|
providers.Google.StartURL = "/auth/google/start"
|
|
return providers
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|