Files
Crussell/backend/handlers/user/twofa.go
T
popertots 9bb812669e fix: fresh-review round — 2FA deliverability, disable re-verification, GDPR batch scrub, dispute alerting, docs accuracy
Second fresh-eyes review pass (7 agents: goal, security, code-quality,
context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers).
Money-safety core verified sound (identical-body replay byte-lossless, clawback
gated on definitive proof, no double-charge window). This round fixes the
issues the fresh pass surfaced:

2FA:
- Setup now DELIVERS the code via the [2FA] server log in ALL modes (was:
  nothing in enforced mode -> production 2FA was an unbreakable dead-end and
  saved-card charges were permanently 403). Enforced mode still withholds the
  code from the API response; the log line is the fake delivery channel until
  email/SMS lands (P6).
- Disabling 2FA now requires a fresh verification code when enforcement is ON
  (previously ignored the code -> a password-only attacker could lift the gate).
  Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained.
- REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive);
  startup warning extended to the empty-env/mock-client/enforced-2FA confusion.

GDPR:
- anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the
  idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just
  the user-initiated delete path.

Webhooks:
- dispute.created for an untracked Square payment now raises a
  critical_payment_log admin notification (chargeback the app can't reconcile
  is never silent). Reason strings truncated on rune boundaries (valid UTF-8).
  Stale at-most-once comment corrected; revertTillSaleGiftCardFunding
  duplication noted.

Sweep/mock parity:
- Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on
  source mismatch) matching ReplayPaymentByKey and real Square.
- COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by
  the sweep (previously only booking checkouts were; till charges were
  invisible until the 24h blind-fail WARN).
- Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and
  in-memory-mock-restart limitations documented.

Docs:
- Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square
  - a deployer following the old path would 404 and silently lose all webhook
  reconciliation).
- 2FA enforcement semantics + code-delivery mechanism documented accurately
  (fail-closed default; log-delivery channel; disable re-verification).
- README/User Manual note the 2FA requirement on online saved-card payments.

Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails
only in this environment: local postgres doesn't offer scram-sha-256 for the
test role; package is byte-identical to HEAD and untouched here). Frontend
builds; svelte-check 0 errors.
2026-08-22 00:34:49 +01:00

543 lines
18 KiB
Go

