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