Files
Crussell/backend/handlers/auth/local.go
T
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

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

936 lines
33 KiB
Go

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
// maxConcurrentLoginBcrypt bounds how many login requests may run their bcrypt
// comparison concurrently (Round 2 Loop A finding 4b). The progressive per-IP
// middleware sleeps BEFORE this handler, so without the cap a flood of
// throttled login requests could stack an unbounded number of goroutines that
// all hit bcrypt the moment their sleeps elapse — a CPU-amplification vector.
// Beyond the cap the login is rejected 429 immediately (nothing has been
// processed, so nothing leaks).
const maxConcurrentLoginBcrypt = 20
// Login state management
var (
loginStateMu sync.Mutex
loginInProgress = make(map[string]time.Time)
// loginBcryptSlots is the counting semaphore backing maxConcurrentLoginBcrypt.
loginBcryptSlots = make(chan struct{}, maxConcurrentLoginBcrypt)
)
// acquireLoginBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
// means the handler must respond 429.
func acquireLoginBcryptSlot() (release func(), ok bool) {
select {
case loginBcryptSlots <- struct{}{}:
return func() { <-loginBcryptSlots }, 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
}
// 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 := acquireLoginBcryptSlot()
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)
}
}