Files
Crussell/backend/handlers/auth/local.go
T
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

962 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"crussell/auth"
"crussell/clock"
"crussell/db"
"crussell/internal/dav"
"crussell/internal/validators"
"crussell/internal/zxcvbnjs"
"crussell/mw"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"github.com/jackc/pgx/v5"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/nyaruka/phonenumbers"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// maxLoginInProgress caps the loginInProgress map (Round 2 Loop A finding 5):
// at most this many logins may be mid-flight at once before the next is
// rejected 429. Stale entries are evicted before the cap is consulted (see
// evictStaleLoginEntriesLocked), so a single attacker holding N fake entries
// cannot permanently exhaust the global budget — only genuinely concurrent
// logins occupy it, and each entry self-releases via the handler's deferred
// delete.
const maxLoginInProgress = 20
// loginInProgressWindow is how long a loginInProgress entry is considered
// live before it is stale and evictable.
const loginInProgressWindow = 30 * time.Second
// maxConcurrentBcrypt bounds how many bcrypt operations may run concurrently
// across BOTH /login and /register (Round 2 Loop A finding 4b + Round 2 Loop B
// finding 4). The progressive per-IP middleware sleeps BEFORE this handler, so
// without the cap a flood of throttled requests could stack an unbounded number
// of goroutines that all hit bcrypt the moment their sleeps elapse — a
// CPU-amplification vector (a register-botnet also burns CPU on
// bcrypt.GenerateFromPassword). Beyond the cap the request is rejected 429
// immediately (nothing has been processed, so nothing leaks).
//
// Round 2 Loop B finding 5b — ACCEPTED BOUNDED-DoS TRADE-OFF: the 20-slot
// global bound is shared by login AND register AND, by extension, every
// authenticated user. A sustained flood at either endpoint can therefore
// starve bcrypt for everyone for up to one request at a time (429 "server
// busy"). That is the intended trade-off: 20 genuinely concurrent bcrypt
// operations (~20 × ~60ms ≈ 1.2s of wall time) is far more than a single
// salon ever produces, and bounding the CPU is the point of the cap.
const maxConcurrentBcrypt = 20
// Login state management
var (
loginStateMu sync.Mutex
loginInProgress = make(map[string]time.Time)
// authBcryptSlots is the counting semaphore backing maxConcurrentBcrypt,
// shared by LoginHandler and RegisterHandler.
authBcryptSlots = make(chan struct{}, maxConcurrentBcrypt)
)
// acquireBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
// means the handler must respond 429. Shared by login and register so the
// bcrypt CPU budget is global, not per-endpoint.
func acquireBcryptSlot() (release func(), ok bool) {
select {
case authBcryptSlots <- struct{}{}:
return func() { <-authBcryptSlots }, true
default:
return nil, false
}
}
// evictStaleLoginEntriesLocked removes loginInProgress entries older than
// loginInProgressWindow. Caller must hold loginStateMu.
func evictStaleLoginEntriesLocked(now time.Time) {
for userID, startedAt := range loginInProgress {
if now.Sub(startedAt) > loginInProgressWindow {
delete(loginInProgress, userID)
}
}
}
// CleanupStaleLoginEntries removes stuck loginInProgress entries older than
// loginInProgressWindow. Called by the centralised jobs scheduler.
func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
loginStateMu.Lock()
defer loginStateMu.Unlock()
evictStaleLoginEntriesLocked(clock.Now())
return 0, nil
}
// Email-verification attempt budget (Round 2 Loop A finding 8): POST
// /verify/check had no per-user attempt counter, so a client holding a code
// could fail it indefinitely and the endpoint doubled as an unbounded guessing
// oracle. Mirror the 2FA attempt pattern: an in-memory map keys a 5-attempt
// budget per submitted code. The code is the only identifier a wrong guess
// carries, and every code is user-scoped (one code belongs to exactly one
// user), so the budget is effectively per-user-per-code — a distinct user can
// never drain another's budget for the same code. A successful verify clears
// the entry; the 5th failed attempt exhausts the budget (429). The map is
// bounded and stale entries are evicted, so a flood of random guesses cannot
// grow it without bound.
const (
emailVerifyMaxAttempts = 5
emailVerifyAttemptWindow = 30 * time.Minute
emailVerifyMaxTrackedCodes = 10_000
)
type emailVerifyAttempt struct {
count int
lastAt time.Time
}
var (
emailVerifyMu sync.Mutex
emailVerifyAttempts = make(map[string]emailVerifyAttempt)
)
// emailVerifyAttemptsExhausted reports whether the submitted code's attempt
// budget is already spent, rejecting the request before any DB work.
func emailVerifyAttemptsExhausted(code string) bool {
emailVerifyMu.Lock()
defer emailVerifyMu.Unlock()
evictStaleEmailVerifyAttemptsLocked()
a, ok := emailVerifyAttempts[code]
return ok && a.count >= emailVerifyMaxAttempts
}
// emailVerifyAttemptFailed registers one failed verification attempt for the
// submitted code and reports whether the budget for that code is now exhausted
// (the handler should respond 429).
func emailVerifyAttemptFailed(code string) bool {
emailVerifyMu.Lock()
defer emailVerifyMu.Unlock()
evictStaleEmailVerifyAttemptsLocked()
now := clock.Now()
a := emailVerifyAttempts[code]
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
a.count = 0
}
a.count++
a.lastAt = now
emailVerifyAttempts[code] = a
return a.count >= emailVerifyMaxAttempts
}
// emailVerifyAttemptsClear drops the budget for a code after a successful
// verify (the code is consumed; the entry would only leak stale state).
func emailVerifyAttemptsClear(code string) {
emailVerifyMu.Lock()
delete(emailVerifyAttempts, code)
emailVerifyMu.Unlock()
}
// evictStaleEmailVerifyAttemptsLocked bounds the attempts map. Caller must
// hold emailVerifyMu.
func evictStaleEmailVerifyAttemptsLocked() {
if len(emailVerifyAttempts) < emailVerifyMaxTrackedCodes {
return
}
now := clock.Now()
for k, a := range emailVerifyAttempts {
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
delete(emailVerifyAttempts, k)
}
}
}
type RegisterRequest struct {
FirstName string `json:"firstName" validate:"required,min=1,max=50"`
LastName string `json:"lastName" validate:"required,min=1,max=50"`
Email string `json:"email" validate:"required,email,max=254"`
Password string `json:"password" validate:"required,min=6,max=72"`
Phone string `json:"phone" validate:"required"`
DateOfBirth string `json:"dateOfBirth" validate:"required"`
AgreedToPolicy bool `json:"agreedToPolicy"`
ReferralCode string `json:"referralCode,omitempty" validate:"omitempty,max=12"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email,max=254"`
Password string `json:"password" validate:"required,max=72"`
}
// POST /api/register
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Must accept terms
if !req.AgreedToPolicy {
mw.RespondError(w, http.StatusBadRequest, "must agree to terms")
return
}
// Normalize input
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.FirstName = strings.TrimSpace(req.FirstName)
req.LastName = strings.TrimSpace(req.LastName)
req.Phone = strings.TrimSpace(req.Phone)
req.DateOfBirth = strings.TrimSpace(req.DateOfBirth)
// Check required fields
if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" || req.DateOfBirth == "" || req.Password == "" {
http.Error(w, "all fields are required", http.StatusBadRequest)
return
}
// Password must not exceed bcrypt's 72-byte limit
if len(req.Password) > 72 {
mw.RespondError(w, http.StatusBadRequest, "password must be 72 characters or less")
return
}
if len(req.Password) < 6 {
mw.RespondError(w, http.StatusBadRequest, "password must be at least 6 characters")
return
}
// Round 2 Loop B finding 4: /register previously ran the zxcvbn strength
// scoring AND bcrypt.GenerateFromPassword with NO concurrency cap — a
// register-botnet could stack unbounded goroutines burning CPU, and each
// registered account also fuels the notification-flood and attempt-map
// findings (1/3). Share the login bcrypt slot budget (acquireBcryptSlot —
// the global 20-slot cap, see maxConcurrentBcrypt): beyond it the
// registration is rejected 429 immediately. The slot wraps the expensive
// part (zxcvbn + bcrypt) and is released via defer on every path.
release, ok := acquireBcryptSlot()
if !ok {
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
return
}
defer release()
// Server-side password strength check using the same @zxcvbn-ts/core as the frontend
// via goja (ExecJS-style). Guarantees exact parity with frontend scoring.
// Skipped when GO_TESTING=1 (dev/test environments) to allow weaker passwords.
if os.Getenv("GO_TESTING") != "1" {
passwordStrength, err := zxcvbnjs.Score(req.Password)
if err != nil {
log.Printf("Password strength check failed: %v", err)
http.Error(w, "password is too weak. please choose a stronger password.", http.StatusBadRequest)
return
}
if passwordStrength < 2 {
http.Error(w, "password is too weak. please choose a stronger password.", http.StatusBadRequest)
return
}
}
// Validate name (unicode letters, spaces, hyphen, apostrophe, dot)
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
if !nameRegex.MatchString(req.FirstName) {
http.Error(w, "invalid characters in name", http.StatusBadRequest)
return
}
// Validate length
if len(req.FirstName) > 50 || len(req.FirstName) < 1 {
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
return
}
if len(req.LastName) > 50 || len(req.LastName) < 1 {
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
return
}
// Validate email format
if err := validators.ValidateEmail(req.Email); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Normalize phone (remove spaces, hyphens, parentheses)
req.Phone = strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' || r == '+' {
return r
}
return -1
}, req.Phone)
// Validate UK phone number
phone, err := ValidateUKPhoneNumber(req.Phone)
if err != nil {
http.Error(w, "invalid phone number format", http.StatusBadRequest)
return
}
req.Phone = strings.TrimSpace(phone)
// Convert names to title case
// Create per-call caser (cases.Caser is not goroutine-safe)
tc := cases.Title(language.English)
req.FirstName = tc.String(strings.ToLower(req.FirstName))
req.LastName = tc.String(strings.ToLower(req.LastName))
// Parse date of birth
dob, err := time.Parse("2006-01-02", req.DateOfBirth)
if err != nil {
http.Error(w, "invalid date format", http.StatusBadRequest)
return
}
// Reject if younger than 16
if !dob.Before(clock.Now().AddDate(-16, 0, 0)) {
http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest)
return
}
// Validate referral code if provided
var referrerID *string
req.ReferralCode = strings.ToLower(strings.TrimSpace(req.ReferralCode))
if req.ReferralCode != "" {
if len(req.ReferralCode) != 12 {
http.Error(w, "referral code must be exactly 12 characters", http.StatusBadRequest)
return
}
referralCodeRegex := regexp.MustCompile(`^[a-zA-Z0-9]{12}$`)
if !referralCodeRegex.MatchString(req.ReferralCode) {
http.Error(w, "referral code must be alphanumeric", http.StatusBadRequest)
return
}
// Look up referrer by referral code
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE referral_code = $1", req.ReferralCode).Scan(&referrerID)
if err != nil {
http.Error(w, "invalid referral code", http.StatusBadRequest)
return
}
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
now := clock.Now()
// Insert and return the generated ID
var userID string
err = tx.QueryRow(r.Context(), `
INSERT INTO users
(n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
created_at, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8)
RETURNING id
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "an account with this email already exists", http.StatusConflict)
} else {
http.Error(w, "could not create user", http.StatusInternalServerError)
}
return
}
// Record referral relationship if referral code was provided
if referrerID != nil {
_, err = tx.Exec(r.Context(), `
INSERT INTO user_referrals (referrer_id, referred_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, *referrerID, userID)
if err != nil {
http.Error(w, "could not process referral", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in CardDAV contact creation: %v", r)
}
}()
input := dav.ContactInput{
UserID: userID,
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
Phone: req.Phone,
DOB: req.DateOfBirth,
}
if err := dav.Service.CreateContact(1, userID, input); err != nil {
log.Printf("Warning: Failed to create contact in DAV for user %s: %v", userID, err)
}
}()
w.WriteHeader(http.StatusCreated)
}
func ValidateUKPhoneNumber(phone string) (string, error) {
num, err := phonenumbers.Parse(phone, "GB")
if err != nil {
return "", err
}
if !phonenumbers.IsValidNumber(num) {
return "", fmt.Errorf("invalid phone number")
}
// Check if it's actually a UK number
if phonenumbers.GetRegionCodeForNumber(num) != "GB" {
return "", fmt.Errorf("only UK numbers allowed")
}
// Format in E.164 format (+44...)
return phonenumbers.Format(num, phonenumbers.E164), nil
}
// POST /api/login
func LoginHandler(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
mw.RespondError(w, http.StatusBadRequest, "invalid request")
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Login validation failed: %v", err)
mw.RespondError(w, http.StatusBadRequest, "Email and password are required")
return
}
// Normalize email
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
var userID, passwordHash, role string
ctx := r.Context()
err := db.Conn.QueryRow(ctx, `
SELECT id, password_hash, account_role
FROM users
WHERE email = $1 AND account_type = 'email'
`, req.Email).Scan(&userID, &passwordHash, &role)
if err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
// Check if account is locked
var failedAttempts int
var lockedUntil *time.Time
err = db.Conn.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
if err == nil && lockedUntil != nil && clock.Now().Before(*lockedUntil) {
http.Error(w, "account is temporarily locked. try again later.", http.StatusTooManyRequests)
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
return
}
// Per-user in-flight slot (finding 5): the same account cannot have two
// concurrent login flows. A re-entry inside the slot window is rejected 429
// (not 409 — no conflict with a finished attempt, and a Conflict response
// would leak that a login for this account is mid-flight). Stale entries are
// evicted before the cap check so a single attacker holding N fake entries
// cannot exhaust the global budget: at most maxLoginInProgress genuinely
// concurrent logins occupy the map, each self-releasing via the deferred
// delete below.
loginStateMu.Lock()
evictStaleLoginEntriesLocked(clock.Now())
if t, ok := loginInProgress[userID]; ok && clock.Now().Sub(t) < loginInProgressWindow {
loginStateMu.Unlock()
mw.RespondError(w, http.StatusTooManyRequests, "login already in progress")
return
}
// Cap the map size - drop new request if at capacity
if len(loginInProgress) >= maxLoginInProgress {
loginStateMu.Unlock()
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
return
}
loginInProgress[userID] = clock.Now()
loginStateMu.Unlock()
// Always clear flag when done
defer func() {
loginStateMu.Lock()
delete(loginInProgress, userID)
loginStateMu.Unlock()
}()
// Verify password under the concurrency cap (finding 4b). The slot is
// released immediately after the compare — bcrypt is the expensive,
// amplifier-prone part; the DB work below is cheap. The deferred delete
// above releases this user's in-flight slot on every path.
release, ok := acquireBcryptSlot()
if !ok {
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
return
}
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
release()
if !passwordOK {
// Increment failed attempts in DB with progressive lockout
var newFailed int
var newLockedUntil *time.Time
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
err = tx.QueryRow(r.Context(), `
UPDATE users
SET failed_attempts = failed_attempts + 1,
locked_until = CASE
WHEN failed_attempts + 1 >= 5 THEN NOW() + (CASE
WHEN failed_attempts + 1 >= 7 THEN INTERVAL '30 minutes'
ELSE INTERVAL '15 minutes'
END)
ELSE locked_until
END
WHERE id = $1
RETURNING failed_attempts, locked_until
`, userID).Scan(&newFailed, &newLockedUntil)
if err != nil {
log.Printf("Failed to update failed login attempts: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Printf("LOGIN_AUDIT: failed login user=%s ip=%s attempts=%d locked_until=%v",
userID, middleware.GetClientIP(r.Context()), newFailed, newLockedUntil)
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
// On success, clear lockout and update last_login
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
// failed_attempts and locked_until — a locked-out user can't call this handler.
//
// LOW 6 documented gap (finding 6): there is NO password-reset UI — the
// backend-only reset flow (GenerateVerificationCodeHandler/VerifyCodeHandler)
// has no frontend link, so a user locked out by a guessing attacker has no
// self-service recovery until locked_until lapses (capped at 30 minutes —
// see the failure path above); the operator can only intervene at the DB.
// The lockout counter stays keyed per-user (not per-(user,IP)) because this
// codebase deliberately rejects IP-in-the-key for account-level budgets (see
// the 2FA limiter note in main.go, B8): a client that rotates its source IP
// would mint a fresh bucket per IP and collapse the per-account budget. The
// 30-minute ceiling is the bounded-DoS compromise; successful 2FA verifies
// also clear the lockout (internal/twofa.Check).
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
_, err = tx.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
if err != nil {
log.Printf("Failed to reset login attempts on success: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Issue an opaque refresh token (B5): the 90-day credential is stored
// hashed in refresh_tokens and rotated on every use. A stolen ACCESS token
// can no longer self-renew — only a valid, unexpired, unrevoked refresh
// token can mint a new pair. The refresh token is returned in the body so
// the SPA can persist it and present it to POST /api/refresh-token. The
// access token is bound to the new rotation family (HIGH 1) so that if the
// login refresh token is ever replayed the whole family — access token
// included — is killed.
refreshToken, familyID, err := auth.GenerateRefreshToken(r.Context(), userID, role)
if err != nil {
log.Printf("failed to issue refresh token for user %s: %v", userID, err)
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
// Generate the access token AFTER the refresh token so it can be bound to
// the same rotation family.
tokenString, jti, err := auth.GenerateTokenForFamily(userID, role, familyID)
if err != nil {
log.Printf("failed to generate access token for user %s: %v", userID, err)
http.Error(w, "could not generate token", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the login response contract
Token: tokenString,
JTI: jti,
RefreshToken: refreshToken,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// POST /api/refresh-token
// Requires a valid refresh token in the Authorization header (Bearer). The
// opaque refresh token is validated against the DB (hashed), consumed
// (rotated), and exchanged for a fresh access token + a NEW refresh token.
//
// B5 (security): the handler deliberately does NOT accept the access token.
// VerifyRefreshToken rotates (marks used) the presented refresh token, so a
// stolen access token can never self-renew — it expires in 1 hour and only a
// valid, unexpired, unrevoked refresh token can mint a new pair. A replayed
// refresh token (used twice) returns 401, detecting theft via rotation and
// revoking the entire rotation family with a critical admin alert.
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization header"})
return
}
refreshToken := strings.TrimPrefix(authHeader, "Bearer ")
// VerifyRefreshToken consumes (rotates) the refresh token: the used token
// is marked used in refresh_tokens, so a stolen/leaked refresh token cannot
// be replayed and an access token alone can never mint a new session. A
// replayed (already-rotated) token revokes the entire rotation family and
// raises a critical admin alert, but still surfaces as this generic 401.
userID, role, familyID, err := auth.VerifyRefreshToken(r.Context(), refreshToken)
if err != nil {
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
return
}
// Verify user still exists and role hasn't changed
var currentRole string
err = db.Conn.QueryRow(r.Context(), `
SELECT account_role FROM users WHERE id = $1
`, userID).Scan(&currentRole)
if err != nil {
http.Error(w, "user not found", http.StatusUnauthorized)
return
}
if currentRole != role {
http.Error(w, "role changed, please log in again", http.StatusUnauthorized)
return
}
// Issue a fresh access token + refresh token pair. The rotated refresh
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
// a replayed ancestor can revoke the whole lineage, descendants included.
// The access token is bound to that same family (HIGH 1): when reuse
// detection kills the family, the freshly-minted access token handed to the
// attacker at rotation dies with it instead of staying valid for 1 hour.
newRefreshToken, err := auth.GenerateRefreshTokenInFamily(r.Context(), userID, currentRole, familyID)
if err != nil {
log.Printf("failed to issue rotated refresh token for user %s: %v", userID, err)
mw.RespondError(w, http.StatusInternalServerError, "could not generate refresh token")
return
}
// Mint the access token AFTER the descendant refresh token so it can be
// bound to the same rotation family.
newToken, jti, err := auth.GenerateTokenForFamily(userID, currentRole, familyID)
if err != nil {
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
return
}
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the refresh-token response contract
Token: newToken,
JTI: jti,
RefreshToken: newRefreshToken,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// POST /api/logout (requires auth middleware)
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
jti, ok := mw.GetJTI(r.Context())
if !ok || jti == "" {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
userID, _ := mw.GetUserID(r.Context())
// Revoke the JTI — match the access token lifetime (1 hour)
if err := auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour)); err != nil {
slog.Error("logout: failed to revoke JTI", "err", err)
mw.RespondError(w, http.StatusInternalServerError, "failed to revoke token. please try again.")
return
}
// B5: logging out must also kill the outstanding refresh credential for
// THIS session, or a previously-issued (possibly stolen) refresh token
// would keep the session alive past logout.
//
// LOW-1: the revocation is scoped to the PRESENTED access token's rotation
// family (family_id claim) instead of every refresh token the user holds.
// A stolen access token can no longer wipe every session the user keeps on
// other devices — only this token's own lineage dies, which is all B5
// needs (the presented refresh token lives in that family). An unbound
// token (no family_id claim — test/legacy minting via GenerateToken)
// falls back to the user-wide delete, preserving the old behaviour for
// those tokens.
familyID := auth.FamilyIDFromToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if familyID != "" {
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE family_id = $1`, familyID); err != nil {
slog.Error("logout: failed to revoke refresh token family", "familyID", familyID, "err", err)
}
// Drop the family-alive cache verdict so this family's access tokens
// (the logged-out one and any in-flight duplicates) are re-checked
// against the now-empty refresh_tokens on their next request.
auth.InvalidateFamilyAlive(familyID)
} else if userID != "" {
if _, err := db.Conn.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE user_id = $1`, userID); err != nil {
slog.Error("logout: failed to revoke refresh tokens", "userID", userID, "err", err)
}
}
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
type VerificationCodeRequest struct {
Email string `json:"email" validate:"required,email,max=254"`
}
type VerifyCodeRequest struct {
Code string `json:"code" validate:"required,max=64"`
}
type VerificationResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
var req VerificationCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
var userID string
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE LOWER(email) = $1", email,
).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
log.Printf("Failed to look up user: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
expiresAt := clock.Now().Add(24 * time.Hour)
var code string
err = db.Conn.QueryRow(r.Context(),
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt,
).Scan(&code)
if err != nil {
log.Printf("Failed to insert verification code: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
var req VerifyCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
code := strings.TrimSpace(req.Code)
if code == "" {
http.Error(w, "code is required", http.StatusBadRequest)
return
}
// Finding 8: a spent attempt budget rejects before any DB work — the code
// can no longer be guessed against.
if emailVerifyAttemptsExhausted(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
var userID string
var purpose string
var expiresAt time.Time
err := db.Conn.QueryRow(r.Context(),
`SELECT user_id, purpose, expires_at FROM verification_codes
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
code,
).Scan(&userID, &purpose, &expiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Check if code exists but was already used or expired
var checkUsedAt *time.Time
checkErr := db.Conn.QueryRow(r.Context(),
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
).Scan(&checkUsedAt)
if checkErr != nil {
// Code doesn't exist at all — a guess. Count it against the
// code's attempt budget.
if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
// Code exists but was already used — a definite state, not a guess.
if checkUsedAt != nil {
http.Error(w, "code already used", http.StatusForbidden)
return
}
// Code exists but expired — count it against the budget too.
if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
log.Printf("Failed to verify code: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
_, err = tx.Exec(r.Context(),
`UPDATE verification_codes SET used_at = NOW() WHERE code = $1`,
code,
)
if err != nil {
log.Printf("Failed to mark code as used: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if purpose == "email_verify" {
_, err = tx.Exec(r.Context(),
`UPDATE users SET account_role = 'verified_email' WHERE id = $1 AND account_role = 'unverified_email'`,
userID,
)
if err != nil {
log.Printf("Failed to update user role: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit verification: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Finding 8: a successful verify clears the code's attempt budget.
emailVerifyAttemptsClear(code)
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}