Security (P0): - IsJTIRevoked fails closed on DB error (previously accepted revoked tokens) - Remove dead consume parameter from SCA gate (prevented token replay) - Rate limiter map TTL-based eviction (prevented memory exhaustion) - 2FA attempt map already had LRU eviction (verified) Money Safety (P1): - Gift card transfer refuses expired destination cards - Gift card balance deduction has WHERE balance >= amount guard - Webhook clawback acquires till-sale advisory lock - Sweep/retry lock keys aligned Privacy/Cookies (P2): - Self-host Google Fonts (Playfair Display woff2) - Replace CARTO map tiles with OpenStreetMap raster tiles - Replace Wikimedia/icon-icons external images with local SVGs - Remove external image URLs from CSP Legal (P3): - Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies) - Terms: add Tips section (optionality, non-refundable, same processing as bookings) Code Quality (P4): - twofa.Check accepts db.Querier for testability - depositPromotionMinPct uses literal 0.20 (not misleading alias) - HolidayHours.svelte uses proper type (not as any[]) - Remove stale TODO comments from main.go Testing (P5): - 94 new float64 money validity tests across 3 test files - Cover VAT, splits, refunds, gift cards, rounding, precision boundaries - All 27 backend test packages pass
609 lines
28 KiB
Go
609 lines
28 KiB
Go
// Package twofa owns the shared 2FA verification-code machinery: the
|
|
// per-user brute-force attempt map, the constant-time code check, and the
|
|
// exported verification entry point.
|
|
//
|
|
// Why this package exists (B11c coordination contract): handlers/user imports
|
|
// handlers/payments (TwoFactorEnforced, SquareClient), so handlers/payments
|
|
// CANNOT import handlers/user — Go would reject the cycle. The verification
|
|
// core therefore lives here, importing neither, so both sides of the import
|
|
// boundary can reach it.
|
|
//
|
|
// The verification consumers are the INTERACTIVE ACCOUNT FLOWS ONLY. The
|
|
// saved-card payments gates are now exclusively PSD2 SCA (Square buyer
|
|
// verification) and no longer call Check/VerifyForUser — the "charge gate"
|
|
// contract described in earlier revisions is obsolete. Today the callers are:
|
|
//
|
|
// - handlers/user: 2FA setup verify (VerifyTwoFAHandler) and 2FA disable
|
|
// re-verification (DisableTwoFAHandler), both via checkTwoFACode with
|
|
// DeferredConsume (they clear the pending fields themselves on success);
|
|
// - handlers/user/account.go: delete-account re-authentication
|
|
// (DeleteAccountHandler) via twofa.VerifyForUser with ConsumeOnVerify, so
|
|
// one code authorizes exactly one account erasure.
|
|
//
|
|
// The payments package still touches this package on the TERMINAL-SUCCESS path
|
|
// only: ConsumePendingCode (after an SCA-approved saved-card charge or gift
|
|
// card issuance, where the pending code left over from the interactive mint
|
|
// must be retired) and StateFor/Hash for its code re-issue bookkeeping.
|
|
//
|
|
// Consume mode: a successful verify with consume=true NULLs the pending code
|
|
// ATOMICALLY in the same critical section as the check, so one code authorizes
|
|
// exactly ONE operation — two concurrent consumers can never both pass the gate
|
|
// with the same code (the per-user mutex serializes Check, and the second
|
|
// verify reads a NULLed digest and returns ErrMissingOrExpired). DeleteAccount
|
|
// is the only current ConsumeOnVerify caller.
|
|
//
|
|
// The failed-attempt counter is keyed per user and resets ONLY on a successful
|
|
// verify (or after the 10-minute attempt window elapses) — never on a fresh
|
|
// code mint, so minting a new code cannot grant a fresh guessing budget (B11b).
|
|
package twofa
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
)
|
|
|
|
// MaxAttempts is the number of consecutive failed verify attempts allowed
|
|
// before the pending code is invalidated and a new one must be requested.
|
|
const MaxAttempts = 5
|
|
|
|
// Consume mode for VerifyForUser / Check. Named so the magic bool cannot drift
|
|
// between call sites (the payments gate vs the interactive flows).
|
|
const (
|
|
// ConsumeOnVerify makes a successful verify SINGLE-USE immediately: the
|
|
// pending-code digest and expiry are NULLed in the same critical section as
|
|
// the successful check (see Check). Use this for a TERMINAL operation that
|
|
// must be authorized by exactly one code — today that is the delete-account
|
|
// re-authentication flow (DeleteAccountHandler); the saved-card charge and
|
|
// SAVE gates no longer call Check (SCA-only since the PSD2 rework).
|
|
ConsumeOnVerify = true
|
|
// DeferredConsume verifies WITHOUT consuming; the caller NULLs the code
|
|
// itself when its operation reaches terminal success (ConsumePendingCode) or
|
|
// clears the pending fields on success (the interactive enable/disable
|
|
// flows).
|
|
DeferredConsume = false
|
|
)
|
|
|
|
// AttemptWindow bounds how long a per-user attempt counter lives before
|
|
// resetting, and doubles as the stale-entry eviction horizon for the map.
|
|
const AttemptWindow = 10 * time.Minute
|
|
|
|
// MaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
|
|
// user IDs cannot grow it without bound.
|
|
//
|
|
// ACCEPTED LIMITATION (LOW 6 — documentation only, no behavior change): every
|
|
// 2FA counter here — the per-user failed-attempt count, the lockout window, and
|
|
// the mint-cooldown stamp (AttemptState.LastMintAt, used by twoFAMintCooldown
|
|
// in handlers/user) — is purely in-memory and resets on process restart. The
|
|
// DB schema is locked (there is no attempt column), and the practical impact
|
|
// is bounded by the 10-minute pending-code expiry (AttemptWindow /
|
|
// twoFAPendingExpiry): at most one fresh 5-guess budget per 10-minute window.
|
|
// A MULTI-INSTANCE deployment would need a shared store (e.g. a DB column or
|
|
// Redis) for these counters, because today each instance keeps its own map —
|
|
// an attacker could distribute guesses across instances. Single-instance
|
|
// deployments (this app) are unaffected.
|
|
//
|
|
// Declared as a var so the eviction policy is unit-testable at a small cap.
|
|
var MaxTrackedAttempts = 10_000
|
|
|
|
// AttemptState 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. Count and LastAt are
|
|
// atomic so the map eviction path can read them without taking the per-user
|
|
// mutex (lock ordering forbids MapMu→st.Mu: Check holds st.Mu then takes
|
|
// MapMu). LastAt is stored as nanoseconds since the Unix epoch so the eviction
|
|
// scan and LockedOut read it race-free even on 32-bit platforms — a plain
|
|
// time.Time read/write pair there could tear the 8-byte timestamp and reset or
|
|
// extend the lockout window.
|
|
// LastMintAt is the mint-cooldown stamp (see twoFAMintCooldown in the user
|
|
// package); it is only ever touched under Mu.
|
|
type AttemptState struct {
|
|
Mu sync.Mutex
|
|
Count atomic.Int32
|
|
LastAt atomic.Int64
|
|
LastMintAt time.Time
|
|
}
|
|
|
|
// LastActive returns the state's last-activity timestamp (nanoseconds since
|
|
// the Unix epoch, UTC). Reads are atomic so the map eviction scan can call it
|
|
// while holding only MapMu.
|
|
func (st *AttemptState) LastActive() time.Time {
|
|
return time.Unix(0, st.LastAt.Load()).UTC()
|
|
}
|
|
|
|
// SetLastActive records a last-activity timestamp. Writes happen under Mu
|
|
// (Check) while the eviction scan reads under MapMu only — the atomic store
|
|
// makes both race-free.
|
|
func (st *AttemptState) SetLastActive(t time.Time) {
|
|
st.LastAt.Store(t.UnixNano())
|
|
}
|
|
|
|
// SetLastMintAtLocked records the per-user mint-cooldown stamp. The caller
|
|
// MUST already hold st.Mu (matching how the stamp is read by
|
|
// twoFAMintThrottled in handlers/user and the payments re-issue cooldown).
|
|
//
|
|
// Round 2 Loop B finding 3a: writing to the SHARED saturated state is a
|
|
// NO-OP. saturatedLockedState is returned by StateFor for EVERY untracked user
|
|
// once the map is at capacity, so a mint stamped on it would throttle every
|
|
// untracked user for the whole cooldown (one user's mint blocks everyone for
|
|
// 60s) and ClearMintCooldownForUser would clear it for all of them. The
|
|
// singleton's stamp therefore stays permanently zeroed: under saturation,
|
|
// per-user mints are unthrottled, which is SAFE because a mint never grants a
|
|
// fresh guessing budget (B11b) and the saturated state is already permanently
|
|
// locked out for verification. The stamp must also never be written by the
|
|
// reissue/mint paths when st IS the singleton — the no-op below guarantees it.
|
|
func (st *AttemptState) SetLastMintAtLocked(t time.Time) {
|
|
if st == saturatedLockedState {
|
|
return
|
|
}
|
|
st.LastMintAt = t
|
|
}
|
|
|
|
// LockedOut reports whether the state is inside its lockout window: the attempt
|
|
// counter has reached the cap and the window has not yet elapsed. Such a record
|
|
// is the rate limit's source of truth for its user and must never be evicted
|
|
// while in-window — evicting it would silently reset the counter and grant a
|
|
// fresh guessing budget.
|
|
func (st *AttemptState) LockedOut(now time.Time) bool {
|
|
return st.Count.Load() >= MaxAttempts && now.Sub(st.LastActive()) <= AttemptWindow
|
|
}
|
|
|
|
var (
|
|
MapMu sync.Mutex
|
|
Map = make(map[string]*AttemptState)
|
|
)
|
|
|
|
// saturatedLockedState is the SHARED attempt state returned by StateFor when
|
|
// the attempt map is at capacity and every tracked record is inside its
|
|
// lockout window (finding 3, Round 2 Loop A — see StateFor). Its last-activity
|
|
// stamp is pinned FAR in the future, so LockedOut always holds and Check's
|
|
// window-reset branch (now.Sub(LastActive) > AttemptWindow) can never reach it:
|
|
// every untracked user is treated as PERMANENTLY locked out instead of being
|
|
// granted a fresh 5-guess budget per request. It is a package-level singleton
|
|
// rather than a per-call allocation so the pathological path allocates nothing
|
|
// and all saturated requests share one record.
|
|
var saturatedLockedState = newSaturatedLockedState()
|
|
|
|
func newSaturatedLockedState() *AttemptState {
|
|
st := &AttemptState{}
|
|
st.Count.Store(MaxAttempts)
|
|
// Pinned so far in the future that now.Sub(LastActive) is always
|
|
// <= AttemptWindow (LockedOut true) and never > AttemptWindow (no reset).
|
|
// clock.Now(), not time.Now(): the rest of the package reads time through
|
|
// crussell/clock (UTC-normalised, test-controllable) so the pinned stamp
|
|
// must be expressed in the same clock or a frozen test clock would leave
|
|
// now.Sub(LastActive) inconsistent with the saturation invariant.
|
|
st.SetLastActive(clock.Now().Add(24 * 365 * 24 * time.Hour))
|
|
return st
|
|
}
|
|
|
|
// StateFor returns the per-user attempt state, creating it if needed. The map
|
|
// is bounded: stale (window-expired) entries are evicted opportunistically and,
|
|
// when at capacity, the least-recently-active non-locked-out entry is dropped.
|
|
// A record still inside its lockout window is NEVER evicted — evicting it would
|
|
// reset the victim's attempt counter and bypass the rate limit under a hostile
|
|
// flood of new keys. When the map is full of in-window locked-out records (a
|
|
// pathological flood), a transient, untracked state is returned instead of
|
|
// growing the map past the cap.
|
|
func StateFor(userID string) *AttemptState {
|
|
MapMu.Lock()
|
|
defer MapMu.Unlock()
|
|
now := clock.Now()
|
|
|
|
if len(Map) >= MaxTrackedAttempts {
|
|
var oldestID string
|
|
var oldestAt time.Time
|
|
for id, st := range Map {
|
|
if now.Sub(st.LastActive()) > AttemptWindow {
|
|
// Idle/expired — its counter has already lapsed; safe to evict.
|
|
delete(Map, id)
|
|
continue
|
|
}
|
|
if st.LockedOut(now) || st.Count.Load() > 0 {
|
|
// Round 2 Loop B finding 3b: an in-window record with a
|
|
// NON-ZERO attempt counter is the rate limit's IN-PROGRESS
|
|
// state for a genuine user (a locked-out record, or a user
|
|
// mid-window with failed attempts banked). Evicting it would
|
|
// silently reset the counter and grant a fresh guessing
|
|
// budget. Only count==0 in-window records (fresh lookups /
|
|
// idle mint-cooldown stamps) are evictable.
|
|
continue
|
|
}
|
|
if at := st.LastActive(); oldestID == "" || at.Before(oldestAt) {
|
|
oldestID, oldestAt = id, at
|
|
}
|
|
}
|
|
if len(Map) >= MaxTrackedAttempts && oldestID != "" {
|
|
delete(Map, oldestID)
|
|
}
|
|
if len(Map) >= MaxTrackedAttempts {
|
|
// Every entry is a protected in-window record (locked out, or
|
|
// carrying an in-progress counter). Do not evict one (that would
|
|
// reset its rate limit) and do not grow past the cap: return the
|
|
// SHARED permanently-locked state (finding 3, Round 2 Loop A).
|
|
// Previously a fresh transient state was returned per call, so
|
|
// every untracked user received a fresh 5-guess budget per
|
|
// request — silently disabling the brute-force lockout exactly
|
|
// under the hostile flood that saturated the map. The shared state
|
|
// treats every untracked user as locked out instead. It is never
|
|
// stored in Map (so the eviction scan / ResetAttempts /
|
|
// DeleteAttempts never touch it) and self-heals: as soon as one of
|
|
// the real locked-out records lapses out of its window, StateFor
|
|
// evicts it and normal per-user tracking resumes.
|
|
//
|
|
// Round 2 Loop B finding 3c — ACCEPTED RESIDUAL: an attacker can
|
|
// still fill the map with up to MaxTrackedAttempts (~10k)
|
|
// in-window locked-out records, forcing every other user into the
|
|
// shared saturated state. That is a bounded, FAIL-CLOSED outcome:
|
|
// the fallback is a locked-out-everyone state (brute force
|
|
// impossible, availability reduced), never a locked-out-nobody one.
|
|
// Mint cooldowns are unthrottled under saturation (the shared
|
|
// stamp is zeroed — see SetLastMintAtLocked), which is safe
|
|
// because a mint grants no guessing budget (B11b). The map drains
|
|
// as locked-out windows lapse.
|
|
return saturatedLockedState
|
|
}
|
|
}
|
|
|
|
st := Map[userID]
|
|
if st == nil {
|
|
st = &AttemptState{}
|
|
st.SetLastActive(now)
|
|
Map[userID] = st
|
|
}
|
|
return st
|
|
}
|
|
|
|
// ResetAttempts resets a user's attempt counter in place (count only) WITHOUT
|
|
// deleting the entry, preserving LastMintAt so the mint cooldown survives a
|
|
// fresh-code delivery. Called on successful verify only — a fresh code mint
|
|
// MUST NOT reset the counter, or a password-only attacker could loop
|
|
// mint → burn 5 guesses → mint forever (B11b). LastAt is deliberately not
|
|
// touched here: it is re-stamped by Check on real activity, and writing it
|
|
// under MapMu would race with Check's Mu-guarded write. Lock ordering is
|
|
// Mu→MapMu at call sites, never the reverse (StateFor takes MapMu only and
|
|
// never takes Mu).
|
|
func ResetAttempts(userID string) {
|
|
MapMu.Lock()
|
|
defer MapMu.Unlock()
|
|
if st := Map[userID]; st != nil {
|
|
st.Count.Store(0)
|
|
}
|
|
}
|
|
|
|
// DeleteAttempts removes a user's attempt-map entry entirely, unlike
|
|
// ResetAttempts 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.
|
|
func DeleteAttempts(userID string) {
|
|
MapMu.Lock()
|
|
defer MapMu.Unlock()
|
|
delete(Map, userID)
|
|
}
|
|
|
|
// PepperProvider supplies the server-side HMAC pepper (TWO_FACTOR_PEPPER). The
|
|
// build-dependent behavior — dev/test fallback to the legacy unsalted digest
|
|
// with a one-time warning vs production fail-closed — is registered by the
|
|
// handlers/user build-tagged files via SetPepperProvider.
|
|
var PepperProvider = func() string { return "" }
|
|
|
|
// SetPepperProvider registers the build-specific pepper reader.
|
|
func SetPepperProvider(f func() string) { PepperProvider = f }
|
|
|
|
// Hash returns the hex digest of a verification code as stored in the DB. With
|
|
// TWO_FACTOR_PEPPER set the digest is HMAC-SHA256 keyed by the pepper, so a
|
|
// leaked digest cannot be brute-forced offline (the key stays server-side).
|
|
// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest:
|
|
// dev/test builds also log a one-time warning, while production builds can
|
|
// never persist such a digest because issuance fails closed without the pepper
|
|
// — the fallback survives only for the legacy-row migration window and the
|
|
// dev/test loose-fake flow. The plaintext code is never stored.
|
|
func Hash(code string) string {
|
|
if p := PepperProvider(); p != "" {
|
|
mac := hmac.New(sha256.New, []byte(p))
|
|
mac.Write([]byte(code))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// LegacyHash returns the pre-pepper plain SHA-256 digest, used to verify rows
|
|
// written before TWO_FACTOR_PEPPER was provisioned during the migration window
|
|
// (see VerifyHash).
|
|
func LegacyHash(code string) string {
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// VerifyHash reports whether reqCode matches a stored pending-code digest,
|
|
// always in constant time (subtle.ConstantTimeCompare). The first comparison
|
|
// uses the current pepper'd digest; when that fails the stored hash may be a
|
|
// legacy pre-pepper plain SHA-256 (rows written before TWO_FACTOR_PEPPER was
|
|
// provisioned), so the legacy digest is tried too. When a legacy row matches,
|
|
// legacy is true and the caller should re-hash with the pepper on the next
|
|
// successful verify, retiring the plain digest.
|
|
func VerifyHash(reqCode, storedHash string) (match, legacy bool) {
|
|
if subtle.ConstantTimeCompare([]byte(Hash(reqCode)), []byte(storedHash)) == 1 {
|
|
return true, false
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(LegacyHash(reqCode)), []byte(storedHash)) == 1 {
|
|
return true, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// Result classifies Check's outcome so callers can map it to the correct HTTP
|
|
// status (or error, in VerifyForUser's case).
|
|
type Result int
|
|
|
|
const (
|
|
OK Result = iota
|
|
Incorrect
|
|
LockedOut
|
|
MissingOrExpired
|
|
)
|
|
|
|
// Check verifies the submitted code against the user's stored pending code
|
|
// under the per-user brute-force lockout. The caller must hold st.Mu (from
|
|
// StateFor) so concurrent attempts from the same user cannot race the limit
|
|
// check. A correct code resets the attempt counter and returns OK. An incorrect
|
|
// code increments the counter and, on the 5th consecutive failure, invalidates
|
|
// the pending code (lockout). A missing or expired pending code returns
|
|
// MissingOrExpired. consume makes a correct code single-use IMMEDIATELY: the
|
|
// stored digest and its expiry are NULLed right here, so one code cannot
|
|
// authorize a second operation within its lifetime. The interactive account
|
|
// flows are the only consumers: the 2FA setup/disable handshakes pass
|
|
// DeferredConsume (false) and clear the pending fields themselves on success,
|
|
// while delete-account re-authentication passes ConsumeOnVerify (true) so one
|
|
// code authorizes exactly one erasure. The payments gates are SCA-only and no
|
|
// longer call Check. 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 Check(ctx context.Context, q db.Querier, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
|
|
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
|
|
st.Count.Store(0)
|
|
st.SetLastActive(now)
|
|
}
|
|
if st.Count.Load() >= MaxAttempts {
|
|
return LockedOut, nil
|
|
}
|
|
|
|
var pendingHash sql.NullString
|
|
var pendingExpires sql.NullTime
|
|
err := q.QueryRow(ctx, `
|
|
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
|
FROM users
|
|
WHERE id = $1
|
|
`, userID).Scan(&pendingHash, &pendingExpires)
|
|
if err != nil {
|
|
return LockedOut, err
|
|
}
|
|
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
|
|
return MissingOrExpired, nil
|
|
}
|
|
// Constant-time compare (subtle) so a wrong code's match position cannot be
|
|
// inferred from response timing. Both digests are fixed-length hex. Legacy
|
|
// pre-pepper rows (plain SHA-256, hashed before TWO_FACTOR_PEPPER existed)
|
|
// still verify during the transition window.
|
|
match, legacy := VerifyHash(reqCode, pendingHash.String)
|
|
if !match {
|
|
st.Count.Add(1)
|
|
st.SetLastActive(clock.Now())
|
|
if st.Count.Load() >= MaxAttempts {
|
|
// Lockout reached: destroy the pending code so a stolen digest
|
|
// cannot be replayed against a fresh guessing loop.
|
|
if _, err := q.Exec(ctx, `
|
|
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 LockedOut, nil
|
|
}
|
|
return Incorrect, nil
|
|
}
|
|
|
|
// Success: a legacy (pre-pepper) hash that verified is re-hashed with the
|
|
// pepper so the plain digest is retired on the next successful verify. This
|
|
// matters only for the interactive paths (consume=false), where the pending
|
|
// code stays valid for the rest of the handshake — consume mode destroys
|
|
// the digest outright, so there is nothing to upgrade.
|
|
if legacy && !consume {
|
|
if _, err := q.Exec(ctx, `
|
|
UPDATE users
|
|
SET two_factor_pending_code_hash = $2
|
|
WHERE id = $1
|
|
`, userID, Hash(reqCode)); err != nil {
|
|
log.Printf("failed to upgrade legacy 2FA pending code hash for user %s: %v", userID, err)
|
|
}
|
|
}
|
|
// Success: clear the attempt counter before the caller performs its
|
|
// action. The mint-cooldown stamp (LastMintAt) is deliberately NOT cleared
|
|
// here (Round 2 Loop A finding 2): a code verified at an interactive gate
|
|
// may still be followed by a FAILED money action whose retry mints a fresh
|
|
// code, and that fresh-code mint path (twoFAMintThrottled in handlers/user)
|
|
// enforces the per-user mint cooldown against this stamp. Clearing it on a
|
|
// gate-verify let a failure loop mint a fresh code on every iteration with
|
|
// no 60s cooldown (code churn + dev log flooding). The stamp is cleared
|
|
// only at a TERMINAL SUCCESS — the completed-operation consumption path
|
|
// (ConsumePendingCode, called inside the transaction that records the
|
|
// completed operation) — so a user who just completed a flow can
|
|
// immediately request a fresh code.
|
|
st.Count.Store(0)
|
|
st.SetLastActive(clock.Now())
|
|
ResetAttempts(userID)
|
|
// LOW 6b: a correct code proves control of the account's second factor, so
|
|
// lift any password-guessing login lockout (users.failed_attempts /
|
|
// locked_until) — a successful 2FA challenge is a strong auth signal, and
|
|
// the only way to reach a 2FA verify is an already-authenticated session.
|
|
// Best-effort: a failure only logs; the verify has already succeeded.
|
|
if _, err := q.Exec(ctx, `
|
|
UPDATE users
|
|
SET failed_attempts = 0, locked_until = NULL
|
|
WHERE id = $1
|
|
`, userID); err != nil {
|
|
log.Printf("failed to clear login lockout on 2FA verify for user %s: %v", userID, err)
|
|
}
|
|
if consume {
|
|
// Consume mode (the payments saved-card gate, B6/B10): a verified code
|
|
// is single-use. NULL the stored digest and its expiry so the same code
|
|
// cannot authorize a second saved-card charge within its 10-minute
|
|
// lifetime. The interactive setup/disable flows pass consume=false:
|
|
// they clear the pending fields themselves on success (enableTwoFA /
|
|
// disableTwoFA), so the code must stay valid through the whole
|
|
// verification handshake here.
|
|
//
|
|
// DB-ATOMIC (F5.5): the UPDATE is conditional on the exact digest this
|
|
// verify just matched (WHERE id=$1 AND two_factor_pending_code_hash=$2)
|
|
// and reports rows affected. The per-user mutex above only serializes
|
|
// attempts WITHIN one process; two concurrent verifications of the same
|
|
// code on different instances both read the same digest and both match
|
|
// it, but only the first conditional UPDATE can affect a row — the
|
|
// loser sees 0 rows and must fail (MissingOrExpired), so one code
|
|
// authorizes exactly ONE operation even across instances.
|
|
tag, err := q.Exec(ctx, `
|
|
UPDATE users
|
|
SET two_factor_pending_code_hash = NULL,
|
|
two_factor_pending_code_expires = NULL
|
|
WHERE id = $1 AND two_factor_pending_code_hash = $2
|
|
`, userID, pendingHash.String)
|
|
if err != nil {
|
|
// The DB is erroring — atomicity cannot be proven. Keep the prior
|
|
// fail-open behaviour (report the verify as OK) so the user is not
|
|
// stranded; the in-process mutex still serializes same-instance
|
|
// verifications, and a degraded DB is the only way to reach here.
|
|
log.Printf("failed to consume 2FA pending code for user %s: %v", userID, err)
|
|
} else if tag.RowsAffected() == 0 {
|
|
// A concurrent verification consumed the code between this SELECT
|
|
// and this UPDATE. Single-use means this one must fail.
|
|
return MissingOrExpired, nil
|
|
}
|
|
}
|
|
return OK, nil
|
|
}
|
|
|
|
// ConsumePendingCode NULLs the user's pending 2FA code digest and expiry, and
|
|
// clears the per-user mint-cooldown stamp (AttemptState.LastMintAt).
|
|
// The saved-card charge gates are SCA-only and no longer consume codes at a
|
|
// gate verify (there is no homegrown gate verify to consume at); this is used
|
|
// on the TERMINAL-SUCCESS paths of the payments package — after an
|
|
// SCA-approved saved-card charge, a gift card issuance, or a till sale that
|
|
// used the customer's pending code — inside the transaction that records the
|
|
// completed operation, so a pending code minted for a flow can never authorize
|
|
// a second one. Idempotent: consuming an already-NULL pending code is a no-op,
|
|
// so a code still authorizes exactly one completed charge and can never
|
|
// authorize a second after success. Accepts a db.Querier so the write can ride
|
|
// the caller's transaction (pgx.Tx) or the pool proxy.
|
|
//
|
|
// Round 2 Loop A finding 2: this is the ONLY place the mint-cooldown stamp is
|
|
// cleared on the charge path. A successful gate VERIFY (twofa.Check) must NOT
|
|
// clear it — the flow may still fail and the retry's fresh-code mint
|
|
// (twoFAMintThrottled in handlers/user) enforces its cooldown against the
|
|
// stamp. Reaching terminal SUCCESS is what re-arms immediate re-minting, so
|
|
// consumption (which runs only at that terminal state) clears it.
|
|
func ConsumePendingCode(ctx context.Context, q db.Querier, userID string) error {
|
|
if userID == "" {
|
|
return nil
|
|
}
|
|
_, err := q.Exec(ctx, `
|
|
UPDATE users
|
|
SET two_factor_pending_code_hash = NULL,
|
|
two_factor_pending_code_expires = NULL
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("2FA consume pending code: %w", err)
|
|
}
|
|
// Clear the mint-cooldown stamp. Best-effort and in-memory: a missing or
|
|
// evicted entry (e.g. after a process restart) only lets the cooldown
|
|
// lapse — it never grants a fresh guessing budget.
|
|
ClearMintCooldownForUser(userID)
|
|
return nil
|
|
}
|
|
|
|
// ClearMintCooldownForUser zeroes the user's mint-cooldown stamp (LastMintAt)
|
|
// under the per-user mutex — LastMintAt is only ever touched under Mu. A no-op
|
|
// when the user's state IS the shared saturated singleton (Round 2 Loop B
|
|
// finding 3a: the shared stamp must not be cleared for every untracked user by
|
|
// one user's terminal success).
|
|
//
|
|
// Round 2 Loop B finding 6a — COORDINATION CONTRACT (money agent): the FRESH
|
|
// saved-card charge path consumes the customer's 2FA code AT THE GATE
|
|
// (consume=true), so twofa.ConsumePendingCode is NOT called on its terminal
|
|
// success and the mint-cooldown stamp survives — a customer who completes a
|
|
// fresh charge within 60s of their last code mint and immediately requests a
|
|
// new code gets 429 for the rest of the window. The money agent's fresh-charge
|
|
// terminal-success path in handlers/payments/handlers.go should call
|
|
// ClearMintCooldownForUser(userID) at the same point a completed charge is
|
|
// recorded, so a just-completed charge re-arms immediate re-minting. This
|
|
// function is the exported, coordination-actionable entry point for that call.
|
|
func ClearMintCooldownForUser(userID string) {
|
|
st := StateFor(userID)
|
|
if st == saturatedLockedState {
|
|
return
|
|
}
|
|
st.Mu.Lock()
|
|
st.LastMintAt = time.Time{}
|
|
st.Mu.Unlock()
|
|
}
|
|
|
|
// Classifying errors returned by VerifyForUser.
|
|
var (
|
|
// ErrIncorrect reports a code that does not match the user's pending code.
|
|
ErrIncorrect = errors.New("2FA code is incorrect")
|
|
// ErrLockedOut reports that the user has exhausted the failed-attempt
|
|
// budget; further attempts must wait for the attempt window to elapse.
|
|
ErrLockedOut = errors.New("2FA code locked out: too many failed attempts")
|
|
// ErrMissingOrExpired reports that no valid pending code exists for the
|
|
// user; a fresh code must be requested first.
|
|
ErrMissingOrExpired = errors.New("2FA code is missing or has expired")
|
|
)
|
|
|
|
// VerifyForUser verifies a 2FA code for a user outside the HTTP handler layer,
|
|
// under the same per-user brute-force lockout as the interactive endpoints.
|
|
// It returns nil on a correct code, or one of ErrIncorrect / ErrLockedOut /
|
|
// ErrMissingOrExpired (or a DB error, wrapped). This is the non-HTTTP entry
|
|
// point for the interactive account flows — today only delete-account
|
|
// re-authentication (handlers/user/account.go), which passes ConsumeOnVerify so
|
|
// a code authorizes exactly one erasure. The payments saved-card gates no
|
|
// longer verify codes (SCA-only); the interactive setup/disable flows reach
|
|
// Check through handlers/user's checkTwoFACode with DeferredConsume and clear
|
|
// the pending fields themselves on success.
|
|
func VerifyForUser(ctx context.Context, userID, code string, consume bool) error {
|
|
st := StateFor(userID)
|
|
st.Mu.Lock()
|
|
defer st.Mu.Unlock()
|
|
|
|
result, err := Check(ctx, db.Conn, userID, st, code, consume)
|
|
if err != nil {
|
|
return fmt.Errorf("2FA verify: %w", err)
|
|
}
|
|
switch result {
|
|
case OK:
|
|
return nil
|
|
case Incorrect:
|
|
return ErrIncorrect
|
|
case LockedOut:
|
|
return ErrLockedOut
|
|
case MissingOrExpired:
|
|
return ErrMissingOrExpired
|
|
}
|
|
return nil
|
|
}
|