fix(auth): allow backup codes for TOTP recovery

This commit is contained in:
Jens Kowalsky
2026-10-14 22:13:52 +02:00
committed by Maurice
parent bda24ec639
commit a36019b7e2
5 changed files with 50 additions and 8 deletions
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
/>
<input
type="text"
inputMode="numeric"
inputMode="text"
autoComplete="one-time-code"
value={mfaDisableCode}
onChange={e => 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`}
@@ -244,9 +244,10 @@ export default function MSettingsAccount() {
/>
<MSetInput
className="mt-2"
inputMode="numeric"
inputMode="text"
autoComplete="one-time-code"
value={mfaDisableCode}
onChange={(e) => 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')}
/>
<MSetButton
+10 -3
View File
@@ -871,11 +871,18 @@ export class AuthService {
throw new DomainError(401, 'Incorrect password');
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret });
if (!ok) {
const tokenStr = String(code).trim();
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
// Recovery codes already work at login. Accept them here too, but only
// after the account password has been verified and while the existing
// rate limit / instance MFA policy allow this operation.
const okRecovery = !okTotp && parseBackupCodeHashes(user.mfa_backup_codes)
.some((hash) => 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);
+33
View File
@@ -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);
});
});
// ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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',