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:
@@ -7,9 +7,13 @@
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -103,19 +107,31 @@
|
||||
})
|
||||
);
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
|
||||
// customer's saved card online. Cash, card machine, and online (new-card
|
||||
// nonce) payments are unaffected.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
// B6/B10: charging a customer's saved card via the till requires the
|
||||
// customer's current 2FA verification code when the backend enforces the
|
||||
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
|
||||
// surfaced whenever the gate is enforced — the operator relays the
|
||||
// customer's code. Cash, card machine, and online (new-card nonce) payments
|
||||
// are unaffected.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
|
||||
// B6/B10: verification code for the customer's saved-card till charge,
|
||||
// collected on the saved-card payment screen. Kept populated across retries
|
||||
// so an invalid/expired code can be corrected without re-typing it. The
|
||||
// admin always supplies the CUSTOMER's code — the admin's own 2FA flag is
|
||||
// irrelevant to the backend gate.
|
||||
let twoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code — reveals the input even
|
||||
// if the session user's flag is unset.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && paymentMethod === 'saved_card')
|
||||
);
|
||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
||||
|
||||
// The saved-card option is hidden outright unless a customer is selected
|
||||
// AND has at least one currently-valid card on file AND 2FA gating is not
|
||||
// active.
|
||||
const showSavedCardOption = $derived(
|
||||
selectedCustomer !== null && validCards.length > 0 && !twoFactorBlocksSavedCards
|
||||
);
|
||||
// AND has at least one currently-valid card on file.
|
||||
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
|
||||
|
||||
const availablePaymentMethods = $derived(
|
||||
PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption)
|
||||
@@ -285,10 +301,6 @@
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (paymentMethod === 'saved_card' && twoFactorBlocksSavedCards) {
|
||||
toast.error('Two-factor authentication is required to use online card payments');
|
||||
return;
|
||||
}
|
||||
if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) {
|
||||
toast.error('Select a customer and a saved card before charging');
|
||||
return;
|
||||
@@ -296,6 +308,7 @@
|
||||
isProcessingPaymentSync = true;
|
||||
processing = true;
|
||||
paymentError = null;
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
// One sale per cart line × quantity — each till sale funds its own
|
||||
// gift card (the backend only accepts item_type 'gift_card').
|
||||
@@ -312,6 +325,9 @@
|
||||
if (paymentMethod === 'saved_card') {
|
||||
body.user_id = selectedCustomer?.id;
|
||||
body.user_saved_card_id = selectedSavedCardId;
|
||||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||||
// verification code when the gate is enforced.
|
||||
if (show2FACodeInput) body.verification_code = twoFactorCode;
|
||||
} else if (paymentMethod === 'online_square') {
|
||||
if (!onlineSquareCardInput) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
@@ -338,6 +354,7 @@
|
||||
})
|
||||
);
|
||||
if (!res.ok) {
|
||||
responseStatus = res.status;
|
||||
const errText = await res.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
}
|
||||
@@ -350,8 +367,14 @@
|
||||
toast.success('Sale complete');
|
||||
cart = [];
|
||||
idempotencyKeys.clear();
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
// 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 sale can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -666,13 +689,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if twoFactorBlocksSavedCards}
|
||||
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if paymentMethod === 'online_square'}
|
||||
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||||
{#if isSquareConfigured()}
|
||||
@@ -755,6 +771,11 @@
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- B6/B10: saved-card till charges require the customer's
|
||||
current 2FA verification code when the backend enforces
|
||||
the gate. -->
|
||||
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -777,6 +798,7 @@
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
missing2FACode ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user