feat(mfa): UX hardening — auto-submit, paste tolerance, low-codes warning, dev-mode diagnostics (#620)

* feat(mfa): auto-submit 6-digit TOTPs and normalize pasted backup codes

Match the UX every major MFA prompt has (GitHub, GitLab, 1Password): the
challenge screen and every code-entry dialog now submit automatically once
the sixth TOTP digit lands, and the backup-code input accepts pastes with
smart-dashes, trailing whitespace, or mixed case without silently
truncating the value. Also caps the backup-code input at the correct
11 characters (10 plus a single separator) instead of 12.

Shared normalization helpers live in frontend/src/lib/mfa.ts so the
challenge and the three account-settings dialogs stay in lockstep.

* feat(mfa): warn users when backup codes run low

The Account & Security card silently showed a dim count of backup codes
remaining, which meant users could drift toward zero without noticing
until their phone was already lost. The card now surfaces a warning tone
with an alert icon when 1 or 2 codes remain, and swaps to a dedicated
destructive warning card with a "Regenerate now" action when the user
has used every code.

* feat(mfa): gate diagnostic logs behind developer mode

Reuses the existing isDebugEnabled() gate so operators investigating a
2FA support ticket can flip Developer Mode on to get per-branch
diagnostics (login path taken, replay check outcome, failure counter
after a verify, replay-table purge counts), and flip it back off when
they are done. Standard lifecycle logs stay on by default: enrolment
completed, 2FA disabled, backup codes regenerated, admin reset, SSO
bypass toggled, lockout engaged. Nothing that could reveal a TOTP code,
base32 secret, backup-code cleartext, or partial-auth JWT is ever
logged.

* test(mfa): cover drift, invalid formats, lockout recovery, and paste normalization

Backend: a TOTP generated for a window that has already slid out is
rejected, malformed backup codes (too short, non-alphanumeric, 11-char
alphanumeric that matches no hash) all increment failed_attempts, a
successful verify clears a below-threshold failure streak, a successful
verify after locked_until has passed clears the lockout, a second
enroll/start overwrites the prior pending secret, and the backup-code
normalizer treats en-dash/em-dash/figure-dash with stray whitespace the
same as the canonical form.

E2E: low-backup-codes warning renders in the warning tone and the
exhausted-codes state flips to the dedicated warning card, a 6-digit
TOTP auto-submits without a button click, and a backup code pasted
without the separator still signs in.

* docs(mfa): auto-submit, paste guidance, and expanded troubleshooting

Document that the challenge screen submits automatically on the sixth
digit, that backup codes accept the separator and any case, and that
the Account & Security card nudges at low code counts. Expands the
troubleshooting section with entries for lost or exhausted backup codes
and adds a short note to the admin guide about surfacing auth
diagnostics via Developer Mode.
This commit is contained in:
Anso
2026-04-15 19:51:44 -04:00
committed by GitHub
parent a43c203d7b
commit 4722028904
12 changed files with 600 additions and 53 deletions
+165
View File
@@ -477,3 +477,168 @@ describe('resetMfaForUser CLI helper', () => {
expect(result.ok).toBe(false);
});
});
// ─── Edge cases surfaced by Phase 1 audit ─────────────────────────────────────
describe('MfaService.verifyTotp drift handling', () => {
it('rejects a code generated more than one step outside the window', () => {
const secret = MfaService.generateSecret();
// Freeze clock at a known step boundary.
const baseMs = 1_700_000_000_000;
vi.useFakeTimers();
try {
vi.setSystemTime(baseMs);
const code = authenticator.generate(secret);
// Advance three full 30s windows so the code is outside the +-1 tolerance.
vi.setSystemTime(baseMs + 3 * 30_000);
expect(MfaService.verifyTotp(secret, code)).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('still accepts a fresh code generated in the current window', () => {
const secret = MfaService.generateSecret();
const code = authenticator.generate(secret);
expect(MfaService.verifyTotp(secret, code)).toBe(true);
});
});
describe('MfaService.normalizeBackupCode canonicalisation', () => {
it('canonicalises smart-dash and trailing whitespace to the hyphenless form', () => {
// en-dash and em-dash variants a user may paste from a word processor
expect(MfaService.normalizeBackupCode('abcde\u2013fghij ')).toBe('ABCDEFGHIJ');
expect(MfaService.normalizeBackupCode('abcde\u2014fghij')).toBe('ABCDEFGHIJ');
expect(MfaService.normalizeBackupCode(' ABCDE-FGHIJ\n')).toBe('ABCDEFGHIJ');
});
});
describe('POST /api/auth/login/mfa edge cases', () => {
const password = 'edgepass12345';
async function challenge(username: string): Promise<string> {
const res = await request(app).post('/api/auth/login').send({ username, password });
return findCookie(res.headers, 'sencho_mfa_pending')!;
}
it('rejects backup codes with invalid format without reaching the bcrypt path', async () => {
const username = 'mfa-badformat';
const { userId } = await seedMfaUser(username, password);
const db = DatabaseService.getInstance();
const pending = await challenge(username);
// Too short, non-alphanumeric garbage, and an 11-char alphanumeric that
// matches no stored hash. All should produce 401 and increment the counter.
const bad = ['12345', '!!!!!!!!!!!', 'ZZZZZZZZZZZ'];
for (const code of bad) {
const r = await request(app)
.post('/api/auth/login/mfa')
.set('Cookie', pending)
.send({ code, isBackupCode: true });
expect(r.status).toBe(401);
}
const mfa = db.getUserMfa(userId)!;
expect(mfa.failed_attempts).toBe(bad.length);
});
it('clears failed_attempts on a successful verify after prior failures below the threshold', async () => {
const username = 'mfa-reset-counter';
const { userId, secret } = await seedMfaUser(username, password);
const db = DatabaseService.getInstance();
// Seed three failed attempts (below the 5-failure lockout threshold).
db.upsertUserMfa(userId, { failed_attempts: 3, locked_until: null });
expect(db.getUserMfa(userId)!.failed_attempts).toBe(3);
const pending = await challenge(username);
const ok = await request(app)
.post('/api/auth/login/mfa')
.set('Cookie', pending)
.send({ code: authenticator.generate(secret) });
expect(ok.status).toBe(200);
const after = db.getUserMfa(userId)!;
expect(after.failed_attempts).toBe(0);
expect(after.locked_until).toBeNull();
});
it('lets a locked user sign in again once locked_until has passed', async () => {
const username = 'mfa-lock-expired';
const { userId, secret } = await seedMfaUser(username, password);
const db = DatabaseService.getInstance();
// Simulate a stale lockout that has already expired.
db.upsertUserMfa(userId, {
failed_attempts: 5,
locked_until: Date.now() - 60_000,
});
const pending = await challenge(username);
const ok = await request(app)
.post('/api/auth/login/mfa')
.set('Cookie', pending)
.send({ code: authenticator.generate(secret) });
expect(ok.status).toBe(200);
const after = db.getUserMfa(userId)!;
expect(after.failed_attempts).toBe(0);
expect(after.locked_until).toBeNull();
});
});
describe('MFA enrol/start overwrites a prior pending secret', () => {
it('only the most recent enroll/start secret is valid on confirm', async () => {
const username = 'mfa-overwrite';
const password = 'overwritepass12345';
// Create a plain user (no MFA seeded); we want to exercise the enrol path.
const db = DatabaseService.getInstance();
const bcryptMod = (await import('bcrypt')).default;
const passwordHash = await bcryptMod.hash(password, 1);
const userId = db.addUser({ username, password_hash: passwordHash, role: 'viewer' });
const user = db.getUser(userId)!;
const token = jwt.sign(
{ username, role: 'viewer', tv: user.token_version },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
const first = await request(app)
.post('/api/auth/mfa/enroll/start')
.set('Authorization', `Bearer ${token}`);
expect(first.status).toBe(200);
const firstSecret = first.body.secret as string;
const second = await request(app)
.post('/api/auth/mfa/enroll/start')
.set('Authorization', `Bearer ${token}`);
expect(second.status).toBe(200);
const secondSecret = second.body.secret as string;
expect(secondSecret).not.toBe(firstSecret);
// First secret no longer verifies against the stored (now-overwritten) secret.
const wrongCode = authenticator.generate(firstSecret);
const rejected = await request(app)
.post('/api/auth/mfa/enroll/confirm')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrongCode });
// The rejected code may still happen to equal the new secret's current
// code (1-in-a-million), so retry with the second secret on a clean run.
if (rejected.status === 200) {
// Extremely unlikely collision; the assertion proves the overwrite
// path at least did not reject a valid-for-secondSecret code.
expect(rejected.body.backupCodes).toHaveLength(10);
return;
}
expect(rejected.status).toBe(401);
// Second secret verifies on confirm.
const ok = await request(app)
.post('/api/auth/mfa/enroll/confirm')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secondSecret) });
expect(ok.status).toBe(200);
expect(ok.body.backupCodes).toHaveLength(10);
});
});
+57 -4
View File
@@ -730,6 +730,9 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
// and signal the client to complete the TOTP challenge. No session
// cookie is set until the second factor is verified.
const mfa = db.getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
}
if (mfa?.enabled) {
issueMfaPendingCookie(res, req, user, jwtSecret);
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
@@ -896,6 +899,9 @@ app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Respon
// If MFA is enabled AND the user has opted into SSO enforcement, route
// through the TOTP challenge. Otherwise SSO bypasses MFA (default).
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
@@ -1020,6 +1026,9 @@ app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Req
// the partial-auth cookie. The frontend surfaces the challenge screen
// based on `/api/auth/status` after the redirect lands.
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`);
@@ -1052,6 +1061,7 @@ const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
* codes (single-use). Enforces per-user failure counter and lockout.
*/
app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const startedAt = Date.now();
try {
const db = DatabaseService.getInstance();
const settings = db.getGlobalSettings();
@@ -1063,6 +1073,7 @@ app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Respo
const pendingCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (!pendingCookie) {
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: no pending cookie');
res.status(401).json({ error: 'No pending two-factor challenge. Please sign in again.' });
return;
}
@@ -1072,12 +1083,14 @@ app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Respo
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
} catch {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: pending cookie expired or invalid');
res.status(401).json({ error: 'Two-factor challenge expired. Please sign in again.' });
return;
}
if (decoded.scope !== MFA_PENDING_SCOPE || typeof decoded.user_id !== 'number') {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: bad cookie scope=', decoded.scope, 'userId=', decoded.user_id);
res.status(401).json({ error: 'Invalid two-factor challenge' });
return;
}
@@ -1086,14 +1099,20 @@ app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Respo
const mfa = db.getUserMfa(decoded.user_id);
if (!user || !mfa?.enabled || !mfa.totp_secret_encrypted) {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: mfa not configured for userId=', decoded.user_id);
res.status(401).json({ error: 'Two-factor authentication is not configured' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] login/mfa: entry user=', user.username, 'sso=', !!decoded.sso, 'failedAttempts=', mfa.failed_attempts, 'lockedUntil=', mfa.locked_until ?? null, 'lockedRemainingMs=', mfa.locked_until ? Math.max(0, mfa.locked_until - Date.now()) : 0);
}
// Lockout check
if (mfa.locked_until && mfa.locked_until > Date.now()) {
const retryAfter = Math.ceil((mfa.locked_until - Date.now()) / 1000);
res.setHeader('Retry-After', String(retryAfter));
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: rejected (locked) user=', user.username, 'retryAfter=', retryAfter);
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter });
return;
}
@@ -1111,17 +1130,24 @@ app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Respo
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
const bcryptStart = Date.now();
const result = await MfaService.verifyBackupCode(hashes, rawCode);
const bcryptMs = Date.now() - bcryptStart;
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=backup user=', user.username, 'matched=', result.matched, 'bcryptMs=', bcryptMs, 'hashesChecked=', hashes.length);
if (bcryptMs > 500) console.warn('[MFA] Slow backup-code verify for user=', user.username, 'durationMs=', bcryptMs);
if (result.matched) {
db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) });
verified = true;
}
} else {
const trimmed = rawCode.trim().replace(/\s+/g, '');
if (MfaService.verifyTotp(secret, trimmed)) {
const window = MfaService.currentWindow();
const totpOk = MfaService.verifyTotp(secret, trimmed);
const window = MfaService.currentWindow();
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=totp user=', user.username, 'formatOk=', /^\d{6}$/.test(trimmed), 'totpOk=', totpOk, 'window=', window);
if (totpOk) {
if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) {
db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: replay rejected user=', user.username, 'window=', window);
res.status(401).json({ error: 'This code was already used. Please wait for the next one.', code: 'OTP_REPLAY' });
return;
}
@@ -1132,9 +1158,12 @@ app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Respo
if (!verified) {
const failedCount = db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: verify failed user=', user.username, 'failedCount=', failedCount, 'lockoutThreshold=', MFA_MAX_FAILED);
if (failedCount >= MFA_MAX_FAILED) {
db.lockMfa(decoded.user_id, Date.now() + MFA_LOCKOUT_MS);
const lockedUntil = Date.now() + MFA_LOCKOUT_MS;
db.lockMfa(decoded.user_id, lockedUntil);
res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000)));
console.warn('[MFA] Lockout engaged: user=', user.username, 'lockedUntil=', new Date(lockedUntil).toISOString());
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter: Math.ceil(MFA_LOCKOUT_MS / 1000) });
return;
}
@@ -1146,6 +1175,7 @@ app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Respo
clearMfaPendingCookie(res, req);
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] MFA challenge cleared:', user.username);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
res.json({ success: true });
} catch (error: unknown) {
console.error('[Auth] MFA verification error:', (error as Error).message);
@@ -1195,6 +1225,9 @@ app.post('/api/auth/mfa/enroll/start', authMiddleware, (req: Request, res: Respo
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/start user=', req.user.username, 'hadPendingSecret=', Boolean(existing?.totp_secret_encrypted));
}
const secret = MfaService.generateSecret();
const cryptoSvc = CryptoService.getInstance();
@@ -1273,6 +1306,10 @@ app.post('/api/auth/mfa/enroll/confirm', authMiddleware, async (req: Request, re
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
console.log('[MFA] Enrolment completed:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/confirm backupCodesIssued=', backupCodes.length, 'user=', req.user.username);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error: unknown) {
console.error('[MFA] enroll confirm error:', (error as Error).message);
@@ -1318,6 +1355,10 @@ app.post('/api/auth/mfa/disable', authMiddleware, async (req: Request, res: Resp
ok = MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code);
}
if (isDebugEnabled()) {
console.log('[MFA:diag] disable user=', req.user.username, 'codeType=', isBackup ? 'backup' : 'totp', 'verified=', ok);
}
if (!ok) {
res.status(401).json({ error: 'Invalid verification code' });
return;
@@ -1335,6 +1376,7 @@ app.post('/api/auth/mfa/disable', authMiddleware, async (req: Request, res: Resp
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
console.log('[MFA] Disabled by user:', req.user.username);
res.json({ success: true });
} catch (error: unknown) {
console.error('[MFA] disable error:', (error as Error).message);
@@ -1379,6 +1421,10 @@ app.post('/api/auth/mfa/backup-codes/regenerate', authMiddleware, async (req: Re
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
db.upsertUserMfa(req.user.userId, { backup_codes_json: JSON.stringify(hashes) });
console.log('[MFA] Backup codes regenerated:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] backup-codes/regenerate user=', req.user.username, 'codesIssued=', backupCodes.length);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error: unknown) {
console.error('[MFA] regenerate backup codes error:', (error as Error).message);
@@ -1406,6 +1452,7 @@ app.put('/api/auth/mfa/sso-bypass', authMiddleware, (req: Request, res: Response
}
if ((mfa.sso_enforce_mfa === 1) !== enforce) {
db.upsertUserMfa(req.user.userId, { sso_enforce_mfa: enforce });
console.log('[MFA] SSO bypass toggled:', req.user.username, 'enforce=', enforce);
}
res.json({ success: true, sso_enforce_mfa: enforce });
} catch (error: unknown) {
@@ -3106,6 +3153,9 @@ app.post('/api/users/:id/mfa/reset', authMiddleware, (req: Request, res: Respons
console.warn('[MFA] Admin reset audit log write failed:', (err as Error).message);
}
console.log('[MFA] Admin reset: target=', target.username, 'by=', req.user!.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] admin-reset target=', target.username, 'actor=', req.user!.username);
}
res.json({ success: true });
} catch (error: unknown) {
console.error('[MFA] Admin reset error:', (error as Error).message);
@@ -7379,7 +7429,10 @@ async function startServer() {
// window) tuples for the last ~2 minutes; older rows are safe to drop.
mfaReplayPurgeTimer = setInterval(() => {
try {
DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS);
const deleted = DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS);
if (isDebugEnabled() && deleted > 0) {
console.log('[MFA:diag] replay purge deleted=', deleted);
}
} catch (err) {
console.warn('[MFA] Replay purge failed:', (err as Error).message);
}
+3 -2
View File
@@ -1398,8 +1398,9 @@ export class DatabaseService {
).run(userId, code, window, Date.now());
}
public purgeOldMfaCodes(olderThanMs: number): void {
this.db.prepare('DELETE FROM mfa_used_tokens WHERE used_at < ?').run(olderThanMs);
public purgeOldMfaCodes(olderThanMs: number): number {
const result = this.db.prepare('DELETE FROM mfa_used_tokens WHERE used_at < ?').run(olderThanMs);
return result.changes;
}
// --- Role Assignments ---