Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed: MONEY: - CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) — a rejected auto-refund no longer re-replays the expired key every sweep run (which minted a stacking unauthorized charge each time); FAILED-webhook demotion respects the cap; never re-replay a key whose B1 refund failed - HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible campaign discount rows immediately (capped) instead of skipping with no discount recorded — no more promised-discount-not-recorded overcharge - MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed new-card+save_card charges (re-issue guard now covers req.SaveCard) - LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining now matches the authoritative tip-excluded balance) SECURITY: - MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap on critical_payment_log + refresh_token_reuse rows) - MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer clears LastMintAt on gate-verify; cleared on terminal charge success) - MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked state instead of a fresh 5-guess budget per request - MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs instead of sleeping unboundedly; login bcrypt concurrency semaphore added - LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check; email-verification per-user attempt counter DUP/MOD: - formatCurrency single source (frontend format.ts, 7 files consolidated); SquareRefundStatusToLocal single source (errors.go, all sites); admin audit-log helper dedup; SCA retry model unified (proactive on all 6 surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from backend; generateUUID at all card-form sites; magic numbers named (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card terminal charges now audited; DAV_SKIP_INIT documented in manuals Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet tags, frontend tests+build, env-docs 42/42.
453 lines
23 KiB
Go
453 lines
23 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 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 deduped 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)
|
|
// Finding 1 (Round 2 Loop A): bound the operator-facing flood. The
|
|
// deduped insert (sweep.go's insertCriticalPaymentNotification, keyed on
|
|
// reason/booking_id/user_id) is unbounded across attacker-registered
|
|
// accounts, so a hostile flood of failed re-issues could bury the
|
|
// single-operator notification centre. The global cap skips the insert —
|
|
// the CRITICAL log line above still fires, so no alert information is
|
|
// lost to the operator's log pipeline — once
|
|
// maxUnacknowledgedNotifications unacknowledged 'critical_payment_log'
|
|
// rows exist. Acknowledging rows re-arms inserts.
|
|
if notificationsCapExceeded(ctx, "critical_payment_log") {
|
|
log.Printf("2FA: critical-payment admin notification suppressed for user %s — %d unacknowledged 'critical_payment_log' notifications already exist; acknowledge outstanding notifications to re-arm", userID, maxUnacknowledgedNotifications)
|
|
return
|
|
}
|
|
insertCriticalPaymentNotification(ctx, nil, &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 {
|
|
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.
|
|
//
|
|
// 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. 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 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. That is the intended bounded
|
|
// behaviour; if immediate re-mint after a completed fresh charge is wanted,
|
|
// the money agent should clear the stamp there (twofa.ClearMintCooldownForUser
|
|
// or ConsumePendingCode) on terminal success.
|
|
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
|
|
|
|
// maxUnacknowledgedNotifications is the GLOBAL cap on unacknowledged
|
|
// admin_notifications rows for one reason (Round 2 Loop A finding 1). The
|
|
// dedup guards keyed on (reason, booking_id, user_id) are bounded per issue but
|
|
// UNBOUNDED across attacker-registered accounts, so a hostile flood could bury
|
|
// the single-operator notification centre. Insert sites check
|
|
// notificationsCapExceeded before inserting and skip the row (the CRITICAL log
|
|
// line still fires) once the unacknowledged queue for that reason is at the
|
|
// cap — the operator acknowledges rows to re-arm.
|
|
const maxUnacknowledgedNotifications = 100
|
|
|
|
// notificationsCapExceeded reports whether the number of unacknowledged
|
|
// admin_notifications rows for reason has reached maxUnacknowledgedNotifications.
|
|
// Best-effort and fail-OPEN: a count error is logged and the cap is NOT
|
|
// enforced (a money alert must never be dropped because the count query failed).
|
|
func notificationsCapExceeded(ctx context.Context, reason string) bool {
|
|
var n int
|
|
err := db.Conn.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM admin_notifications
|
|
WHERE reason = $1::admin_notification_reason AND acknowledged_at IS NULL
|
|
`, reason).Scan(&n)
|
|
if err != nil {
|
|
log.Printf("2FA: failed to count unacknowledged %s admin notifications: %v", reason, err)
|
|
return false
|
|
}
|
|
return n >= maxUnacknowledgedNotifications
|
|
}
|
|
|
|
// COORDINATION NOTE (Round 2 Loop A finding 1) — the cap pattern must be
|
|
// applied by the other two insert sites this finding calls out, which live in
|
|
// files owned by other agents:
|
|
//
|
|
// - handlers/payments/sweep.go:1414 insertCriticalPaymentNotification (the
|
|
// money agent): its INSERT ... WHERE NOT EXISTS dedup is keyed on (reason,
|
|
// booking_id, user_id) and is the same unbounded-across-accounts shape.
|
|
// Fold the global guard into the SELECT: `AND (SELECT COUNT(*) FROM
|
|
// admin_notifications WHERE reason = 'critical_payment_log' AND
|
|
// acknowledged_at IS NULL) < 100`.
|
|
//
|
|
// - auth/jwt.go:666 VerifyRefreshToken's 'refresh_token_reuse' alert (the
|
|
// auth agent): dedups on (reason, user_id) only, so a single attacker
|
|
// replaying MANY rotated families can flood the same single-operator
|
|
// centre. Apply the identical cap for reason 'refresh_token_reuse' (its
|
|
// insert is also `INSERT ... SELECT ... WHERE NOT EXISTS`, so the same
|
|
// COUNT subquery folds in).
|
|
//
|
|
// This helper lives in payments/twofa.go because that is where this task's
|
|
// reissue-fail alert (reissueTwoFACodeAfterFailedCharge, above) applies it; the
|
|
// other sites copy the pattern since the auth/jobs packages cannot import
|
|
// payments. 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.
|