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
@@ -36,12 +36,14 @@
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
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 { POLICY } from '$lib/constants/policy';
import {
canSaveCardsForRole,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry
} from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
@@ -144,10 +146,30 @@
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
// 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 deposits (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);
// B6/B10: verification code for a saved-card deposit / 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 depositTwoFactorCode = $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.
let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(
reveal2FACodeInput ||
(savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard))
);
const missing2FACode = $derived(
show2FACodeInput && twoFactorEnabled && depositTwoFactorCode.trim() === ''
);
const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -319,12 +341,6 @@
isProcessingPayment = true;
paymentAttempted = false;
try {
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
// but not enabled — clear any stale selection so the new-card
// (nonce) path is used instead.
if (twoFactorBlocksSavedCards && selectedPaymentMethod) {
selectedPaymentMethod = '';
}
// Create the booking only if one does not already exist. A retry after
// a failed deposit charge (or a lost response) must NOT re-create a
// booking — the existing confirmedBooking is the one to charge, and
@@ -349,8 +365,10 @@
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedPaymentMethod && !twoFactorBlocksSavedCards) {
// saved card — nothing to tokenize
if (selectedPaymentMethod) {
// saved card — nothing to tokenize; B6/B10 requires the customer's
// current 2FA verification code (collected in the charge form)
// when the backend enforces the gate.
} else if (paymentCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the
@@ -412,7 +430,8 @@
idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: depositTwoFactorCode } : {})
};
paymentAttempted = true;
@@ -483,6 +502,8 @@
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
depositTwoFactorCode = '';
reveal2FACodeInput = false;
overflowConfirm = null;
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
@@ -499,6 +520,12 @@
}
const text = await response.text();
// 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 deposit can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
reveal2FACodeInput = true;
}
// Pre-start overpayment guard on stale booking data: park the rejected
// request (body + amount) and surface the Confirm/Cancel prompt instead
// of a dead-end 400. The cached nonce + SCA verification token +
@@ -2634,33 +2661,44 @@
/>
</div>
{/if}
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
<!-- B6/B10: saved-card deposits require the customer's
current 2FA verification code when the backend
enforces the gate. -->
<div class="mb-6">
<TwoFactorCodeInput
bind:code={depositTwoFactorCode}
showInput={show2FACodeInput}
enabled={twoFactorEnabled}
/>
</div>
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid || missing2FACode}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
<p class="mt-4 text-center text-xs text-gray-500">
Secure payment powered by Square