Files
Crussell/backend/handlers/payments/twofa.go
T
popertots 36887167c6 fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round
Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:

MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance

SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue

DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)

Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
2026-08-22 00:34:50 +01:00

343 lines
16 KiB
Go

package payments
import (
"context"
"crypto/rand"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"os"
"strings"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/twofa"
"crussell/mw"
"github.com/jackc/pgx/v5"
)
// require2FADisabled reports whether REQUIRE_2FA explicitly disables 2FA
// enforcement. The parse is case-insensitive and alias-tolerant (false/0/off/no),
// so a value like "False", "OFF" or "off" never silently leaves the gate ON.
// Any other value — including empty or unknown — keeps enforcement ON
// (fail-closed).
func require2FADisabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("REQUIRE_2FA"))) {
case "false", "0", "off", "no":
return true
default:
return false
}
}
// twoFactorEnforced reports whether 2FA is required for online card payments.
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
// (REQUIRE_2FA=false/0/off/no, case-insensitive — see require2FADisabled) or
// SQUARE_ENVIRONMENT explicitly selects the dev/mock stack
// (mock/dev/development/test — see IsExplicitDevOrMockEnv in
// idempotency_helpers.go). Empty or unknown SQUARE_ENVIRONMENT values are
// treated as production-enforced, so a mistyped env var can never silently
// disarm the gate — main.go logs a startup warning for that misconfiguration.
func twoFactorEnforced() bool {
return !require2FADisabled() && !IsExplicitDevOrMockEnv()
}
// TwoFactorEnforced is the exported form of twoFactorEnforced, so the user
// package (settings endpoints) and the profile handler can report whether 2FA
// is currently required without re-implementing the env logic.
func (s *PaymentService) TwoFactorEnforced() bool {
return twoFactorEnforced()
}
// UserTwoFactorEnabled reports whether the user has completed 2FA setup
// (users.two_factor_enabled). It is the source of truth for the card-access
// gate: an enforced environment blocks online card access for users who have
// not enabled 2FA.
func (s *PaymentService) UserTwoFactorEnabled(ctx context.Context, userID string) (bool, error) {
var enabled bool
err := db.Conn.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
if err != nil {
return false, err
}
return enabled, nil
}
// Single source of truth for 2FA verification: crussell/internal/twofa owns
// the code hashing (HMAC-SHA256 keyed by TWO_FACTOR_PEPPER, legacy SHA-256
// fallback), the constant-time compare, the code-lifetime check, and the
// per-user brute-force lockout. The user package's interactive endpoints
// (setup/verify/disable) and this saved-card gate all share it; nothing is
// re-implemented locally here. Two agents once shipped a drift-risk duplicate
// of the hash+verify in this file (hashTwoFAVerificationCode +
// verifyPendingTwoFactorCode) — that copy is gone, and any future change to
// the hashing or lockout rules must land in internal/twofa only.
// verifyPendingTwoFactorCode verifies the submitted code against the user's
// stored pending 2FA code. It is a thin delegation shim over
// twofa.VerifyForUser — the single source of truth for the verification core
// (per-user brute-force lockout, constant-time compare, legacy pre-pepper
// hash fallback, code lifetime). consume=true makes a verified code SINGLE-USE
// immediately (the pending code is NULLed on success); consume=false verifies
// WITHOUT consuming. Since finding 1 the saved-card CHARGE gates pass
// consume=!reusePendingRecord: a FRESH charge consumes at the gate (one code
// authorizes exactly one charge), while a PENDING-REUSE retry passes false and
// defers consumption to the completed-charge transaction via
// twofa.ConsumePendingCode, so a retry that fails again keeps its code for one
// more attempt. The save-card SAVE gates pass true because saving a card is a
// terminal operation with no downstream charge to attach consumption to. It
// returns nil on a valid code, or a classified twofa.ErrIncorrect /
// twofa.ErrLockedOut / twofa.ErrMissingOrExpired (or a wrapped DB error) for
// the caller to map to the correct HTTP status.
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consume bool) error {
return twofa.VerifyForUser(ctx, userID, code, consume)
}
// twoFactorFallbackEnabled reports whether the homegrown 2FA may act as a
// BACKUP authorization for a saved-card charge when SCA is unavailable (the
// charge carries no Square verification_token). The parse is case-insensitive
// and alias-tolerant (false/0/off/no) — a value like "False" or "OFF" never
// silently leaves the fallback ON. Any other value — including empty and
// unknown — keeps the fallback enabled (the shipped default). It is the
// TWO_FACTOR_FALLBACK policy switch read at startup by main.go and exposed via
// PaymentService.TwoFactorFallbackEnabled.
func twoFactorFallbackEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("TWO_FACTOR_FALLBACK"))) {
case "false", "0", "off", "no":
return false
default:
return true
}
}
// TwoFactorFallbackEnabled is the exported form of twoFactorFallbackEnabled, so
// main.go can log the SCA-primary/2FA-backup posture at startup without
// re-implementing the env logic.
func (s *PaymentService) TwoFactorFallbackEnabled() bool {
return twoFactorFallbackEnabled()
}
// requireTwoFactorForCardAccess gates the saved-card online payment paths under
// the SCA-primary / 2FA-backup decision model. It returns (allowed, fallbackUsed):
// allowed is true when the request may proceed; fallbackUsed is true when the
// authorization was granted by the homegrown 2FA BACKUP (SCA was unavailable and
// the customer's 2FA code verified) — the caller must then write a strict
// insertTwoFAFallbackAudit row for the charge.
//
// The decision model, in order:
//
// - 2FA is not enforced (dev/mock) → allowed, no fallback.
//
// - The request carries a Square verification_token (SCA performed — the
// issuer has already authenticated the buyer): SKIP the 2FA gate entirely.
// SCA is PRIMARY; the issuer did the job, so the homegrown gate is never
// consulted (fallbackUsed=false). A charge that carries a token passes even
// for a user who has not enabled 2FA.
//
// - Otherwise the gate is the FALLBACK authorization for a ccof charge with
// no verification token. It only runs when the fallback is permitted:
//
// (a) TWO_FACTOR_FALLBACK is enabled (see twoFactorFallbackEnabled) — when
// the deployment opts out, a token-less charge is denied 402
// verification_required: the frontend shows the SCA challenge, and if the
// bank cannot do SCA the payment cannot proceed (security-first); and
//
// (b) a 2FA code delivery channel exists (twoFADeliveryAvailable, build-
// dependent like the user package's) — a code the customer can never
// receive would silently lock the gate, so it is denied 503
// ("2FA requires an email or SMS delivery channel").
//
// - The user has completed 2FA setup (two_factor_enabled) AND the request
// carries a verification_code matching the user's stored pending code.
//
// B10: the setup flag alone must NOT unlock saved-card charges — an enforced
// environment requires an actual one-time code challenge at charge time, so
// merely enabling 2FA (a setup flag) can never unlock saved-card access with
// no challenge. The code is the customer's current pending 2FA code, which an
// operator relays (delivery is the user package's build-dependent [2FA] log /
// email-SMS channel).
//
// consume controls whether a verified code is NULLed immediately (consume=true
// — a FRESH charge's single-use burn at the gate, closing the TOCTOU where a
// verified-but-unconsumed code could authorize a second charge; and the
// save-card SAVE gate, a terminal operation) or left intact for the caller to
// consume when a PENDING-REUSE retry reaches terminal success
// (consume=false — see verifyPendingTwoFactorCode / twofa.ConsumePendingCode,
// finding 1). In every case the 5-attempt lockout and the
// code-destroy-on-lockout semantics are unchanged (twofa.Check).
//
// The code check is delegated to crussell/internal/twofa via
// verifyPendingTwoFactorCode, so this gate participates in the SAME per-user
// brute-force lockout (5 failed attempts invalidate the pending code) as the
// user package's setup/verify/disable flows. Classified errors map to the HTTP
// statuses the frontend expects: incorrect → 400, locked out → 429, missing or
// expired → 400, DB failure → 500.
//
// On any denial an error JSON is written (parseable by the frontend via
// extractErrorMessage) and allowed=false is returned — the caller must abort
// the charge.
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
if !twoFactorEnforced() {
return true, false
}
if service == nil {
service = &PaymentService{}
}
// SCA-primary: a Square verification_token means the issuer already
// completed Strong Customer Authentication — the 2FA gate is skipped and no
// fallback audit applies.
if verificationToken != "" {
return true, false
}
// 2FA is now the BACKUP authorization for a token-less ccof charge. Fail
// closed when the deployment disabled the fallback (TWO_FACTOR_FALLBACK) or
// has no delivery channel for its codes (twoFADeliveryAvailable).
if !twoFactorFallbackEnabled() {
writeVerificationRequiredResponse(w)
return false, false
}
if !twoFADeliveryAvailable() {
mw.RespondError(w, http.StatusServiceUnavailable, "2FA requires an email or SMS delivery channel; contact the salon")
return false, false
}
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
return false, false
}
log.Printf("failed to check two-factor status for user %s: %v", userID, err)
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
return false, false
}
if !enabled {
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
return false, false
}
// B10: an enforced charge of a saved card needs a live one-time code, not
// just the enabled setup flag.
if verificationCode == "" {
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
return false, false
}
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
case err == nil:
// The 2FA BACKUP authorized this token-less saved-card charge. The
// caller writes the strict fallback audit row on the charge's success.
return true, true
case errors.Is(err, twofa.ErrIncorrect):
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
return false, false
case errors.Is(err, twofa.ErrLockedOut):
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
return false, false
case errors.Is(err, twofa.ErrMissingOrExpired):
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
return false, false
default:
log.Printf("failed to check two-factor verification code for user %s: %v", userID, err)
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor verification code")
return false, false
}
}
// reissueTwoFACodeAfterFailedCharge mints a fresh 2FA code after a saved-card
// charge failed at Square — but ONLY when a code was actually consumed by a
// FRESH saved-card charge (fresh-only semantics). The charge gate consumes the
// verified code at gate time for fresh charges (single-use — closing the
// verify-then-consume TOCTOU where a verified-but-unconsumed code could
// authorize a second charge), so a failed fresh charge leaves no live code for
// the same-key retry; this re-issues one with the same 10-minute lifetime and
// delivery behaviour as the user package's code issuance (dev/test logs the
// code for the operator to relay; production logs only with
// TWO_FACTOR_ALLOW_LOG_DELIVERY=true, matching the fail-closed delivery
// contract). It is a NO-OP for every other outcome: a new-card (cnon) charge
// never gates (usedSavedCard=false), a pending-reuse retry verified WITHOUT
// consuming (fallbackUsed=false — its code survives for one more attempt and a
// re-issue would silently invalidate the one the customer holds), and an
// SCA-authorized charge never touched the 2FA gate at all.
//
// Callers pass:
// - usedSavedCard: whether this charge actually used a saved card (the 2FA
// gate applies only to saved-card ccof charges);
// - fallbackUsed: whether the 2FA fallback gate actually consumed a code on
// THIS attempt (true only for a fresh charge — the gate's
// twoFAFallbackUsed ANDed with the caller's not-a-pending-reuse test).
//
// LOW-MEDIUM (finding 2): the re-issue is routed through the same fail-closed
// issuance gate as the interactive mint paths (twoFAReissueIssueAllowed —
// mirrored from the user package's twoFAEnsureIssueAllowed via the build-tagged
// twofa_delivery_dev.go / twofa_delivery_prod.go): a production build refuses
// to re-issue when TWO_FACTOR_PEPPER is unset (an unsalted digest in the 1M
// code space would be offline-brute-forceable) or when no delivery channel is
// configured. It also respects the same per-user mint cooldown
// (twoFAMintCooldown via the shared twofa.AttemptState.LastMintAt), so a
// charge-failure loop cannot mint codes faster than the mint endpoints allow.
// Best-effort: a failure logs and the customer requests a fresh code through
// the normal 2FA flow.
func reissueTwoFACodeAfterFailedCharge(ctx context.Context, q db.Querier, userID string, usedSavedCard, fallbackUsed bool, r *http.Request) {
if userID == "" || !twoFactorEnforced() || !usedSavedCard || !fallbackUsed {
return
}
if err := twoFAReissueIssueAllowed(); err != nil {
log.Printf("2FA: refused to re-issue a code for user %s after a failed charge: %v", userID, err)
return
}
// Mint cooldown (B11a): the shared per-user mutex serializes the stamp
// read/write with the user package's mints and the gate's verify critical
// section. A successful verify (twofa.Check) clears the stamp, so a code
// verified at the gate never throttles this immediate re-issue.
st := twofa.StateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
if !st.LastMintAt.IsZero() && clock.Now().Sub(st.LastMintAt) < twoFAMintCooldown {
log.Printf("2FA: re-issue skipped for user %s after a failed charge (mint cooldown)", userID)
return
}
code, err := generatePaymentsTwoFACode()
if err != nil {
log.Printf("2FA: failed to generate a re-issued code for user %s after a failed charge: %v", userID, err)
return
}
if _, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1
`, userID, twofa.Hash(code), clock.Now().Add(twoFAPendingCodeLifetime)); err != nil {
log.Printf("2FA: failed to store a re-issued code for user %s after a failed charge: %v", userID, err)
return
}
st.LastMintAt = clock.Now()
// Delivery mirrors the user package's build-dependent behaviour (the
// operator relays the [2FA] log line). Production logs the plaintext code
// only when explicitly opted in; dev/test always.
if IsExplicitDevOrMockEnv() || os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
log.Printf("[2FA] code: %s", code)
}
}
// twoFAMintCooldown bounds how often the re-issue path mints a fresh 2FA code
// for one user after a failed saved-card charge, mirroring the user package's
// mint cooldown (handlers/user/twofa.go). The shared stamp lives on the
// per-user twofa.AttemptState.LastMintAt so both mint paths cohere.
const twoFAMintCooldown = 1 * time.Minute
// generatePaymentsTwoFACode returns a random 6-digit verification code,
// mirroring the user package's generator (crypto/rand, uniform 0-999999).
func generatePaymentsTwoFACode() (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
}
// twoFAPendingCodeLifetime is how long a re-issued 2FA code stays valid,
// mirroring the user package's pending-code expiry.
const twoFAPendingCodeLifetime = 10 * time.Minute