Files
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

134 lines
7.2 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) {
_ = consume // TODO: remove when callers updated — the SCA-only gate never reads verification codes
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) {
_ = consume // TODO: remove when callers updated — the SCA-only gate never reads verification codes
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
}