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" "github.com/jackc/pgx/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 // keep their original names. // 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) } // 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) } // twoFAMintThrottled reports whether a fresh 2FA code mint for the user is // still inside the per-user cooldown window (twoFAMintCooldown): a previous // mint within the window throttles the request (429) instead of minting // another code. Shared by SetupTwoFAHandler and EnsurePendingTwoFACode so the // cooldown rule cannot drift between the setup and disable-flow call paths. func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool { return !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown } // 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 shared twoFAMintThrottled helper bounds setup // re-mints — the same guard the disable flow applies via // EnsurePendingTwoFACode. 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 twoFAMintThrottled(st, now) { 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.SetLastMintAtLocked(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 with consume=false: the interactive setup/disable // flows clear the pending code themselves on success (enableTwoFA / // disableTwoFA), so the code must stay valid through the whole handshake here. func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) { res, err := twofa.Check(r.Context(), userID, st, reqCode, false) 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, consume) from // crussell/internal/twofa (the shared home of this verification core) instead // of importing this package. Since MEDIUM-2 the payments saved-card CHARGE // gates pass consume=false and NULL the code at the charge's terminal success // via twofa.ConsumePendingCode; the save-card SAVE gates pass consume=true. 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, false) 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, "disable 2FA"); 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/code // Lets an ENABLED user request a fresh verification code for a saved-card // charge (the B6/B10 gate). This closes the enforced-deployment dead-end where // 2FA setup clears the pending code and SetupTwoFAHandler refuses already // enabled users (409): without it there is no way to mint a code for a // saved-card charge, so every charge returned 400 "Verification code expired — // request a new one" with no way to get a new one. // // The mint machinery is shared with the disable flow: EnsurePendingTwoFACode // reuses a still-valid pending code when one exists and otherwise mints + // delivers a fresh one via the same build-dependent channel as setup // (deliverTwoFACode — [2FA] log in dev/test; pepper- and delivery-channel // gated in production). Fresh-code mints are throttled per-user // (twoFAMintCooldown) and never reset the failed-attempt counter (B11b). // // Contract: 200 {"message":"Code sent","remaining_seconds":N} (+ a dev-only // "code" field when 2FA is unenforced, matching setup) where N is how many // seconds the effective pending code stays valid — the FULL twoFAPendingExpiry // after a fresh mint, or the decremented lifetime when an existing valid code // was reused (LOW 5). A 200 with a reused code must NOT be read as "a new code // was sent": the frontend should use the already-delivered code and show the // countdown. 409 when the user has not enabled 2FA; 429 on the mint cooldown; // 503 when no delivery channel is configured (production without // TWO_FACTOR_ALLOW_LOG_DELIVERY=true); 500 on DB failure. The route is // mounted with RequireAuth + RequireNonGuest + the shared per-user 2FA limiter // (plus the group's per-IP limiter), so an enabled user cannot hammer code // requests faster than the surface budget. func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) 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 not enabled", http.StatusConflict) return } // The per-user mutex serializes the mint with the charge gate's verify // 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() code, remaining, err := EnsurePendingTwoFACode(r, userID, st, "saved-card charge") if 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 saved-card charge cannot be re-challenged. 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 saved-card charge for user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return } // remaining_seconds tells the client how much longer the (possibly reused) // pending code stays valid, so a button that would otherwise toast "Code // sent" while no NEW code was minted can instead show a countdown / keep // the existing code (LOW 5). resp := map[string]any{"message": "Code sent", "remaining_seconds": int(remaining.Seconds())} if !twoFARequired() && code != "" { // Dev convenience (matches setup): return the freshly minted code so // the request path is testable without grepping the backend log. The // code is never included when 2FA is enforced. resp["code"] = code } if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("failed to encode 2FA code response: %v", err) } } // AdminSendVerificationCodeHandler mints (or reuses) a 2FA code for a TARGET // user, not the session user. The saved-card charge gate verifies the code // against the CARD OWNER (customer) — never the admin session (till.go:951, // handlers.go:797) — so a session-scoped mint would key the code to the admin // and could never authorize the customer's charge. Delivering keyed to the // customer preserves the invariant that the customer, not the admin, is the // authentication subject for their card. // // Finding 2: every successful mint-or-reuse writes an admin_audit_log row // (action_type '2fa_code_mint', details carrying reused vs fresh + the // remaining code lifetime), so admin-scoped code issuance is never silent. func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) { targetUserID := chi.URLParam(r, "id") if targetUserID == "" { http.Error(w, "user_id is required", http.StatusBadRequest) return } var enabled bool err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, targetUserID).Scan(&enabled) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "User not found", http.StatusNotFound) return } log.Printf("failed to check 2FA state for user %s: %v", targetUserID, err) http.Error(w, "server error", http.StatusInternalServerError) return } if !enabled { http.Error(w, "Two-factor authentication is not enabled for this user", http.StatusConflict) return } // The per-user mutex serializes the mint with the charge gate's verify // critical section for the CUSTOMER, so concurrent mints (admin + customer // requesting simultaneously) cannot race the cooldown or lockout counters. st := twoFAAttemptStateFor(targetUserID) st.Mu.Lock() defer st.Mu.Unlock() code, remaining, err := EnsurePendingTwoFACode(r, targetUserID, st, "saved-card charge") if 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) { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } log.Printf("failed to prepare 2FA code for user %s: %v", targetUserID, err) http.Error(w, "server error", http.StatusInternalServerError) return } // Finding 2a: the admin who minted/reused the code is audited. The empty // `code` return discriminates reuse from a fresh mint — the plaintext is // only returned for a fresh delivery, never on reuse (EnsurePendingTwoFACode). if adminID, ok := mw.GetUserID(r.Context()); ok { payments.InsertAdminAuditCharge(r.Context(), adminID, targetUserID, "2fa_code_mint", map[string]any{ "reused": code == "", "remaining_seconds": int(remaining.Seconds()), }) } resp := map[string]any{"message": "Code sent", "remaining_seconds": int(remaining.Seconds())} if !twoFARequired() && code != "" { // Dev convenience (matches setup): return the freshly minted code so // the request path is testable without grepping the backend log. The // code is never included when 2FA is enforced. resp["code"] = code } if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("failed to encode 2FA code response: %v", err) } } // 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, "disable 2FA"); 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. purpose labels the delivery for the [2FA] // log line (e.g. "disable 2FA", "saved-card charge"). The caller must hold the // user's attempt-state mutex. // // Exported so the finding-1 re-mint contract is referenceable: a saved-card // charge gate that burns a code at verify time (twofa.ConsumeOnVerify) re-mints // on a failed charge through this function. handlers/payments cannot import // this package (import cycle), so payments reaches it via the HTTP mint // endpoints (POST /user/2fa/code, POST /admin/users/{id}/2fa/code) that // delegate to it. // // It returns the plaintext code only when a FRESH code was minted and // delivered (dev/test builds always deliver it; production builds only when // the operator opted into log delivery — see twofa_prod.go). When a valid // pending code was reused, the returned code is empty: only the digest is // stored, so the plaintext is unavailable. Callers must only expose the // returned code in unenforced environments (matching SetupTwoFAHandler's dev // convenience). The remaining lifetime of the effective pending code (the // reused one, or the fresh mint's full twoFAPendingExpiry) is always returned // so a caller can surface "code still valid for N seconds" instead of implying // a fresh code was sent (LOW 5). // // 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, purpose string) (string, time.Duration, 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 "", 0, err } if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) { // LOW 5 / finding 2: a reused code is NOT re-delivered (the plaintext is // unavailable — only the digest is stored), but it must still leave a // trace so silent code-reuse is audit-visible in the [2FA] log stream. log.Printf("[2FA] code reused (user=%s, purpose=%s, remaining=%s) — no fresh code minted or delivered", userID, purpose, pendingExpires.Time.Sub(clock.Now()).Round(time.Second)) return "", pendingExpires.Time.Sub(clock.Now()), nil } now := clock.Now() if twoFAMintThrottled(st, now) { return "", 0, errTwoFAMintThrottled } code, err := deliverTwoFACode(r, userID, "", purpose) if err != nil { return "", 0, err } st.SetLastMintAtLocked(now) return code, twoFAPendingExpiry, 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) }