fix: 2FA disable now requires the verification code (enforced mode)

The disable flow was broken in enforced (production) mode: the account page
posted {code:''} to /api/user/2fa/disable, but the backend mints + validates a
code when 2FA is enforced, so the empty code always failed with 400 and a user
could never disable 2FA through the UI. In unenforced (dev/mock) mode the
backend short-circuits and no code is needed — which is why the user only saw
the confirmation dialog and no code prompt.

Backend:
- New POST /api/user/2fa/disable/code (SendDisableCodeHandler): mints +
  delivers a fresh disable-flow code via the existing ensurePendingTwoFACode
  machinery (per-user 1-min mint cooldown, 429 when throttled, 5-attempt
  lockout preserved on the disable call itself). This is the disable-flow
  equivalent of /api/user/2fa/setup. Runs unconditionally (no dev
  short-circuit) so the step is exercisable in dev too. Route mounted in
  main.go beside the other 2FA routes.
- Tests: mints fresh code, reuses valid pending code (hash unchanged),
  mint-throttled 429 (after the pending code is dropped, as a lockout does),
  unauthorized 401, unenforced still mints.

Frontend (account page):
- The disable confirmation now branches on twoFactorRequired: enforced →
  POST /api/user/2fa/disable/code to mint, then a 6-digit code-entry input +
  'Confirm Disable' button that posts the code to /api/user/2fa/disable;
  unenforced (dev) → unchanged direct disable. Code entry mirrors the enable
  flow's input styling; mint-throttle 429 / wrong-code 400 / lockout 429 all
  surface as toasts with the entry kept open for retry.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (all 20
packages ok, 0 failures incl. 5 new 2FA tests), go build ./... and -tags dev,
go vet clean, svelte-check 0 errors 0 warnings, env-docs gate OK.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 6691cd5657
commit 9111258461
4 changed files with 216 additions and 1 deletions
+79 -1
View File
@@ -558,6 +558,10 @@
let twoFADisabling = $state(false);
let selectedTwoFA = $state<'none' | 'email' | 'sms'>('none');
let showDisableTwoFADialog = $state(false);
let showDisableCodeEntry = $state(false);
let twoFADisableCode = $state('');
let twoFASendingDisableCode = $state(false);
let twoFADisableConfirming = $state(false);
// Locally-selected 2FA state, behaving as a radio group: none, email, or sms —
// never both. Initialised from the saved profile state so the toggles reflect
@@ -677,7 +681,60 @@
async function confirmDisableTwoFA() {
showDisableTwoFADialog = false;
await disableTwoFA();
if (authStore.currentUser?.twoFactorRequired) {
await sendDisableCode();
} else {
await disableTwoFA();
}
}
// Enforced environments require a verification code before 2FA can be
// disabled. Mint one first (the backend mints + delivers it), then show
// the code-entry step.
async function sendDisableCode() {
twoFASendingDisableCode = true;
try {
const res = await apiFetch('/api/user/2fa/disable/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
showDisableCodeEntry = true;
twoFADisableCode = '';
toast.success('Verification code sent');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to send verification code');
}
} catch {
toast.error('Network error');
} finally {
twoFASendingDisableCode = false;
}
}
async function confirmDisableWithCode() {
twoFADisableConfirming = true;
try {
const res = await apiFetch('/api/user/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFADisableCode })
});
if (res.ok) {
showDisableCodeEntry = false;
twoFADisableCode = '';
await authStore.refreshProfile();
toast.success('Two-factor authentication disabled');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to disable two-factor authentication');
}
} catch {
toast.error('Network error');
} finally {
twoFADisableConfirming = false;
}
}
// Image cropper state
@@ -2671,6 +2728,27 @@
</div>
{/if}
{#if showDisableCodeEntry}
<div class="mt-3">
<p class="mb-2 text-sm text-gray-600">
Enter the verification code to disable two-factor authentication.
</p>
<div class="flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFADisableCode}
disabled={twoFADisableConfirming}
/>
<Button disabled={twoFADisableConfirming} onclick={confirmDisableWithCode}>
{twoFADisableConfirming ? 'Disabling...' : 'Confirm Disable'}
</Button>
</div>
</div>
{/if}
{#if twoFADirty && !twoFASetupPending}
<Button
class="mt-3"