Files
Crussell/backend/handlers/payments/twofa.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

132 lines
7.0 KiB
Go

package payments
import (
"net/http"
"os"
"strings"
)
// 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()
}
// requireTwoFactorForCardAccess gates the saved-card online payment paths.
// It returns (allowed, fallbackUsed): allowed is true when the request may
// proceed; fallbackUsed is ALWAYS false — the homegrown 2FA fallback for
// token-less saved-card charges was REMOVED ENTIRELY, so no charge is ever
// authorized by a 2FA code and no fallback audit row is ever written.
//
// It is a thin wrapper over requireTwoFactorForCardAccessWithTokenValidation
// that passes tokenForwardedToSquare=true — the saved-card CHARGE surfaces
// (booking, tip, gift-card buy, till, terminal) forward the verification_token
// to Square in CreatePaymentReq.VerificationToken, so a non-empty token there
// IS Square-validated SCA and legitimately skips the gate. The card-SAVE
// surfaces call the WithTokenValidation variant directly with false (see that
// helper for the auth-F1 rationale).
//
// The decision model, in order:
//
// - 2FA enforcement is not active (dev/mock, or REQUIRE_2FA disabled) →
// allowed. The dev mock simulates SCA (SimulateSavedCardVerificationRequired
// + cnon:sca-... tokenize-results), so development has full parity with the
// SCA-only production posture.
//
// - The request carries a Square verification_token (SCA performed — the
// issuer has already authenticated the buyer) AND the token is forwarded to
// Square on this surface (tokenForwardedToSquare=true, the charge paths):
// SKIP the gate entirely. SCA is PRIMARY; a charge that carries a token
// passes even for a user who has not enabled 2FA.
//
// - Otherwise the charge is token-less, and there is NO homegrown 2FA
// fallback anymore. PSR 2017 reg 100 makes Strong Customer Authentication
// mandatory and NON-WAIVABLE for customer-initiated stored-credential
// charges, and a merchant-side 2FA check with no bank involvement cannot
// legally act as an SCA substitute: authorising a token-less charge via 2FA
// would leave the MERCHANT liable for ECI 7 / SLI 210 chargebacks and PSR
// 2017 reg 77(6) compensation, and customer consent does not cure that. On
// genuine sca-unavailable the charge is therefore REFUSED 402
// verification_required and the customer is invited to pay online later.
//
// 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, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
return requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, verificationToken, consume, true)
}
// requireTwoFactorForCardAccessWithTokenValidation is the real gate:
// requireTwoFactorForCardAccess above is the charge-surface wrapper that passes
// tokenForwardedToSquare=true. The extra flag distinguishes surfaces where the
// verification_token WILL be forwarded to Square (charge — Square validates it
// server-side and rejects a forged token, so a non-empty token legitimately
// proves SCA) from surfaces where it is client-asserted and NEVER forwarded
// (card SAVE — the card is persisted via CreateCardOnFile, which takes no
// verification_token, so Square never validates it).
//
// auth-F1: on a SAVE surface a forged non-empty verification_token must NOT
// skip the gate — with tokenForwardedToSquare=false the token is ignored and
// the token-less refusal below applies. An authenticated client can therefore
// no longer persist a card to the account with `verification_token: "anything"`
// and no SCA. The legitimate SAVE skips are handled by the call site before
// this helper is ever reached: a genuine Square token-like card token is
// SCA-proven (the STORE-intent SCA performed at tokenization IS the
// verification — see CreatePaymentMethod's save gate) and the
// scaTokenizedSavedCard flow (new_card_token + saved_card_id) never persists a
// card.
//
// The verification_code parameter that used to flow through this gate is GONE:
// the gate never read it (SCA-only), the request structs no longer carry it,
// and there is no homegrown fallback to authorise anything with it.
func requireTwoFactorForCardAccessWithTokenValidation(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationToken string, consume bool, tokenForwardedToSquare bool) (allowed, fallbackUsed bool) {
if !twoFactorEnforced() {
return true, false
}
// SCA-primary: a Square verification_token means the issuer already
// completed Strong Customer Authentication — the gate is skipped and no
// fallback applies. This skip is only valid when the token WILL be
// forwarded to Square (tokenForwardedToSquare=true, the charge surfaces):
// Square validates it and rejects a forged value. On a SAVE surface the
// token is client-asserted and never reaches Square, so a forged non-empty
// token must not skip the gate (auth-F1) — the token-less refusal below
// applies instead.
if verificationToken != "" && tokenForwardedToSquare {
return true, false
}
// SCA-only: a token-less saved-card charge has NO 2FA fallback (the
// homegrown fallback was REMOVED — see the gate doc above for the PSR 2017
// legal rationale). It is refused 402 verification_required; the customer is
// invited to pay online later (or through Square's buyer-verification flow
// on a retry). fallbackUsed stays false.
writeVerificationRequiredResponse(w)
return false, false
}