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
@@ -8,6 +8,7 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
@@ -18,6 +19,7 @@
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
@@ -45,16 +47,38 @@
// the checkbox inside CardSelection; defaults to false (opt-in).
let saveCard = $state(false);
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
// and saving new cards for reuse. The new-card (nonce) path has its own
// SCA via Square tokenizeWithVerification.
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
// B6/B10: saved-card charges (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
// B6/B10: the verification code for a saved-card charge / new-card save. The
// backend requires the card owner's CURRENT one-time code when 2FA is
// enforced (delivered via the server log / email-SMS channel and relayed by
// the operator). Kept populated across retries so an invalid/expired code can
// be corrected without re-typing it.
let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code: the backend keys on the
// CARD OWNER, so even a session user whose own flag is unset must be able to
// enter the code. Revealing the input makes the failure recoverable.
let reveal2FACodeInput = $state(false);
// Show the code input whenever the pending charge hits the backend's 2FA
// gate: charging a saved card OR saving the new card for reuse.
const show2FACodeInput = $derived(
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
);
const missing2FACode = $derived(
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
);
// Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern.
@@ -163,11 +187,13 @@
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
// Pre-start overpayment confirmation. The backend rejects a payment that
// exceeds the booking's remaining balance before the appointment has
// started unless the request carries `confirm_overflow_tip: true` — a tip
// is gratuity for service already rendered. The frontend caps amounts at
// the remaining balance in normal flows, so this fires on STALE booking data
// Overpayment confirmation. The backend rejects a payment that exceeds the
// booking's remaining balance unless the request carries
// `confirm_overflow_tip: true` — a tip is gratuity for service already
// rendered. This guard now applies both before AND after the appointment
// has started (B12), so the prompt must fire in both states — it is keyed
// purely off the backend's `overflow_tip_confirmation_required` error code,
// never off booking state. It typically fires on STALE booking data
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
// the user would otherwise be stuck with an unresolvable 400. On the guard
// firing, the rejected request (amount, type, cached card tokens) is parked
@@ -384,9 +410,10 @@
let newCardToken: string | undefined;
let verificationToken: string | undefined;
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
// but not enabled — fall back to the new-card (nonce) path.
if (selectedCardId && !twoFactorBlocksSavedCards) {
// Saved-card charge: the card is used directly — B6/B10 requires the
// customer's current 2FA verification code (collected in the charge form)
// when the backend enforces the gate, but never blocks the selection.
if (selectedCardId) {
cardId = selectedCardId;
} else if (cardSelection) {
// New-card mode: tokenize once per attempt WITH SCA verification, then
@@ -489,6 +516,7 @@
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
idempotency_key: payIdempotencyKey
})
})
@@ -539,6 +567,8 @@
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
twoFactorCode = '';
reveal2FACodeInput = false;
paymentResult = {
id: data.id,
amount: data.amount,
@@ -560,6 +590,10 @@
// succeed. Surface the fix instead of the generic backend text.
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE;
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
error = msg;
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
// A definitive charge failure consumes the nonce + SCA verification
@@ -576,8 +610,9 @@
}
}
// Confirm the pre-start overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip.
// Confirm the overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
// both pre-start and post-start overflows (B12).
async function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || status === 'processing') return;
@@ -698,10 +733,11 @@
{#if overflowConfirm}
<div class="space-y-4">
<!-- Pre-start overpayment confirmation: the backend rejected the
payment because the booking's remaining balance has changed
since it was loaded (stale data). The excess over the
remaining balance will be recorded as a tip once confirmed. -->
<!-- Overpayment confirmation: the backend rejected the payment because
the booking's remaining balance has changed since it was loaded
(stale data). The excess over the remaining balance will be
recorded as a tip once confirmed. Applies both before and after
the appointment has started (B12). -->
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
@@ -958,6 +994,14 @@
{/if}
{/if}
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactorCode}
showInput={show2FACodeInput}
enabled={twoFactorEnabled}
/>
{#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p class="font-semibold text-amber-900">Cancellation &amp; Deposit Policy</p>
@@ -1002,7 +1046,7 @@
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
disabled={payButtonDisabled || missing2FACode}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
@@ -1083,7 +1127,7 @@
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
disabled={payButtonDisabled || missing2FACode}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid