PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges; a merchant-side 2FA check cannot legally substitute for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent). - payments/twofa.go: the homegrown 2FA fallback for token-less saved-card charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only: a non-empty Square verification_token (charge surfaces, token forwarded to Square) skips the gate; anything else is refused 402 verification_required. enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs). - New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces where the token IS forwarded to Square (charge — Square validates it) from card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token must NOT skip the save gate, auth-F1). - SCA tokenize-result wire contract (C1): a saved card charged with a fresh one-time tokenize-result sends the token as the charge SOURCE (new_card_token -> source_id) alongside saved_card_id, never a separate verification_token. resolveChargeSource resolves the saved-card branch FIRST (customer from the card row, token as source) so combined token+card requests are SCA-clean. - C6 consent fields (consent_version / consent_accepted) added to the booking/ tip/till/gift-card charge requests, enforced server-side before any fallback charge could reach Square and recorded on the 2fa_fallback_charge audit row; logVerificationTokenProvenance traces minted tokens to their charge. - user 2FA issuance gate refactored into pure build-agnostic functions (twoFAPepperConfigured / twoFADeliveryChannelConfigured / twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and .env.example entry removed; startup posture notes updated. - Test coverage: fail-closed 2FA production gates (pepper/delivery), token validation on save vs charge surfaces, completion idempotency, idempotency key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
457 lines
25 KiB
Go
457 lines
25 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"
|
|
)
|
|
|
|
// 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()
|
|
}
|
|
|
|
// errTwoFAPepperRequired is returned when TWO_FACTOR_PEPPER is unset in a
|
|
// production-style re-issue gate. Refusing to re-issue is the only safe outcome:
|
|
// without the pepper the fresh code would be persisted as an unsalted SHA-256
|
|
// digest in the 1M code space, which a log/DB leak could brute-force offline
|
|
// (mirrors handlers/user's errTwoFAPepperRequired). Defined here (no build tag)
|
|
// so the pure gate (twoFAReissueIssueAllowedStrict) and the test,dev suite
|
|
// share it.
|
|
var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)")
|
|
|
|
// errTwoFADeliveryUnavailable is returned when a production-style re-issue has
|
|
// no delivery channel: email/SMS unwired and the operator has not opted into
|
|
// the insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). A fresh
|
|
// code the customer could never receive would strand them on the saved-card
|
|
// gate (mirrors handlers/user's errTwoFADeliveryUnavailable).
|
|
var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon")
|
|
|
|
// twoFAPepperConfigured reports whether TWO_FACTOR_PEPPER is set — the pure,
|
|
// build-agnostic read behind the strict re-issue gate.
|
|
func twoFAPepperConfigured() bool { return os.Getenv("TWO_FACTOR_PEPPER") != "" }
|
|
|
|
// twoFADeliveryChannelConfigured reports whether the deployment has explicitly
|
|
// configured a 2FA code delivery channel: TWO_FACTOR_ALLOW_LOG_DELIVERY set to
|
|
// exactly "true" (the only production channel today — email/SMS unwired, P6).
|
|
// Pure env read, build-agnostic: the build-tagged twoFADeliveryAvailable
|
|
// (twofa_delivery_dev.go / twofa_delivery_prod.go) is the runtime-facing
|
|
// wrapper that turns this into the always-true dev channel or the prod env
|
|
// check.
|
|
func twoFADeliveryChannelConfigured() bool { return os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" }
|
|
|
|
// twoFAReissueIssueAllowedStrict is the pure, build-agnostic production-style
|
|
// re-issue gate: a re-issued 2FA code may be minted ONLY when BOTH
|
|
// TWO_FACTOR_PEPPER is set (an unsalted digest in the 1M code space would be
|
|
// offline-brute-forceable) AND a delivery channel is configured (otherwise the
|
|
// fresh code could never reach the customer). Either way it fails closed. The
|
|
// build-tagged twoFAReissueIssueAllowed wraps it for production builds;
|
|
// dev/test builds always allow re-issue and never consult it — but the test,dev
|
|
// suite exercises THIS function directly, so the fail-closed branches are
|
|
// CI-visible even though the prod file (!dev && !test) is excluded there.
|
|
func twoFAReissueIssueAllowedStrict() error {
|
|
if !twoFAPepperConfigured() {
|
|
return errTwoFAPepperRequired
|
|
}
|
|
if !twoFADeliveryChannelConfigured() {
|
|
return errTwoFADeliveryUnavailable
|
|
}
|
|
return 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 file's re-issue path share it; nothing is
|
|
// re-implemented locally here. The saved-card charge gate no longer verifies
|
|
// 2FA codes at all (SCA-only — the 2FA fallback was removed), so the only
|
|
// local consumer left is reissueTwoFACodeAfterFailedCharge below.
|
|
|
|
// enforceSCAFallbackConsent is retained as a compile-compatible NO-OP so the
|
|
// saved-card charge handlers (handlers.go / till.go / giftcards.go) keep
|
|
// compiling unchanged. It used to enforce the C6 consent notice on the
|
|
// SCA-unavailable → 2FA fallback path, which is now REMOVED ENTIRELY: PSR 2017
|
|
// reg 100 makes Strong Customer Authentication mandatory and non-waivable for
|
|
// customer-initiated stored-credential charges, and the homegrown 2FA (a
|
|
// merchant-side check with no bank involvement) cannot legally act as an SCA
|
|
// fallback — authorising a token-less charge via 2FA leaves the MERCHANT liable
|
|
// for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6) compensation, and
|
|
// customer consent does not cure that. The gate now refuses a token-less
|
|
// saved-card charge 402 verification_required BEFORE any fallback can be used,
|
|
// so fallbackUsed is always false and there is no consent to demand. The
|
|
// callers pass it through as before; it always returns true.
|
|
func enforceSCAFallbackConsent(w http.ResponseWriter, consentVersion *string, consentAccepted bool, fallbackUsed bool) bool {
|
|
return true
|
|
}
|
|
|
|
// consentVersionValue normalizes the request's optional consent_version pointer
|
|
// to a string ("" when absent). Retained because the saved-card charge handlers
|
|
// still read the field when writing their (now unreachable) fallback audit row.
|
|
func consentVersionValue(version *string) string {
|
|
if version == nil {
|
|
return ""
|
|
}
|
|
return *version
|
|
}
|
|
|
|
// 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 (the
|
|
// callers' insertTwoFAFallbackAudit branches are unreachable).
|
|
//
|
|
// It is a thin wrapper over requireTwoFactorForCardAccessWithTokenValidation
|
|
// that passes tokenForwardedToSquare=true — the legacy signature is retained
|
|
// because the saved-card CHARGE surfaces (booking, tip, gift-card buy, till)
|
|
// 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, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
|
|
return requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, verificationCode, 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 one legitimate SAVE skip is the call-site's scaTokenizedSavedCard
|
|
// flow (new_card_token + saved_card_id): there the SCA tokenize-result token IS
|
|
// the charge source and Square validates it as source_id, so the gate is skipped
|
|
// by the caller before this helper is ever reached.
|
|
func requireTwoFactorForCardAccessWithTokenValidation(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, 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
|
|
}
|
|
|
|
// 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 a CRITICAL line + raises a critical-payment
|
|
// admin notification (the operator must mint a code manually or fix the
|
|
// config) and the customer requests a fresh code through the normal 2FA flow.
|
|
//
|
|
// PEPPER-CHANGE NOTE (Loop B finding 2): TWO_FACTOR_PEPPER is the ONLY hard
|
|
// gate on issuance here (twoFAReissueIssueAllowed) AND on the interactive mint
|
|
// paths (twoFAEnsureIssueAllowed in handlers/user). The pepper keys the
|
|
// HMAC-SHA256 of every stored pending-code hash, so CHANGING it invalidates
|
|
// ALL pending codes — every stored hash was computed with the old pepper and
|
|
// can never match a code minted under the new one. A fresh saved-card charge
|
|
// that consumed a pre-change code then fails will re-issue a code hashed with
|
|
// the NEW pepper, which still cannot match anything the customer holds (their
|
|
// code was minted under the old pepper, or was consumed). Operators MUST NOT
|
|
// change the pepper without re-minting every user's code (or having the
|
|
// customer re-run 2FA setup); main.go's startup check should treat a changed
|
|
// pepper as a config incident.
|
|
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 {
|
|
// Loop B HIGH (finding 2): a REFUSED re-issue strands the customer. The
|
|
// gate consumed their code for the fresh charge (single-use) and the
|
|
// charge failed — with no re-issued code the same-key retry fails
|
|
// forever with 400 ErrMissingOrExpired and the customer is locked out.
|
|
// This is an OPERATOR-FACING incident, not a silent best-effort miss:
|
|
// log a CRITICAL line AND raise the per-issue-capped critical-payment
|
|
// admin notification (sweep.go's insertCriticalPaymentNotification — the
|
|
// DB-backed stand-in for the un-watched CRITICAL logs) so the operator
|
|
// knows the customer is blocked and can mint a code manually or fix the
|
|
// config (TWO_FACTOR_PEPPER / delivery channel).
|
|
log.Printf("CRITICAL: failed to re-issue a 2FA code for user %s after a failed saved-card charge (%v) — the customer's code was consumed by the fresh charge and NO live code remains, so the same-key retry cannot succeed; the operator must mint a code manually or configure TWO_FACTOR_PEPPER and a 2FA delivery channel", userID, err)
|
|
// Round 2 Loop B findings 2 + 7 — the alert is capped PER-ISSUE, NOT
|
|
// globally (see alertReissueFail below): at most ONE unacknowledged row
|
|
// per stranded customer, so one customer's alert can never be
|
|
// suppressed by OTHER users' rows filling the 'critical_payment_log'
|
|
// bucket, and the count-then-insert is atomic (a single INSERT ... WHERE
|
|
// NOT EXISTS — no TOCTOU). An attacker also cannot FLOOD the alert:
|
|
// raising it requires a real saved-card charge that consumed a real code
|
|
// AND a failed re-issue, and the per-issue dedup holds each user to one
|
|
// row until acknowledged. Fail-closed behaviour is unchanged (the
|
|
// CRITICAL log always fires); only the notification INSERT is bounded.
|
|
alertReissueFail(ctx, q, userID)
|
|
return
|
|
}
|
|
// Mint cooldown (B11a + Round 2 Loop A finding 2): 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 NO LONGER clears the
|
|
// stamp (internal/twofa.Check keeps it — it is cleared only at terminal
|
|
// charge success via twofa.ConsumePendingCode), so this check now genuinely
|
|
// bounds a charge-failure loop: the first re-issue after a mint is skipped
|
|
// while clock.Now().Sub(LastMintAt) < twoFAMintCooldown, bounding code churn
|
|
// and dev log flooding. The customer requests a fresh code through the
|
|
// normal mint endpoint once the window elapses.
|
|
st := twofa.StateFor(userID)
|
|
st.Mu.Lock()
|
|
defer st.Mu.Unlock()
|
|
if !st.LastMintAt.IsZero() && clock.Now().Sub(st.LastMintAt) < twoFAMintCooldown {
|
|
// Round 2 Loop B finding 6b: this skip was SILENT before. A FRESH
|
|
// charge consumed the customer's code at the gate (single-use) and the
|
|
// charge failed; the re-issue is now skipped by the per-user mint
|
|
// cooldown — the customer holds NO live code for the same-key retry
|
|
// until the cooldown lapses. That is a stranded customer, so raise the
|
|
// same per-issue-capped reissue-fail alert the refused-issue branch
|
|
// above uses (alertReissueFail, deduped on reason+user_id) so the
|
|
// operator knows to mint a code manually. The alert is per-issue
|
|
// (finding 2): repeated skips for the same customer stay ONE row until
|
|
// acknowledged and can never be suppressed by other users' rows.
|
|
log.Printf("2FA: re-issue skipped for user %s after a failed charge (mint cooldown) — the customer's code was consumed by the fresh charge and NO live code remains until the cooldown lapses", userID)
|
|
alertReissueFail(ctx, q, 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.SetLastMintAtLocked(clock.Now())
|
|
// Delivery is build-dependent (twofa_delivery_dev.go / twofa_delivery_prod.go),
|
|
// mirroring the user package's twoFADeliverCode: dev/test builds always write
|
|
// the [2FA] log line (the operator relays the code); production writes it ONLY
|
|
// when the operator explicitly opted into log delivery
|
|
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — otherwise the plaintext code is never
|
|
// logged.
|
|
twoFAReissueDeliverCode(userID, 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.
|
|
//
|
|
// Round 2 Loop A finding 2: the stamp survives a successful gate verify
|
|
// (internal/twofa.Check no longer clears it) and is cleared only at TERMINAL
|
|
// charge success via twofa.ConsumePendingCode — so this check below is what
|
|
// actually bounds a charge-failure loop: after a fresh charge consumed a code
|
|
// at the gate and failed, the re-issue is skipped while the last mint is
|
|
// inside the cooldown (logged, not silent), bounding code churn + dev log
|
|
// flooding. Round 2 Loop B finding 6a — COORDINATION (money agent): the
|
|
// FRESH-charge terminal-success path in handlers.go does NOT call
|
|
// ConsumePendingCode (the gate already burned the code with consume=true), so
|
|
// its mint-cooldown stamp survives — a customer who completes a fresh charge
|
|
// within the cooldown of their last mint and immediately requests a new code
|
|
// gets 429 until the window elapses. To re-arm immediate re-minting after a
|
|
// completed fresh charge, the money agent should call
|
|
// twofa.ClearMintCooldownForUser(userID) on that terminal-success path (the
|
|
// exported, coordination-actionable entry point documented on
|
|
// internal/twofa.ClearMintCooldownForUser).
|
|
const twoFAMintCooldown = 1 * time.Minute
|
|
|
|
// alertReissueFail surfaces a stranded-customer incident in the admin
|
|
// notification centre (reason 'critical_payment_log'). Round 2 Loop B finding
|
|
// 2: it is capped PER-ISSUE, NOT globally — the NOT EXISTS guard keeps exactly
|
|
// ONE unacknowledged row per (reason, user_id) (the booking is unresolvable at
|
|
// re-issue time), so one customer's alert is never suppressed by other users'
|
|
// rows filling the 'critical_payment_log' bucket, and the count-then-insert is
|
|
// atomic (a single INSERT ... WHERE NOT EXISTS — no TOCTOU). It is a dedicated
|
|
// local insert rather than the sweep's insertCriticalPaymentNotification so
|
|
// that the money agent's upcoming GLOBAL cap on the sweep insert (finding 1)
|
|
// can never swallow this alert — a stranded customer must always surface.
|
|
// Best-effort: a failure logs and the caller's CRITICAL log line still fires.
|
|
func alertReissueFail(ctx context.Context, q db.Querier, userID string) {
|
|
tag, err := q.Exec(ctx, `
|
|
INSERT INTO admin_notifications (reason, user_id, created_at)
|
|
SELECT 'critical_payment_log', $1, NOW()
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM admin_notifications an
|
|
WHERE an.reason = 'critical_payment_log'
|
|
AND an.user_id = $1
|
|
AND an.acknowledged_at IS NULL
|
|
)
|
|
`, userID)
|
|
if err != nil {
|
|
log.Printf("2FA: failed to insert critical-payment admin notification for reissue failure (user=%s): %v", userID, err)
|
|
return
|
|
}
|
|
if tag.RowsAffected() > 0 {
|
|
log.Printf("2FA: inserted critical-payment admin notification for reissue failure (user=%s) — customer stranded after a failed fresh saved-card charge", userID)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
// COORDINATION NOTE (Round 2 Loop B finding 1) — the shared notification flood
|
|
// cap now lives in crussell/internal/adminnotify
|
|
// (MaxUnacknowledgedCriticalLogs = 100 + CriticalLogsCapExceeded), applied
|
|
// ATOMICALLY (a conditional `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...)
|
|
// < $cap`) at every insert site this finding calls out. Status of each site:
|
|
//
|
|
// - THIS package's reissue-fail alert (reissueTwoFACodeAfterFailedCharge):
|
|
// capped PER-ISSUE instead (Round 2 Loop B finding 2) — the local
|
|
// alertReissueFail helper dedups atomically on (reason, user_id), so one
|
|
// customer's alert is never suppressed by other users' rows. NOT globally
|
|
// capped, and deliberately decoupled from sweep's generic insert so the
|
|
// money agent's global cap there can never swallow it.
|
|
//
|
|
// - auth/jwt.go VerifyRefreshToken's 'refresh_token_reuse' alert: NOW capped
|
|
// (Round 2 Loop B finding 1) — folded into its INSERT via the shared
|
|
// adminnotify cap.
|
|
//
|
|
// - handlers/webhooks/square.go (dispute/booking/unknown-event/orphan-replay)
|
|
// and handlers/user/account.go InsertSquareErasureCriticalNotification:
|
|
// NOW capped — same atomic fold.
|
|
//
|
|
// - handlers/payments/sweep.go:1556 insertCriticalPaymentNotification (the
|
|
// MONEY agent): STILL NEEDS the fold. Its INSERT ... SELECT ... WHERE NOT
|
|
// EXISTS is the same unbounded-across-accounts shape. Add
|
|
// `AND (SELECT COUNT(*) FROM admin_notifications _an WHERE _an.reason =
|
|
// 'critical_payment_log' AND _an.acknowledged_at IS NULL) < $N` (N =
|
|
// adminnotify.MaxUnacknowledgedCriticalLogs) to its WHERE clause.
|
|
//
|
|
// - handlers/scheduling/time-blockers.go:511 and internal/jobs/cleanup.go:333
|
|
// (jobs/scheduling agent): same shape on the GDPR-cleanup and log-scan
|
|
// paths — apply the identical fold.
|
|
//
|
|
// - main.go has NO admin_notifications insert sites (it only mounts the
|
|
// notification read/ack routes), so nothing to cap there.
|
|
//
|
|
// Fail-closed behaviour (a REFUSED re-issue still CRITICAL-logs and leaves the
|
|
// operator to mint manually) is unchanged — only the notification INSERT is
|
|
// bounded.
|