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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user