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
@@ -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>