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) } // 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 // — 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 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 } }