mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(auth): allow backup codes for TOTP recovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user