mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
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:
+57
-4
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user