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