Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
108 lines
3.9 KiB
Go
108 lines
3.9 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"crussell/db"
|
|
"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). 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()
|
|
}
|
|
|
|
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
|
|
// the dev/mock Square stack. Only these exact values are treated as dev; an
|
|
// empty or unknown value is NOT dev (fail-closed), because in production an
|
|
// unset/mistyped env var must never bypass the 2FA gate.
|
|
func IsExplicitDevOrMockEnv() bool {
|
|
switch os.Getenv("SQUARE_ENVIRONMENT") {
|
|
case "mock", "dev", "development", "test":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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).
|
|
//
|
|
// When 2FA is enforced and the user has not enabled it, a 403 JSON error 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 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 {
|
|
return true
|
|
}
|
|
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
|
return false
|
|
}
|