Files
Crussell/backend/handlers/user/twofa.go
T
popertots fe88f2084d fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

734 lines
29 KiB
Go

package user
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/twofa"
"crussell/internal/validators"
"crussell/mw"
"github.com/go-chi/chi/v5"
)
// twoFARequired reports whether 2FA enforcement is active in this deployment.
// The user endpoints and the profile handler expose this to the frontend so it
// can gate the settings UI.
func twoFARequired() bool {
return payments.NewPaymentService().TwoFactorEnforced()
}
// twoFAPendingExpiry is how long a generated verification code stays valid.
// Loose fake: real email/SMS infrastructure will own this lifetime once it lands.
const twoFAPendingExpiry = 10 * time.Minute
// generateTwoFACode returns a random 6-digit verification code.
func generateTwoFACode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// twoFAPepperEnv is the environment variable carrying the server-side pepper
// that keys the HMAC of stored 2FA codes (documented in .env.example). Its
// absence is handled per build: dev/test builds fall back to the legacy plain
// SHA-256 digest with a one-time warning, while production builds fail closed
// at code issuance — see twofa_dev.go / twofa_prod.go.
const twoFAPepperEnv = "TWO_FACTOR_PEPPER"
// errTwoFADeliveryUnavailable is returned by production builds when a 2FA code
// is requested but no delivery channel is configured: the email/SMS transport
// is not wired yet (P6) and the operator has not opted into the insecure
// log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Handlers surface it
// verbatim so setup fails loudly with an actionable message instead of issuing
// a code that could never reach the user (which would silently dead-end the
// enforced saved-card-payments gate). Dev/test builds always have the [2FA] log
// channel and never return it (see twofa_dev.go).
var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon")
// twoFAPepper reads TWO_FACTOR_PEPPER (plus the one-time unset warning) and
// twoFAEnsureIssueAllowed guards code issuance; both are build-dependent.
// Dev/test builds keep the documented loose-fake fallback (twofa_dev.go);
// production builds refuse to issue codes without the pepper (twofa_prod.go),
// matching how main.go fails closed on a missing JWT_SECRET_KEY. The pepper
// reader is registered into crussell/internal/twofa via twofa.SetPepperProvider
// by the build-tagged files (twofa_dev.go / twofa_prod.go).
// The verification core (per-user attempt-state map, brute-force lockout,
// constant-time code check) lives in crussell/internal/twofa — a package that
// imports neither handlers/user nor handlers/payments, so the payments
// card-access gate can verify a real 2FA challenge without an import cycle
// (B11c). The identifiers below are thin aliases/wrappers so the HTTP handlers
// and the existing tests keep their original names.
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
// before the pending code is invalidated and a new one must be requested.
const twoFAMaxAttempts = twofa.MaxAttempts
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
// resetting, and doubles as the stale-entry eviction horizon for the map.
const twoFAAttemptWindow = twofa.AttemptWindow
// hashTwoFACode returns the hex digest of a verification code as stored in the
// DB (pepper-driven HMAC-SHA256, or the legacy plain SHA-256 when the pepper is
// unset). Delegates to the shared implementation.
func hashTwoFACode(code string) string { return twofa.Hash(code) }
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest.
func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) }
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
// digest, always in constant time. Delegates to the shared implementation.
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
return twofa.VerifyHash(reqCode, storedHash)
}
// twoFAAttemptState aliases the shared per-user attempt state.
type twoFAAttemptState = twofa.AttemptState
// twoFAAttemptStateFor returns the shared per-user attempt state (creating it
// if needed), under the bounded never-evict-in-lockout map.
func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
return twofa.StateFor(userID)
}
// twoFAResetAttempts zeroes the shared per-user attempt counter in place.
// Called on successful verify only — a fresh code mint must NOT reset it (B11b).
func twoFAResetAttempts(userID string) {
twofa.ResetAttempts(userID)
}
// deliverTwoFACode generates a fresh verification code and persists only its
// digest plus the pending expiry (updating two_factor_method when method is
// non-empty).
//
// Delivery is build-dependent (twofa_dev.go / twofa_prod.go): dev/test builds
// write the plaintext code to the server log — the documented loose-fake
// stand-in for the not-yet-wired email/SMS transport (P6) — while production
// builds fail closed up front: twoFAEnsureIssueAllowed refuses to issue a code
// when TWO_FACTOR_PEPPER is unset (an unsalted digest would be
// offline-brute-forceable) or when no delivery channel is configured (email/SMS
// unwired and log delivery not explicitly opted into via
// TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — so a production setup never mints a
// code that could never reach the user. The API response still only returns the
// code when 2FA is unenforced (dev convenience). purpose labels the delivery
// (e.g. "setup", "disable 2FA").
//
// A fresh code does NOT reset the per-user failed-attempt counter (B11b): only
// a successful verify does. Resetting on re-mint would let a password-only
// attacker loop mint → burn 5 guesses → mint forever within a code's lifetime.
func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, error) {
if err := twoFAEnsureIssueAllowed(); err != nil {
return "", err
}
code, err := generateTwoFACode()
if err != nil {
return "", err
}
expires := clock.Now().Add(twoFAPendingExpiry)
if method != "" {
_, err = db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_method = $2,
two_factor_pending_code_hash = $3,
two_factor_pending_code_expires = $4
WHERE id = $1
`, userID, method, hashTwoFACode(code), expires)
} else {
_, err = db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1
`, userID, hashTwoFACode(code), expires)
}
if err != nil {
return "", err
}
label := method
if label == "" {
label = purpose
}
// Build-dependent delivery: dev/test logs the plaintext code ([2FA] line);
// production logs it ONLY when the operator explicitly opted into log
// delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — otherwise issuance was
// already refused by twoFAEnsureIssueAllowed above, so the default is that
// the code never reaches a log.
twoFADeliverCode(userID, label, code)
return code, nil
}
type TwoFAStatusResponse struct {
Enabled bool `json:"enabled"`
Method *string `json:"method"`
Required bool `json:"required"`
}
// GET /api/user/2fa/status
func GetTwoFAStatusHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var enabled bool
var method sql.NullString
err := db.Conn.QueryRow(r.Context(), `
SELECT two_factor_enabled, two_factor_method
FROM users
WHERE id = $1
`, userID).Scan(&enabled, &method)
if err != nil {
log.Printf("failed to fetch 2FA status for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
resp := TwoFAStatusResponse{Enabled: enabled, Required: twoFARequired()}
if method.Valid {
resp.Method = &method.String
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("failed to encode 2FA status response: %v", err)
}
}
type TwoFASetupRequest struct {
Method string `json:"method"`
}
// POST /api/user/2fa/setup
// Generates a verification code and stores only its SHA-256 hash plus a
// 10-minute expiry in the pending columns. Delivery is build-dependent (see
// deliverTwoFACode): dev/test builds log the code with a [2FA] 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 or when no
// delivery channel is configured (email/SMS unwired and
// TWO_FACTOR_ALLOW_LOG_DELIVERY=true unset), returning a clear actionable
// error instead of silently issuing a code that would never arrive. When 2FA is
// not enforced (dev), the code is also returned in the response so the flow is
// testable without reading backend logs.
func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req TwoFASetupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if req.Method != "email" && req.Method != "sms" {
http.Error(w, "method must be 'email' or 'sms'", http.StatusBadRequest)
return
}
var enabled bool
err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
if err != nil {
log.Printf("failed to check 2FA state for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if enabled {
http.Error(w, "Two-factor authentication is already enabled", http.StatusConflict)
return
}
// The per-user mutex serializes the mint with the disable flow's
// checkTwoFACode critical section so concurrent requests from the same user
// cannot race the mint cooldown or the lockout counter.
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
// Mint cooldown (B11a): the same twoFAMintCooldown guard the disable flow
// applies via ensurePendingTwoFACode now bounds setup re-mints too. A fresh
// setup code no longer resets the failed-attempt counter (B11b), so without
// this a setup-spam loop could mint fresh codes (each invalidating the
// prior lockout state) and keep a guessing budget alive indefinitely.
now := clock.Now()
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return
}
// Deliver a fresh code via the shared setup mechanism: generate, persist
// only the hash + expiry, and deliver it build-dependently (the [2FA] log
// channel in dev/test; in production only when the operator explicitly
// opted into log delivery — see deliverTwoFACode). A production build with
// no delivery channel fails here with a clear, actionable error instead of
// issuing a code that would never reach the user.
code, err := deliverTwoFACode(r, userID, req.Method, "setup")
if err != nil {
if errors.Is(err, errTwoFADeliveryUnavailable) {
// The deployment has no delivery channel at all (email/SMS unwired
// and no explicit log-delivery opt-in). Fail loudly — the code is
// never generated, never logged, and never persisted.
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
log.Printf("failed to store 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
st.LastMintAt = now
resp := map[string]any{"message": "Code sent"}
if !twoFARequired() {
// Dev convenience: unenforced environments return the code so the
// fake-delivery flow is usable without grepping the backend log.
resp["code"] = code
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("failed to encode 2FA setup response: %v", err)
}
}
type TwoFAVerifyRequest struct {
Code string `json:"code"`
}
// twoFACodeCheckResult classifies checkTwoFACode's outcome so callers can map
// it to the correct HTTP status. Aliased to the shared twofa.Result.
type twoFACodeCheckResult = twofa.Result
const (
twoFACodeOK = twofa.OK
twoFACodeIncorrect = twofa.Incorrect
twoFACodeLockedOut = twofa.LockedOut
twoFACodeMissingOrExpired = twofa.MissingOrExpired
)
// checkTwoFACode verifies the submitted code against the user's stored pending
// code under the per-user brute-force lockout, shared by VerifyTwoFAHandler,
// DisableTwoFAHandler and VerifyTwoFACodeForUser. The caller must hold st.Mu
// (from twoFAAttemptStateFor) so concurrent attempts from the same user cannot
// race the limit check. Delegates to the shared implementation in
// crussell/internal/twofa.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
res, err := twofa.Check(r.Context(), userID, st, reqCode)
return twoFACodeCheckResult(res), err
}
// VerifyTwoFACodeForUser verifies a 2FA code for a user under the same
// per-user brute-force lockout as the interactive endpoints, outside the HTTP
// handler layer. It returns nil on a correct code, or one of the exported
// twofa.ErrIncorrect / twofa.ErrLockedOut / twofa.ErrMissingOrExpired (or a DB
// error, wrapped).
//
// Coordination contract for the payments agent (B6/B10): handlers/payments
// cannot import handlers/user — handlers/user imports handlers/payments
// (TwoFactorEnforced, SquareClient), so a payments→user import is a cycle. The
// payments gate must call twofa.VerifyForUser(ctx, userID, code) from
// crussell/internal/twofa (the shared home of this verification core) instead
// of importing this package.
func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
result, err := twofa.Check(ctx, userID, st, code)
if err != nil {
return err
}
switch result {
case twoFACodeOK:
return nil
case twoFACodeIncorrect:
return twofa.ErrIncorrect
case twoFACodeLockedOut:
return twofa.ErrLockedOut
case twoFACodeMissingOrExpired:
return twofa.ErrMissingOrExpired
}
return nil
}
// POST /api/user/2fa/verify
// Confirms the pending code (SHA-256, timing-safe, not expired) and flips
// two_factor_enabled on. When 2FA is not enforced (dev) any code — including an
// empty one — verifies, so local testing never depends on reading the logged
// code.
func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req TwoFAVerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if !twoFARequired() {
// Dev bypass: no code verification in unenforced environments.
if err := enableTwoFA(r, userID); err != nil {
log.Printf("failed to enable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeTwoFAEnabled(w)
return
}
// Enforced path — brute-force resistant (see checkTwoFACode): the per-user
// mutex serializes the critical section so concurrent attempts cannot race
// the limit; after 5 consecutive failures the pending code is invalidated
// and further attempts get 429 until a new code is requested via setup.
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
switch result {
case twoFACodeIncorrect:
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case twoFACodeLockedOut:
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case twoFACodeMissingOrExpired:
// No valid pending code exists. If the deployment also has no delivery
// channel (production without an explicit log-delivery opt-in), the
// user can never receive a fresh code — setup and the disable mint all
// refuse issuance. Surface that actionable setup error instead of the
// generic "missing or expired" (which implies a simple retry would
// help), so this is never a silent lockout.
if !twoFADeliveryAvailable() {
http.Error(w, errTwoFADeliveryUnavailable.Error(), http.StatusServiceUnavailable)
return
}
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
}
if err := enableTwoFA(r, userID); err != nil {
log.Printf("failed to enable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeTwoFAEnabled(w)
}
// enableTwoFA persists two_factor_enabled=true, records the last successful
// verification in two_factor_last_used_at, and clears the pending code fields
// (the method was set during setup). The last-used stamp is written here so it
// covers both the enforced verify path and the dev bypass.
func enableTwoFA(r *http.Request, userID string) error {
_, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_enabled = true,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL,
two_factor_last_used_at = NOW()
WHERE id = $1
`, userID)
return err
}
func writeTwoFAEnabled(w http.ResponseWriter) {
if err := json.NewEncoder(w).Encode(map[string]bool{"enabled": true}); err != nil {
log.Printf("failed to encode 2FA verify response: %v", err)
}
}
// twoFAMintCooldown bounds how often a fresh 2FA code may be minted for one
// user during the disable flow. Without it, a password-only attacker could loop
// disable → fresh code (which resets the 5-attempt counter) → 5 wrong guesses →
// fresh code again, for ~100 guesses/min unbounded. The cooldown caps guessing
// at 5 per window (~5/min) while still letting a legitimate code-lost user
// recover after a short wait.
const twoFAMintCooldown = 1 * time.Minute
// errTwoFAMintThrottled is returned by ensurePendingTwoFACode when the user's
// last disable-flow mint is inside twoFAMintCooldown, so the caller returns 429
// instead of minting another fresh code.
var errTwoFAMintThrottled = errors.New("2FA code mint throttled")
type TwoFADisableRequest struct {
Code string `json:"code"`
}
// POST /api/user/2fa/disable/code
// Mints + delivers a fresh disable-flow verification code so the frontend can
// show a code-entry step before it calls POST /api/user/2fa/disable. This is
// the disable-flow equivalent of SetupTwoFAHandler: it guarantees a valid
// (unexpired) pending code exists, delivering a fresh one via the same
// build-dependent delivery channel (see deliverTwoFACode) and persisting only
// its hash + expiry when none does. The actual disable still happens through
// the existing disable endpoint, which validates the entered code under the
// shared 5-attempt lockout — this handler only performs the mint. Fresh-code
// mints are throttled per-user (twoFAMintCooldown), so a password-only attacker
// cannot loop request-code → burn 5 guesses → request-code forever; a throttled
// request returns 429. Like the disable handler, no code is returned in the
// response (delivery is the [2FA] log line in dev/test builds; production
// fails closed when no delivery channel is configured — no email/SMS and no
// explicit TWO_FACTOR_ALLOW_LOG_DELIVERY opt-in), and unlike setup this
// endpoint runs unconditionally — it does not short-circuit on
// !twoFARequired(), so dev environments can exercise the same step (the mint
// is harmless there).
func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// The per-user mutex serializes the mint with the disable handler's
// checkTwoFACode critical section so concurrent requests from the same user
// cannot race the cooldown or lockout counters.
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
if err := ensurePendingTwoFACode(r, userID, st); err != nil {
if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return
}
if errors.Is(err, errTwoFADeliveryUnavailable) {
// Production with no delivery channel: a fresh code cannot be
// minted, so the disable flow (and thus the user's recovery) fails
// loudly with the actionable setup error.
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// POST /api/user/2fa/disable
// Turns 2FA off and clears method + pending fields for the authenticated user.
//
// Disabling 2FA lifts the SCA stand-in gate on saved-card charges, so in
// enforced environments a verification code is required — a password-only
// attacker must not be able to disable the protection. A fresh code is generated
// and delivered via the build-dependent delivery channel (see deliverTwoFACode)
// when no valid pending code exists, and the submitted code is checked under the
// shared 5-attempt lockout (wrong code → 400, lockout → 429); only a correct
// code clears the flag. When no delivery channel is configured (production
// without email/SMS and without the explicit TWO_FACTOR_ALLOW_LOG_DELIVERY
// opt-in), the mint fails loudly with the actionable setup error instead of a
// silent 500. Fresh-code mints are throttled per-user (twoFAMintCooldown) so
// the loop above cannot reset the lockout faster than once per cooldown. In
// unenforced (dev) environments the loose behavior is kept: no code required,
// so local dev is not blocked.
func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Body is optional; decode leniently so an empty body still works in
// unenforced (dev) environments.
var req TwoFADisableRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if !twoFARequired() {
// Dev bypass: no re-verification in unenforced environments.
if err := disableTwoFA(r, userID); err != nil {
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
return
}
// Enforced path. The per-user mutex serializes the whole critical section
// (fresh-code generation + code check) so concurrent requests cannot race
// the lockout counter.
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
// Reuse a valid pending code when one exists; otherwise generate + deliver
// a fresh one via the same build-dependent channel as setup. Minting a fresh
// code does NOT reset the failed-attempt counter (B11b): the counter resets
// only on a successful verify or when the 10-minute attempt window elapses.
// The per-user mint cooldown still bounds how often a fresh code can be
// minted — at most one per twoFAMintCooldown — but it cannot grant a fresh
// guessing budget.
if err := ensurePendingTwoFACode(r, userID, st); err != nil {
if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return
}
if errors.Is(err, errTwoFADeliveryUnavailable) {
// Production with no delivery channel: no fresh code can be minted,
// so the enforced disable re-verification cannot proceed. Surface
// the actionable setup error instead of a silent 500.
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
switch result {
case twoFACodeIncorrect:
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case twoFACodeLockedOut:
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case twoFACodeMissingOrExpired:
// ensurePendingTwoFACode just guaranteed a valid pending code; defensive.
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
}
if err := disableTwoFA(r, userID); err != nil {
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
// code to verify against, generating + delivering a fresh one via the same
// build-dependent delivery channel as setup (see deliverTwoFACode) when the
// stored code is missing or expired. The caller must hold the user's
// attempt-state mutex.
//
// Minting a fresh code does NOT reset the failed-attempt counter (B11b): the
// counter resets only on a successful verify or when the 10-minute attempt
// window elapses (see twoFAResetAttempts — called on successful verify only).
// The per-user mint cooldown still bounds how often a fresh code can be minted
// (at most one per twoFAMintCooldown per user), but it cannot grant a fresh
// guessing budget. A locked-out user therefore stays locked out until the
// attempt window elapses; the documented disable-flow residual is that a user
// who exhausts the budget must wait out the window, not the mint cooldown. A
// failed delivery does not start the cooldown (the stamp is written only after
// the UPDATE persisted).
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState) error {
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return err
}
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return nil
}
now := clock.Now()
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
return errTwoFAMintThrottled
}
if _, err := deliverTwoFACode(r, userID, "", "disable 2FA"); err != nil {
return err
}
st.LastMintAt = now
return nil
}
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
func disableTwoFA(r *http.Request, userID string) error {
_, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_enabled = false,
two_factor_method = NULL,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID)
return err
}
// twoFADeleteAttempts removes a user's attempt-map entry entirely, unlike
// twoFAResetAttempts which only zeroes the count in place. The admin 2FA
// removal flow uses it so any lingering lockout/counter/mint-cooldown state is
// dropped wholesale and a re-setup starts from a clean slate. Delegates to the
// shared implementation.
func twoFADeleteAttempts(userID string) {
twofa.DeleteAttempts(userID)
}
// removeUser2FA clears all 2FA state for a user: the enabled flag, method,
// pending code fields, and the last-used timestamp. It reports whether a user
// row was actually affected (false means the user does not exist).
func removeUser2FA(r *http.Request, userID string) (bool, error) {
tag, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_enabled = false,
two_factor_method = NULL,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL,
two_factor_last_used_at = NULL
WHERE id = $1
`, userID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// POST /api/admin/users/{id}/2fa/remove
// Admin-only recovery route for when a user loses access to their 2FA device:
// forcibly disables the user's 2FA without requiring their code (bypassing the
// user-facing disable flow's re-verification). The route is mounted inside the
// admin-gated /admin/users group so RequireAdmin runs first. Mirrors
// disableTwoFA's column clearing plus two_factor_last_used_at, and drops the
// user's in-memory attempt/lockout state. A log line records the admin action.
func AdminRemoveUser2FAHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
affected, err := removeUser2FA(r, userID)
if err != nil {
log.Printf("failed to remove 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if !affected {
http.Error(w, "user not found", http.StatusNotFound)
return
}
twoFADeleteAttempts(userID)
log.Printf("[2FA] admin removed 2FA for user %s", userID)
w.WriteHeader(http.StatusOK)
}