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:
2026-08-22 00:34:50 +01:00
parent faceb9809c
commit fe88f2084d
55 changed files with 4180 additions and 971 deletions
+35 -3
View File
@@ -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