- 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
132 lines
7.0 KiB
Go
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
|
|
}
|