package auth import ( "context" "crussell/auth" "crussell/clock" "crussell/db" "crussell/internal/dav" "crussell/internal/twofa" "crussell/internal/validators" "crussell/internal/zxcvbnjs" "crussell/mw" "crypto/rand" "encoding/hex" "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 } // dummyPasswordHash is a real bcrypt hash of a fixed throwaway string, used to // burn the same constant-time bcrypt work on the login no-user path as a real // wrong-password compare (see LoginHandler). It MUST be a well-formed bcrypt // hash: CompareHashAndPassword on a malformed hash returns immediately (fast), // which would reintroduce the timing oracle it exists to remove. const dummyPasswordHash = "$2a$10$x9x4AEOAU.UbaGCmVsVwu.TUhfOfR2LfbmWjB/H2At8Sx69WIlkri" // Verification-code purpose values (verification_purpose enum in init-script.sql). const ( verificationCodePurposeEmailVerify = "email_verify" verificationCodePurposePasswordReset = "password_reset" ) // verificationCodePepperEnv is the environment variable whose value keys the // HMAC-SHA256 of stored verification codes (the SAME pepper the 2FA path uses — // crussell/internal/twofa Hash). Read through the build-tagged // verificationCodeEnsureIssueAllowed (verifycode_dev.go / verifycode_prod.go): // dev/test builds fall back to the legacy plain SHA-256 digest with the 2FA // warning, while production builds refuse to issue codes without the pepper. const verificationCodePepperEnv = "TWO_FACTOR_PEPPER" // Email-verification attempt budget (Round 2 Loop A finding 8 + hardening): // 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. The key is the RESOLVED USER id whenever a submitted code // matches a verification_codes row (a code belongs to exactly one user, so the // budget follows the ACCOUNT being attacked, not the submitted code value) and // the submitted code value only when no row exists to resolve a user (a pure // guess cannot be attributed). Keying per-user closes the evasion where an // attacker holding several codes for one victim (or probing which values are // live) drained a fresh budget per 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 key's (a user id, or a // submitted code with no resolvable user) attempt budget is already spent, // rejecting the request before any DB work. func emailVerifyAttemptsExhausted(key string) bool { emailVerifyMu.Lock() defer emailVerifyMu.Unlock() evictStaleEmailVerifyAttemptsLocked() a, ok := emailVerifyAttempts[key] return ok && a.count >= emailVerifyMaxAttempts } // emailVerifyAttemptFailed registers one failed verification attempt for the // key and reports whether the budget for that key is now exhausted (the handler // should respond 429). func emailVerifyAttemptFailed(key string) bool { emailVerifyMu.Lock() defer emailVerifyMu.Unlock() evictStaleEmailVerifyAttemptsLocked() now := clock.Now() a := emailVerifyAttempts[key] if now.Sub(a.lastAt) > emailVerifyAttemptWindow { a.count = 0 } a.count++ a.lastAt = now emailVerifyAttempts[key] = a return a.count >= emailVerifyMaxAttempts } // emailVerifyAttemptsClear drops the budget for a key after a successful // verify (the code is consumed; the entry would only leak stale state). func emailVerifyAttemptsClear(key string) { emailVerifyMu.Lock() delete(emailVerifyAttempts, key) 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 { // F2-HIGH (user-existence timing oracle): a non-existent email used to // return before any bcrypt work, so its latency (~1 DB round trip) was // measurably shorter than a wrong-password attempt against an existing // account (~1 DB round trip + ~60ms bcrypt) — an attacker could probe // which emails are registered from response timing. Burn the same // constant-time bcrypt compare a real login would, under the shared // bcrypt slot budget, and discard the result. The dummy hash is a real // bcrypt hash (see dummyPasswordHash) so the compare runs the full cost. if release, ok := acquireBcryptSlot(); ok { _ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(req.Password)) release() } http.Error(w, "invalid credentials", http.StatusUnauthorized) return } // Check if account is locked (F5.5): the response MUST be indistinguishable // from a generic invalid-credentials failure — same status, same body, and // the same constant-time bcrypt work — so an attacker can never tell // "locked" from "wrong password". A distinguishable lockout (the old 429 // "account is temporarily locked") is an account-existence oracle AND a // lockout-probing signal: an attacker burning 5 wrong passwords to DoS a // victim could then watch the victim's lockout state flip. The lockout // itself is inherent to the 5-attempt progressive policy; hiding the state // is what removes the oracle. The audit line stays server-side only. // A locked-out user's recovery is the backend-only password-reset flow (see // the success-path TODO below) or an operator clearing the columns at the DB. 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) { // Burn the same constant-time bcrypt compare a real login would, so // the locked path's latency cannot distinguish it either. The compare // result is discarded: a locked account stays locked (and the result // could match if the attacker guessed the password — still no login). if release, ok := acquireBcryptSlot(); ok { _ = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) release() } log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context())) http.Error(w, "invalid credentials", http.StatusUnauthorized) 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 >= 10 THEN INTERVAL '60 minutes' 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 // // 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 // (15min at 5+ failures, 30min at 7+, 60min at 10+ — see the failure path // above); the operator can only intervene at the DB. The escalating ceiling // is the repeat-DoS mitigation: an attacker who keeps guessing past each // unlock makes the lock LONGER (up to 60 minutes) instead of merely // sustaining the 15-minute tier, raising the effort-per-DoS ratio while the // response stays the uniform 401 (never distinguishable from a wrong // password). 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 60-minute ceiling is the bounded-DoS compromise; a successful // 2FA verify also clears the lockout (internal/twofa.Check), and — since // the HIGH finding wiring — so does a successful password_reset verification // code (VerifyCodeHandler), giving a locked-out user a self-service recovery // path (generate → verify → log in → change password). 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(¤tRole) 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"` // Purpose is the verification_purpose the code authorises: "email_verify" // (default, escalates unverified_email → verified_email) or "password_reset" // (clears a login lockout — see VerifyCodeHandler). Validated in code so the // comparison is case-insensitive after trim/lower. Purpose string `json:"purpose,omitempty"` } type VerifyCodeRequest struct { Code string `json:"code" validate:"required,max=64"` } type VerificationResponse struct { Success bool `json:"success"` Message string `json:"message,omitempty"` } // generateVerificationCode returns a 12-hex-character code (48 bits of // randomness), matching the old DB-default generator // (gen_random_bytes(6) hex). Only its HMAC-SHA256 digest is ever persisted; the // plaintext exists solely to be delivered out-of-band (dev [VERIFY] log relay, // or the future SMTP channel) and is never stored. func generateVerificationCode() (string, error) { buf := make([]byte, 6) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // POST /api/verify/generate // Creates a verification_codes row for the account matching the submitted // email (if any), storing ONLY the HMAC-SHA256 digest of a fresh // 12-hex-char code (pepper-keyed via crussell/internal/twofa — see // verificationCodePepperEnv). The plaintext code is delivered build-dependently // (verifycode_dev.go / verifycode_prod.go): dev/test builds write it to the // server log ([VERIFY] prefix) — the loose-fake stand-in for the not-yet-wired // email/SMS transport (P6) — while production builds fail closed when // TWO_FACTOR_PEPPER is unset (an unsalted digest in the 48-bit code space // would be offline-brute-forceable from a DB leak) or when no delivery channel // is configured (email/SMS unwired; stdout-log delivery is a dev/test-only // local feature). The response is IDENTICAL whether or not the email exists, so // the endpoint cannot be used to enumerate registered addresses. // // The purpose field wires the lockout-recovery flow (HIGH finding): a locked-out // user requests a password_reset code for their own email, obtains it (dev log / // operator relay), verifies it at /api/verify/check, and the lockout is cleared // so they can log in and change their password. 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 } purpose := strings.TrimSpace(strings.ToLower(req.Purpose)) if purpose == "" { purpose = verificationCodePurposeEmailVerify } if purpose != verificationCodePurposeEmailVerify && purpose != verificationCodePurposePasswordReset { http.Error(w, "purpose must be 'email_verify' or 'password_reset'", http.StatusBadRequest) return } // A locked-out user reaches this endpoint UNAUTHENTICATED by design (the // whole point of password_reset recovery), so no auth middleware guards it; // the per-IP rate limit on the route is the only throttle, matching the // 2FA mint paths. // Build-dependent issuance gate (pepper + delivery channel in production; // always allowed in dev/test — see verifycode_dev.go / verifycode_prod.go). if err := verificationCodeEnsureIssueAllowed(); err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } // Fail-closed reference: the user lookup happens AFTER the issuance gate so // a prod deployment without the pepper/delivery channel refuses BEFORE any // per-email work (and before the enumeration-uniform path below is reached). 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) { // Uniform anti-enumeration response — byte-identical to the // existing-user branch. if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); 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 } code, err := generateVerificationCode() if err != nil { log.Printf("Failed to generate verification code: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } expiresAt := clock.Now().Add(24 * time.Hour) // Persist ONLY the digest; the plaintext code exists only in the delivery // channel (log relay / future SMTP). _, err = db.Conn.Exec(r.Context(), `INSERT INTO verification_codes (user_id, purpose, code, expires_at) VALUES ($1, $2, $3, $4)`, userID, purpose, twofa.Hash(code), expiresAt, ) if err != nil { log.Printf("Failed to insert verification code: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } verificationCodeDeliver(userID, purpose, code) if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the account exists, a verification code has been generated"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // POST /api/verify/check // Consumes a verification code submitted by an UNAUTHENTICATED caller (there is // no auth middleware on this route — the password_reset recovery flow must be // reachable by a locked-out user). The submitted code is hashed the same way it // was stored (twofa.Hash) and matched against verification_codes; a match // resolves the owning user, and the brute-force attempt budget is keyed PER // USER from that point on (see the emailVerifyAttempts* docs). On a valid, // unexpired, unused code: // // - purpose email_verify: escalates the account to verified_email; // - purpose password_reset: clears failed_attempts / locked_until so the // account owner can log in and change their password (the lockout-recovery // path for the login-DoS finding). 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. This pre-check uses the submitted code // as the key (a guess's miss path; the per-user key cannot be derived until // a row resolves it). if emailVerifyAttemptsExhausted(code) { http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests) return } codeDigest := twofa.Hash(code) var userID string var purpose string var expiresAt time.Time var usedAt *time.Time err := db.Conn.QueryRow(r.Context(), `SELECT user_id, purpose, expires_at, used_at FROM verification_codes WHERE code = $1`, codeDigest, ).Scan(&userID, &purpose, &expiresAt, &usedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // No row with this digest at all — a guess. No user can be // resolved, so the attempt budget stays keyed per submitted code. 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 } // The code resolved to exactly one user — from here the attempt budget is // keyed PER USER, so an attacker draining a victim's codes cannot get a // fresh budget per submitted value. if emailVerifyAttemptsExhausted(userID) { http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests) return } // Code exists but was already used — a definite state, not a guess. if usedAt != nil { http.Error(w, "code already used", http.StatusForbidden) return } // Code exists but expired — count it against the user's budget too. if !expiresAt.After(clock.Now()) { if emailVerifyAttemptFailed(userID) { http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests) return } http.Error(w, "invalid or expired code", http.StatusBadRequest) 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`, codeDigest, ) if err != nil { log.Printf("Failed to mark code as used: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } message := "Email verified successfully" switch purpose { case verificationCodePurposeEmailVerify: _, 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 } case verificationCodePurposePasswordReset: // Lockout-recovery consumer (HIGH finding): a verified password_reset // code proves control of the account's email, so the login lockout is // lifted. The user then logs in and changes their password via the // existing change-password flow. message = "Verification successful - login lockout cleared" _, err = tx.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1`, userID, ) if err != nil { log.Printf("Failed to clear login lockout for password_reset: %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 } // A successful verify clears the user's attempt budget (and the miss-path // key the submitted code used on earlier guesses). emailVerifyAttemptsClear(userID) emailVerifyAttemptsClear(code) if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } }