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:
@@ -11,12 +11,14 @@
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -63,19 +65,53 @@
|
||||
// reactive flag is checked synchronously at the start of every handler.
|
||||
let isProcessingPaymentSync = false;
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
|
||||
// customer's saved card online (the admin's own 2FA status gates it). The
|
||||
// card-machine and new-card paths have their own SCA.
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
// B6/B10: charging a customer's saved card 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.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
|
||||
// B6/B10: verification code for the customer's saved-card charge, collected
|
||||
// on the saved-card 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);
|
||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
||||
|
||||
// B3: pence already paid against this booking. The AppointmentInfo handed in
|
||||
// by /api/admin/today/current-next carries no amount_paid/amount_due/
|
||||
// payments, so this is fetched fresh from the admin booking detail endpoint
|
||||
// on mount and subtracted from the charge (see netTotal).
|
||||
let amountPaidPence = $state(0);
|
||||
async function fetchAmountPaid() {
|
||||
try {
|
||||
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (typeof data.amount_paid === 'number') {
|
||||
amountPaidPence = Math.round(data.amount_paid * 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// fall through to the booking prop below
|
||||
}
|
||||
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
|
||||
}
|
||||
|
||||
const loyaltyEligible = $derived(
|
||||
stamps >= 10 &&
|
||||
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
|
||||
booking.total_amount > 0 &&
|
||||
booking.amount_paid === 0
|
||||
amountPaidPence === 0
|
||||
);
|
||||
|
||||
const loyaltyDiscount = $derived(
|
||||
@@ -199,22 +235,19 @@
|
||||
// so the charge must be the subtotal minus already-applied discounts minus
|
||||
// the eligible campaign credit — otherwise the customer is overcharged.
|
||||
//
|
||||
// NOTE (round-A-4 UX residual, deliberately NOT "fixed"): this ignores
|
||||
// payments already made against the booking. The admin backend path
|
||||
// (CreateTerminalPayment) treats the amount it receives as the charge to
|
||||
// record verbatim — it does NOT compute "remaining due" and subtract prior
|
||||
// payments server-side — and the booking object handed to this modal (from
|
||||
// /api/admin/today/current-next, AppointmentInfo) carries no amount_paid /
|
||||
// amount_due / payments fields to derive them client-side. Subtracting an
|
||||
// unverifiable prior-paid total would risk under-collecting. When a deposit
|
||||
// was already paid, charging the full subtotal here is money-safe server-side
|
||||
// (buildSplitRecords/buildTerminalSplitRecords carve any excess beyond the
|
||||
// remaining booking value into a payment_type='tip' record, so the ledger
|
||||
// still closes exactly at the booking total) but the excess lands as an
|
||||
// UNINTENDED tip. Revisit when the today endpoint exposes the booking's paid
|
||||
// total: netTotal = max(0, subtotal − discountSum − campaignDiscountPence − amountPaidPence).
|
||||
// B3: prior payments are also subtracted. The booking object handed to this
|
||||
// modal (from /api/admin/today/current-next, AppointmentInfo) carries no
|
||||
// amount_paid/amount_due/payments, so on mount the modal fetches the
|
||||
// authoritative paid total from GET /api/admin/bookings/{id} (full Booking
|
||||
// shape, admin-accessible) and charges only the remaining obligation. The
|
||||
// backend money agent clamps the booking portion of a payment to the
|
||||
// remaining value, so the frontend charge and the backend record now agree
|
||||
// and a prior deposit can no longer land as an unintended tip.
|
||||
const netTotal = $derived(
|
||||
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview))
|
||||
Math.max(
|
||||
0,
|
||||
subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence
|
||||
)
|
||||
);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
@@ -414,6 +447,7 @@
|
||||
// customer flow (UserPaymentModal) so the admin modal charges the same
|
||||
// discounted amount the backend will auto-apply.
|
||||
onMount(async () => {
|
||||
fetchAmountPaid();
|
||||
try {
|
||||
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
|
||||
if (resp.ok) {
|
||||
@@ -680,10 +714,6 @@
|
||||
|
||||
async function handleSavedCardPayment() {
|
||||
if (isProcessingPaymentSync) return;
|
||||
if (twoFactorBlocksSavedCards) {
|
||||
toast.error('Two-factor authentication is required to use online card payments');
|
||||
return;
|
||||
}
|
||||
if (!selectedSavedCardId) {
|
||||
toast.error('Please select a saved card');
|
||||
return;
|
||||
@@ -707,7 +737,7 @@
|
||||
savedCardKeyedCardId !== selectedSavedCardId ||
|
||||
savedCardKeyedAmount !== chargeAmount
|
||||
) {
|
||||
savedCardIdempotencyKey = crypto.randomUUID();
|
||||
savedCardIdempotencyKey = generateUUID();
|
||||
savedCardKeyedBookingId = booking.id;
|
||||
savedCardKeyedCardId = selectedSavedCardId;
|
||||
savedCardKeyedAmount = chargeAmount;
|
||||
@@ -730,6 +760,7 @@
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
})
|
||||
@@ -757,6 +788,8 @@
|
||||
// charge gets a fresh UUID and can't be deduped against this one.
|
||||
savedCardIdempotencyKey = '';
|
||||
savedCardKeyedAmount = 0;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
toast.success('Saved card payment successful');
|
||||
onComplete(paymentResult);
|
||||
} catch (_err) {
|
||||
@@ -768,6 +801,10 @@
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
if (isSavedCardVerificationRequired(responseStatus, true))
|
||||
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(msg);
|
||||
} finally {
|
||||
@@ -776,13 +813,6 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// PSD2 SCA stand-in: if 2FA gating becomes active mid-modal, bail out
|
||||
// of the saved-card screen back to method selection.
|
||||
if (selectedMethod === 'savedcard' && twoFactorBlocksSavedCards) {
|
||||
selectedMethod = null;
|
||||
status = 'idle';
|
||||
return;
|
||||
}
|
||||
if (selectedMethod === 'cash') {
|
||||
cashAmount = totalDue.toFixed(2);
|
||||
extraAsTip = false;
|
||||
@@ -935,10 +965,19 @@
|
||||
|
||||
{#if nothingToCharge}
|
||||
<p class="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
The booking is fully covered by discounts — nothing to charge.
|
||||
Nothing to charge — the booking is fully covered by discounts or prior payments.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if amountPaidPence > 0}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
|
||||
>
|
||||
<span class="text-sm font-medium text-green-800">Already paid</span>
|
||||
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
||||
{#each discountPreview.discounts as d (d.name)}
|
||||
@@ -1022,7 +1061,7 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1078,19 +1117,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if twoFactorBlocksSavedCards && savedCards.length > 0}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm 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
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-3 sm:hidden">
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1436,12 +1464,16 @@
|
||||
</div>
|
||||
{/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={true} />
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="flex-1"
|
||||
disabled={!selectedSavedCardId || nothingToCharge}
|
||||
disabled={!selectedSavedCardId || nothingToCharge || missing2FACode}
|
||||
>
|
||||
Charge Saved Card
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user