diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx
index 462ad20f4..83c40103c 100644
--- a/client/src/components/Settings/AccountTab.tsx
+++ b/client/src/components/Settings/AccountTab.tsx
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
/>
setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
+ onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`}
diff --git a/client/src/mobile/screens/settings/MSettingsAccount.tsx b/client/src/mobile/screens/settings/MSettingsAccount.tsx
index 953a0c794..82fa481f9 100644
--- a/client/src/mobile/screens/settings/MSettingsAccount.tsx
+++ b/client/src/mobile/screens/settings/MSettingsAccount.tsx
@@ -244,9 +244,10 @@ export default function MSettingsAccount() {
/>
setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
+ onChange={(e) => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
placeholder={t('settings.mfa.codePlaceholder')}
/>
matchBackupCode(tokenStr, hash));
+ if (!okTotp && !okRecovery) {
throw new DomainError(401, 'Invalid verification code');
}
+ // Disabling MFA clears the old secret and *all* backup codes atomically.
+ // A recovery code used here cannot be replayed after re-enrolment.
// Every other session ends with the second factor; this one just proved both.
await this.uow.transactional(async () => {
await this.usersRepo.disableMfa(userId);
diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts
index dcd8775a9..99b8c9230 100644
--- a/server/tests/integration/auth.test.ts
+++ b/server/tests/integration/auth.test.ts
@@ -517,6 +517,39 @@ describe('MFA', () => {
expect(disableRes.status).toBe(200);
expect(disableRes.body.mfa_enabled).toBe(false);
});
+
+ it('AUTH-019 — disable MFA with password and unused backup code', async () => {
+ const { hashBackupCodeBcrypt } = await import('../../src/nest/auth/auth.helpers');
+ const { user, password } = createUserWithMfa(testDb);
+ await updateRows(orm, Users, { id: user.id }, {
+ mfa_backup_codes: JSON.stringify([hashBackupCodeBcrypt('A1B2-C3D4')]),
+ });
+ const res = await request(app).post('/api/auth/mfa/disable')
+ .set('Cookie', authCookie(user.id))
+ .send({ password, code: 'a1b2-c3d4' });
+ expect(res.status).toBe(200);
+ expect(res.body.mfa_enabled).toBe(false);
+ const updated = testDb.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes FROM users WHERE id = ?').get(user.id) as any;
+ expect(updated.mfa_enabled).toBe(0);
+ expect(updated.mfa_secret).toBeNull();
+ expect(updated.mfa_backup_codes).toBeNull();
+ });
+
+ it('AUTH-019 — reject invalid recovery code and incorrect password', async () => {
+ const { hashBackupCodeBcrypt } = await import('../../src/nest/auth/auth.helpers');
+ const { user, password } = createUserWithMfa(testDb);
+ await updateRows(orm, Users, { id: user.id }, {
+ mfa_backup_codes: JSON.stringify([hashBackupCodeBcrypt('A1B2-C3D4')]),
+ });
+ const badCode = await request(app).post('/api/auth/mfa/disable')
+ .set('Cookie', authCookie(user.id))
+ .send({ password, code: 'FFFF-FFFF' });
+ expect(badCode.status).toBe(401);
+ const badPassword = await request(app).post('/api/auth/mfa/disable')
+ .set('Cookie', authCookie(user.id))
+ .send({ password: 'invalid-password', code: 'A1B2-C3D4' });
+ expect(badPassword.status).toBe(401);
+ });
});
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts
index d6016097d..624091a58 100644
--- a/shared/src/i18n/en/settings.ts
+++ b/shared/src/i18n/en/settings.ts
@@ -350,7 +350,7 @@ const settings: TranslationStrings = {
'settings.mfa.enable': 'Enable 2FA',
'settings.mfa.cancelSetup': 'Cancel',
'settings.mfa.disableTitle': 'Disable 2FA',
- 'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.',
+ 'settings.mfa.disableHint': 'Enter your account password and a current authenticator code or an unused backup code.',
'settings.mfa.disable': 'Disable 2FA',
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',