fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
@@ -118,14 +118,46 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
|
||||
export const SAVED_CARD_VERIFICATION_MESSAGE =
|
||||
'Your card issuer requires verification. Please pay with a new card or re-add your card.';
|
||||
|
||||
/**
|
||||
* B6/B10: whether charging a saved card requires the customer's current 2FA
|
||||
* verification code. True when the session user has `twoFactorRequired` set —
|
||||
* the profile exposes the backend's `twoFactorEnforced()` posture, so this is
|
||||
* true for every session user in an enforced environment. Charge surfaces show
|
||||
* the verification-code input whenever this is set; the backend additionally
|
||||
* 403s saved-card charges when the code is missing, so surfaces must also
|
||||
* reveal the input on that error. Single source of truth so the booking,
|
||||
* account, tip and admin surfaces can't drift on the gate condition.
|
||||
*/
|
||||
export function requires2FACodeForSavedCard(
|
||||
sessionUser: { twoFactorRequired?: boolean } | null | undefined
|
||||
): boolean {
|
||||
return !!sessionUser?.twoFactorRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a saved-card charge error is a 2FA verification-gate rejection
|
||||
* (backend/handlers/payments/twofa.go): 403 when no code was supplied or the
|
||||
* card owner hasn't enabled 2FA, 429 when the brute-force lockout tripped, and
|
||||
* 400 for an invalid or expired code. All are recoverable by entering the
|
||||
* customer's CURRENT code (a lockout invalidates the pending code, so a fresh
|
||||
* code must be requested). Callers keep the verification-code input populated
|
||||
* and surfaced on these statuses.
|
||||
*/
|
||||
export function isTwoFactorVerificationGateFailure(status: number, message: string): boolean {
|
||||
if (status === 403 || status === 429) return true;
|
||||
if (status !== 400) return false;
|
||||
return /invalid verification code|verification code expired/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error code the booking-payment endpoint (POST /api/bookings/{id}/payment)
|
||||
* returns with a 400 when a payment would exceed the booking's remaining
|
||||
* balance BEFORE the appointment has started. buildSplitRecords records any
|
||||
* balance without `confirm_overflow_tip`. buildSplitRecords records any
|
||||
* overflow beyond the booking total as a tip — but a tip is gratuity for
|
||||
* service already rendered, so the backend refuses to silently convert an
|
||||
* unconfirmed pre-start overpayment into a tip (see CreateBookingPayment).
|
||||
* The frontend must surface a Confirm/Cancel prompt and resend the SAME
|
||||
* unconfirmed overpayment into a tip (see CreateBookingPayment). The guard
|
||||
* applies both BEFORE and AFTER the appointment has started (B12); the
|
||||
* frontend must surface a Confirm/Cancel prompt and resend the SAME
|
||||
* request with `confirm_overflow_tip: true` on confirm. This fires mainly on
|
||||
* stale booking data (multi-tab, admin-changed totals, refunds that reopened
|
||||
* capacity), so the response body carries no amount — the caller computes the
|
||||
|
||||
Reference in New Issue
Block a user