Files
Crussell/backend/handlers/payments/twofa.go
T
popertots 4d5d2cd381 fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX
Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
2026-08-22 00:34:50 +01:00

157 lines
6.9 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 is passed so a verified code is
// SINGLE-USE: the gate NULLs the pending code on success, so one code
// authorizes exactly one saved-card charge (not unlimited charges for its
// 10-minute lifetime). 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) error {
return twofa.VerifyForUser(ctx, userID, code, true)
}
// 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).
//
// 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) 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); {
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
}
}