Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: MONEY: - HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges - HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining - MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row - MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking) - MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal - MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited - MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency SECURITY: - HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts - MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records - MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded - MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep) - MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert - LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed DUP/MOD: - Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
915 lines
38 KiB
Go
915 lines
38 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"
|
|
"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)
|
|
}
|