package user
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"sync"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/mw"
)
// 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
}
// hashTwoFACode returns the SHA-256 hex digest of a verification code. The DB
// stores only the digest; the plaintext code is delivered by logging it with a
// [2FA] prefix (see deliverTwoFACode). The digest is unsalted SHA-256 —
// peppering it via HMAC-SHA256 with a server-side 2FA_PEPPER secret is a future
// hardening step once such a secret is provisioned.
func hashTwoFACode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// 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 = 5
// 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 = 10 * time.Minute
// twoFAMaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
// user IDs cannot grow it without bound. Counters are purely in-memory (the DB
// schema is locked — there is no attempt column), so they reset on process
// restart; the 10-minute pending-code expiry bounds the practical impact.
const twoFAMaxTrackedAttempts = 10_000
// twoFAAttemptState tracks consecutive failed verify attempts for one user. The
// per-user mutex serializes the whole verify critical section so concurrent
// attempts from the same user cannot race the limit check.
type twoFAAttemptState struct {
mu sync.Mutex
count int
lastAt time.Time
}
var (
twoFAAttemptMapMu sync.Mutex
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
)
// twoFAAttemptStateFor returns the per-user attempt state, creating it if
// needed. The map is bounded: stale entries are evicted opportunistically and,
// when at capacity, the least-recently-active entry is dropped.
func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
twoFAAttemptMapMu.Lock()
defer twoFAAttemptMapMu.Unlock()
now := clock.Now()
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts {
var oldestID string
var oldestAt time.Time
for id, st := range twoFAAttemptMap {
if now.Sub(st.lastAt) > twoFAAttemptWindow {
delete(twoFAAttemptMap, id)
continue
}
if oldestID == "" || st.lastAt.Before(oldestAt) {
oldestID, oldestAt = id, st.lastAt
}
}
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" {
delete(twoFAAttemptMap, oldestID)
}
}
st := twoFAAttemptMap[userID]
if st == nil {
st = &twoFAAttemptState{lastAt: now}
twoFAAttemptMap[userID] = st
}
return st
}
// twoFAResetAttempts clears a user's attempt counter. Called on successful
// verify and when a fresh code is generated via setup.
func twoFAResetAttempts(userID string) {
twoFAAttemptMapMu.Lock()
delete(twoFAAttemptMap, userID)
twoFAAttemptMapMu.Unlock()
}
// deliverTwoFACode generates a fresh verification code, persists only its
// SHA-256 hash plus the pending expiry (updating two_factor_method when method
// is non-empty), resets any prior lockout, and logs the plaintext code.
//
// The [2FA] log line is the delivery channel — the loose-fake stand-in for the
// not-yet-wired email/SMS transport (P6). The plaintext code is ALWAYS logged,
// enforced and unenforced alike: in enforced (production) environments the
// server log is the only way a code can reach the user, so an operator must
// relay it out-of-band. Do not gate this log line on the environment — without
// it, enforced-mode 2FA has no delivery path at all and every online saved-card
// charge stays 403. The API response still only returns the code when 2FA is
// unenforced (dev convenience). purpose labels the log line (e.g. "setup",
// "disable 2FA").
func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, error) {
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
}
// A fresh code invalidates any prior lockout state.
twoFAResetAttempts(userID)
label := method
if label == "" {
label = purpose
}
log.Printf("[2FA] verification code for user %s (%s): %s", 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. The code is delivered by logging it
// with a [2FA] prefix — the loose-fake stand-in for the not-yet-wired email/SMS
// transport (P6). The plaintext code is ALWAYS logged, enforced and unenforced
// alike: in enforced (production) environments the server log is the only
// delivery channel, so an operator must relay the code to the user out-of-band.
// 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
}
// Deliver a fresh code via the shared setup mechanism: generate, persist
// only the hash + expiry, reset any prior lockout, and log the plaintext
// code (the [2FA] log channel — see deliverTwoFACode).
code, err := deliverTwoFACode(r, userID, req.Method, "setup")
if err != nil {
log.Printf("failed to store 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
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.
type twoFACodeCheckResult int
const (
twoFACodeOK twoFACodeCheckResult = iota
twoFACodeIncorrect
twoFACodeLockedOut
twoFACodeMissingOrExpired
)
// checkTwoFACode verifies the submitted code against the user's stored pending
// code under the per-user brute-force lockout, shared by VerifyTwoFAHandler and
// DisableTwoFAHandler. The caller must hold st.mu (from twoFAAttemptStateFor)
// so concurrent attempts from the same user cannot race the limit check. A
// correct code resets the attempt counter and returns twoFACodeOK. An incorrect
// code increments the counter and, on the 5th consecutive failure, invalidates
// the pending code (lockout). A missing or expired pending code returns
// twoFACodeMissingOrExpired. The returned error is non-nil only for DB failures
// (callers return 500); a lockout's pending-code invalidation failure is logged
// here and still reported as a lockout.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow {
st.count = 0
st.lastAt = now
}
if st.count >= twoFAMaxAttempts {
return twoFACodeLockedOut, nil
}
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 twoFACodeLockedOut, err
}
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
return twoFACodeMissingOrExpired, nil
}
// Constant-time compare (subtle) so a wrong code's match position cannot be
// inferred from response timing. Both digests are fixed-length hex.
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(reqCode)), []byte(pendingHash.String)) != 1 {
st.count++
st.lastAt = clock.Now()
if st.count >= twoFAMaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
}
return twoFACodeLockedOut, nil
}
return twoFACodeIncorrect, nil
}
// Success: clear the attempt counter before the caller performs its action.
st.count = 0
st.lastAt = clock.Now()
twoFAResetAttempts(userID)
return twoFACodeOK, 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:
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 and clears the pending code
// fields (the method was set during setup).
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
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)
}
}
type TwoFADisableRequest struct {
Code string `json:"code"`
}
// 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 [2FA] log channel 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. 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 [2FA] log channel as setup.
if err := ensurePendingTwoFACode(r, userID); err != nil {
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 [2FA]
// log channel as setup when the stored code is missing or expired. A fresh code
// also resets any prior lockout, matching setup's recovery behavior. The caller
// must hold the user's attempt-state mutex.
func ensurePendingTwoFACode(r *http.Request, userID string) 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
}
_, err = deliverTwoFACode(r, userID, "", "disable 2FA")
return err
}
// 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
}