From a36019b7e26a424a60efc803a2f9e4c5b36e5c3e Mon Sep 17 00:00:00 2001 From: Jens Kowalsky Date: Wed, 14 Oct 2026 22:13:52 +0200 Subject: [PATCH] fix(auth): allow backup codes for TOTP recovery --- client/src/components/Settings/AccountTab.tsx | 5 +-- .../screens/settings/MSettingsAccount.tsx | 5 +-- server/src/nest/auth/auth.service.ts | 13 ++++++-- server/tests/integration/auth.test.ts | 33 +++++++++++++++++++ shared/src/i18n/en/settings.ts | 2 +- 5 files changed, 50 insertions(+), 8 deletions(-) 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',