Loop B restart (money/security/dup-mod adversarial) fixes: - CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows) - HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows - MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard - MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400 - MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued) - MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited - MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity - LOW-1: logout scoped to the presented token's family (no cross-session kill) - LOW-2: refresh-reuse grace widened for same-IP replays - LOW-4: squareEnvironmentMismatch enforced for empty env - LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID) - Cash/giftcard tip-enabled overflow mirrors the card-terminal carve 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
168 lines
7.7 KiB
Go
168 lines
7.7 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"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 (MEDIUM-2 — the saved-card CHARGE gates pass false and
|
|
// defer consumption to the completed-charge transaction via
|
|
// twofa.ConsumePendingCode, so a failed Square charge does not burn the code;
|
|
// the save-card SAVE gate passes 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)
|
|
}
|
|
|
|
// requireTwoFactorForCardAccess gates the saved-card online payment paths
|
|
// (PSD2 SCA stand-in until real SCA infra lands). It returns true when the
|
|
// request may proceed:
|
|
//
|
|
// - 2FA is not enforced (dev/mock), OR
|
|
// - 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
|
|
// — the save-card SAVE gate) or left intact for the caller to consume when its
|
|
// operation reaches a terminal success state (consume=false — the saved-card
|
|
// CHARGE gates; see verifyPendingTwoFactorCode / twofa.ConsumePendingCode,
|
|
// MEDIUM-2). 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 false is returned — the caller must abort the charge.
|
|
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
|
|
if !twoFactorEnforced() {
|
|
return true
|
|
}
|
|
if service == nil {
|
|
service = &PaymentService{}
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
// 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
|
|
}
|
|
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
|
|
case err == nil:
|
|
return true
|
|
case errors.Is(err, twofa.ErrIncorrect):
|
|
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
|
|
return false
|
|
case errors.Is(err, twofa.ErrLockedOut):
|
|
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
|
|
return false
|
|
case errors.Is(err, twofa.ErrMissingOrExpired):
|
|
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
|
|
return 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
|
|
}
|
|
}
|