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 ---
+29 -1
View File
@@ -60,14 +60,34 @@ After entering your password, Sencho shows the 2FA challenge screen.
1. Open your authenticator app, find the Sencho entry, read the six-digit code
2. Type it into the **Verification code** field
3. Click **Verify and sign in**
3. Sencho submits automatically once you enter the sixth digit. No click required.
<Note>
If you prefer the explicit route, the **Verify and sign in** button still works. The auto-submit only applies to the six-digit TOTP field; backup codes always require a click to confirm.
</Note>
If your phone is unavailable, click **Use a backup code instead**, enter one of the codes you saved during enrolment, and click **Verify and sign in**. That code is now used up.
### Backup code entry tips
Backup codes are shown grouped as `ABCDE-FGHIJ` to make them easier to read. When signing in, you can enter them any of these ways:
- Paste the code exactly as shown: `ABCDE-FGHIJ`
- Paste without the dash: `ABCDEFGHIJ`
- Paste with extra whitespace or lowercase letters; Sencho normalises the input before sending it to the server
Only letters and digits are significant; dashes, spaces, and case are ignored.
## Regenerate backup codes
If you think your backup codes have been exposed, or you have used most of them, regenerate them from **Settings → Account & Security → Regenerate backup codes**. Sencho asks for a current code, then issues a fresh set of ten and invalidates the old set immediately.
The Account & Security card nudges you about low code counts so you notice before you are locked out:
- **3 or more codes remaining:** a muted count under the status message.
- **1 or 2 codes remaining:** the count turns warning-coloured with an alert icon, inviting you to regenerate a fresh set.
- **0 codes remaining:** a dedicated warning card appears with a **Regenerate now** button. At this point, losing your authenticator app means recovery needs an administrator, so regenerate before that happens.
<Frame>
<img src="/images/two-factor-auth/account-card-enabled.png" alt="Account card showing 2FA enabled with regenerate and disable actions" />
</Frame>
@@ -111,6 +131,14 @@ Click **Can't scan? Show secret key** during enrolment, copy the base32 string,
Contact an administrator. An admin can reset your 2FA from the Users section in **Settings → Users**. See the [admin guide](/operations/two-factor-admin) for the steps. If you are the only admin and have lost access, the administrator can also reset 2FA from the command line on the host running Sencho.
### Lost your backup codes
If you still have your authenticator app, sign in as normal and regenerate the codes from **Settings → Account & Security → Regenerate backup codes**. The previous set stops working immediately. If you no longer have the authenticator app either, follow the "Lost phone and no backup codes left" entry above and ask an administrator to reset 2FA.
### Ran out of backup codes
Each backup code can be used once. As soon as you sign back in with an authenticator code, regenerate a new set from **Settings → Account & Security**. Without codes, losing your phone means recovery requires an administrator.
### SSO sign-in is unexpectedly asking for a code
The **Require 2FA even when signing in via SSO** toggle is on for your account. Open **Settings → Account & Security** and flip it off if SSO alone is enough for your threat model.
+4
View File
@@ -39,6 +39,10 @@ Replace `<username>` with the admin's account name. On success the command print
The command respects the container's `DATA_DIR`, so it always acts on the same SQLite database the application uses. It writes an audit log entry attributed to `cli` so the action is auditable after the fact.
<Note>
Turning on **Developer Mode** in **Settings → Developer** surfaces additional authentication diagnostics in the backend logs. These are helpful when investigating a 2FA support ticket, and can be turned off again once the issue is resolved.
</Note>
## Per-user SSO enforcement
When SSO (LDAP or OIDC) is configured, users with 2FA enabled sign in through SSO without a second factor by default. SSO is already an authenticated flow, and requiring a TOTP on top is extra friction that most teams do not need.
+72 -5
View File
@@ -88,12 +88,13 @@ test.describe.serial('Two-factor authentication', () => {
// Step 1 (QR) -> Next
await page.getByRole('button', { name: /^Next$/ }).click();
// Step 2 (Confirm): enter a fresh TOTP and capture the backup codes.
// Step 2 (Confirm): enter a fresh TOTP. The confirm step auto-submits on
// the sixth digit, so no explicit click is required. Capture the backup
// codes from the response.
const confirmPromise = page.waitForResponse(
(r) => r.url().includes('/api/auth/mfa/enroll/confirm') && r.status() === 200,
);
await page.locator('#mfa-confirm-code').fill(totpNow(secret));
await page.getByRole('button', { name: /^Verify$/ }).click();
const confirmRes = await confirmPromise;
const confirmBody = await confirmRes.json();
backupCodes = confirmBody.backupCodes;
@@ -106,20 +107,86 @@ test.describe.serial('Two-factor authentication', () => {
await expect(page.getByText(/^Enabled$/)).toBeVisible();
});
test('login with a valid TOTP code reaches the dashboard', async ({ page }) => {
test('low backup codes warning renders when <=2 codes remain', async ({ page }) => {
// Mock the status endpoint so we can exercise the warning branch without
// racing backup-code consumption in this serial suite. The UI only cares
// about the fields on the response, so this is a pure rendering check.
await page.route('**/api/auth/mfa/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, backupCodesRemaining: 1, sso_enforce_mfa: false }),
});
});
// The test user has MFA on, so loginAs is not usable. Drive the challenge
// manually with a backup code so we do not race the TOTP replay blacklist
// against the next test's fresh code in the same 30-second window.
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(backupCodes[5]);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
await openAccountSettings(page);
await expect(page.getByText(/1 backup code remaining/i)).toBeVisible();
await expect(page.getByText(/regenerate a fresh set/i)).toBeVisible();
// Now exercise the exhausted branch (0 codes): the dedicated warning card.
await page.unroute('**/api/auth/mfa/status');
await page.route('**/api/auth/mfa/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, backupCodesRemaining: 0, sso_enforce_mfa: false }),
});
});
// Re-open the account section so it refetches status with the new mock.
await page.keyboard.press('Escape').catch(() => {});
await openAccountSettings(page);
await expect(page.getByText(/No backup codes left/i)).toBeVisible();
await expect(page.getByRole('button', { name: /Regenerate now/i })).toBeVisible();
await page.unroute('**/api/auth/mfa/status');
});
test('typing a 6-digit TOTP auto-submits and reaches the dashboard', async ({ page }) => {
// Fresh page lands on the login screen; password passes but the MFA
// challenge appears because test #1 enrolled the user.
// challenge appears because test #1 enrolled the user. Entering the 6th
// digit must auto-submit the form without the user clicking "Verify".
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
// fill() emits the final value in a single onChange, which at length === 6
// schedules a submit via requestAnimationFrame. No explicit click.
await page.locator('#mfa-code').fill(totpNow(secret));
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
test('backup code entered without the dash still succeeds', async ({ page }) => {
// The backup-code input accepts any paste form; the client normalises to
// 10 alphanumeric characters before sending. Consumes backupCodes[4].
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
const raw = backupCodes[4].replace('-', '');
expect(raw).toMatch(/^[A-Z0-9]{10}$/);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-code').fill(raw);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
test('backup code works once and cannot be replayed', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
+58 -11
View File
@@ -1,37 +1,84 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
BACKUP_CODE_DISPLAY_LENGTH,
TOTP_LENGTH,
normalizeBackupCodeInput,
normalizeTotpInput,
} from '@/lib/mfa';
export function MfaChallenge({
className,
...props
}: React.ComponentPropsWithoutRef<'div'>) {
const { submitMfa, cancelMfa } = useAuth();
const [code, setCode] = useState('');
// `display` is what the user sees in the input (with dash for backup codes);
// `raw` is the normalized value we send to the server.
const [display, setDisplay] = useState('');
const [raw, setRaw] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [useBackup, setUseBackup] = useState(false);
// Latch so auto-submit only fires once per full code entry: cleared on any
// edit that brings the input back below a full code.
const submittedRef = useRef(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const runSubmit = async (valueToSubmit: string) => {
setError('');
setIsLoading(true);
const result = await submitMfa(code, { isBackupCode: useBackup });
const result = await submitMfa(valueToSubmit, { isBackupCode: useBackup });
if (!result.success) {
const retryNote = result.retryAfter ? ` (try again in ${Math.ceil(result.retryAfter / 60)} min)` : '';
setError((result.error || 'Verification failed') + retryNote);
setCode('');
setDisplay('');
setRaw('');
submittedRef.current = false;
}
setIsLoading(false);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isLoading || !raw) return;
submittedRef.current = true;
void runSubmit(raw);
};
const handleChange = (value: string) => {
if (useBackup) {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
if (next.raw.length < 10) submittedRef.current = false;
// Backup codes are longer and deliberate; do not auto-submit.
return;
}
const normalized = normalizeTotpInput(value);
setDisplay(normalized);
setRaw(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (
normalized.length === TOTP_LENGTH &&
!isLoading &&
!submittedRef.current
) {
submittedRef.current = true;
// Let the state update flush before firing so the spinner state lines
// up with the disabled button.
requestAnimationFrame(() => { void runSubmit(normalized); });
}
};
const handleToggleBackup = () => {
setUseBackup((v) => !v);
setCode('');
setDisplay('');
setRaw('');
setError('');
submittedRef.current = false;
};
return (
@@ -89,9 +136,9 @@ export function MfaChallenge({
autoComplete="one-time-code"
autoFocus
required
maxLength={useBackup ? 12 : 6}
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={useBackup ? BACKUP_CODE_DISPLAY_LENGTH : TOTP_LENGTH}
value={display}
onChange={(e) => handleChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
/>
@@ -101,7 +148,7 @@ export function MfaChallenge({
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !code}>
<Button type="submit" className="w-full" disabled={isLoading || !raw}>
{isLoading ? 'Verifying...' : 'Verify and sign in'}
</Button>
<button
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
import { Copy, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
interface MfaBackupCodesDialogProps {
open: boolean;
@@ -21,38 +22,63 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const submittedRef = useRef(false);
const resetState = () => {
setStep('confirm');
setCode('');
setError('');
setBackupCodes([]);
submittedRef.current = false;
};
const handleConfirm = async (e: React.FormEvent) => {
e.preventDefault();
const submitRegenerate = async (valueToSubmit: string) => {
setError('');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/backup-codes/regenerate', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code }),
body: JSON.stringify({ code: valueToSubmit }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Could not regenerate backup codes');
setCode('');
submittedRef.current = false;
return;
}
setBackupCodes(data.backupCodes || []);
setStep('show');
} catch (err) {
setError((err as Error)?.message || 'Could not regenerate backup codes');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const handleConfirm = (e: React.FormEvent) => {
e.preventDefault();
if (loading || code.length !== TOTP_LENGTH) return;
submittedRef.current = true;
void submitRegenerate(code);
};
const handleCodeChange = (raw: string) => {
const normalized = normalizeTotpInput(raw);
setCode(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (
normalized.length === TOTP_LENGTH &&
!loading &&
!submittedRef.current
) {
submittedRef.current = true;
requestAnimationFrame(() => { void submitRegenerate(normalized); });
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(backupCodes.join('\n'));
@@ -119,9 +145,9 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
autoComplete="one-time-code"
autoFocus
required
maxLength={6}
maxLength={TOTP_LENGTH}
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
@@ -129,7 +155,7 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || code.length !== 6}>
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
{loading ? 'Working...' : 'Regenerate'}
</Button>
</DialogFooter>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogContent,
@@ -13,6 +13,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import {
BACKUP_CODE_DISPLAY_LENGTH,
BACKUP_CODE_RAW_LENGTH,
TOTP_LENGTH,
normalizeBackupCodeInput,
normalizeTotpInput,
} from '@/lib/mfa';
interface MfaDisableDialogProps {
open: boolean;
@@ -21,44 +28,94 @@ interface MfaDisableDialogProps {
}
export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) {
const [code, setCode] = useState('');
// `display` is what the input shows (backup codes carry a dash after five chars);
// `raw` is the normalized value sent to the server.
const [display, setDisplay] = useState('');
const [raw, setRaw] = useState('');
const [useBackup, setUseBackup] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const submittedRef = useRef(false);
useEffect(() => {
if (open) {
setCode('');
setDisplay('');
setRaw('');
setError('');
setUseBackup(false);
submittedRef.current = false;
}
}, [open]);
const handleDisable = async () => {
const submitDisable = async (valueToSubmit: string) => {
setError('');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/disable', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code, isBackupCode: useBackup }),
body: JSON.stringify({ code: valueToSubmit, isBackupCode: useBackup }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Could not disable two-factor authentication');
setDisplay('');
setRaw('');
submittedRef.current = false;
return;
}
toast.success('Two-factor authentication disabled');
setCode('');
setDisplay('');
setRaw('');
onOpenChange(false);
onDisabled();
} catch (err) {
setError((err as Error)?.message || 'Could not disable two-factor authentication');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const expectedLength = useBackup ? BACKUP_CODE_RAW_LENGTH : TOTP_LENGTH;
const handleCodeChange = (value: string) => {
if (useBackup) {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
// Never auto-submit a backup code; the action is destructive.
if (next.raw.length < BACKUP_CODE_RAW_LENGTH) submittedRef.current = false;
return;
}
const normalized = normalizeTotpInput(value);
setDisplay(normalized);
setRaw(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (
normalized.length === TOTP_LENGTH &&
!loading &&
!submittedRef.current
) {
submittedRef.current = true;
requestAnimationFrame(() => { void submitDisable(normalized); });
}
};
const handleToggleBackup = () => {
setUseBackup((v) => !v);
setDisplay('');
setRaw('');
setError('');
submittedRef.current = false;
};
const handleDisableClick = () => {
if (loading || raw.length !== expectedLength) return;
submittedRef.current = true;
void submitDisable(raw);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
@@ -77,9 +134,9 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
type="text"
inputMode={useBackup ? 'text' : 'numeric'}
autoComplete="one-time-code"
maxLength={useBackup ? 12 : 6}
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={useBackup ? BACKUP_CODE_DISPLAY_LENGTH : TOTP_LENGTH}
value={display}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
/>
@@ -87,7 +144,7 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
onClick={() => { setUseBackup((v) => !v); setCode(''); setError(''); }}
onClick={handleToggleBackup}
>
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
</button>
@@ -100,8 +157,8 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
type="button"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
disabled={loading || !code}
onClick={handleDisable}
disabled={loading || raw.length !== expectedLength}
onClick={handleDisableClick}
>
{loading ? 'Disabling...' : 'Disable'}
</Button>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import { Copy, Download, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
interface MfaEnrollDialogProps {
open: boolean;
@@ -33,6 +34,8 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
// Latch so auto-submit only fires once per complete entry.
const submittedRef = useRef(false);
// When the dialog opens, start enrolment so the QR is ready immediately.
useEffect(() => {
@@ -43,6 +46,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
setError('');
setBackupCodes([]);
setShowSecret(false);
submittedRef.current = false;
setLoading(true);
apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true })
.then(async (r) => {
@@ -65,30 +69,53 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
return () => { cancelled = true; };
}, [open, onOpenChange]);
const handleConfirm = async (e: React.FormEvent) => {
e.preventDefault();
const submitConfirm = async (valueToSubmit: string) => {
setError('');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/enroll/confirm', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code }),
body: JSON.stringify({ code: valueToSubmit }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Verification failed');
setCode('');
submittedRef.current = false;
return;
}
setBackupCodes(data.backupCodes || []);
setStep('backup');
} catch (err) {
setError((err as Error)?.message || 'Verification failed');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const handleConfirm = (e: React.FormEvent) => {
e.preventDefault();
if (loading || code.length !== TOTP_LENGTH) return;
submittedRef.current = true;
void submitConfirm(code);
};
const handleCodeChange = (raw: string) => {
const normalized = normalizeTotpInput(raw);
setCode(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (
normalized.length === TOTP_LENGTH &&
!loading &&
!submittedRef.current
) {
submittedRef.current = true;
requestAnimationFrame(() => { void submitConfirm(normalized); });
}
};
const handleCopySecret = async () => {
try {
await navigator.clipboard.writeText(secret);
@@ -202,9 +229,9 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
autoComplete="one-time-code"
autoFocus
required
maxLength={6}
maxLength={TOTP_LENGTH}
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
@@ -212,7 +239,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>Back</Button>
<Button type="submit" disabled={loading || code.length !== 6}>
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
{loading ? 'Verifying...' : 'Verify'}
</Button>
</DialogFooter>
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { RefreshCw, Shield, ShieldCheck } from 'lucide-react';
import { AlertTriangle, RefreshCw, Shield, ShieldCheck } from 'lucide-react';
import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog';
import { MfaDisableDialog } from '@/components/mfa/MfaDisableDialog';
import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog';
@@ -144,9 +144,36 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
<div className="mt-4 text-xs text-muted-foreground">Loading</div>
) : mfa?.enabled ? (
<div className="mt-4 space-y-3">
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining
</div>
{mfa.backupCodesRemaining === 0 ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-destructive" strokeWidth={1.5} />
<div className="flex-1">
<div className="text-sm font-medium text-destructive">No backup codes left</div>
<div className="text-xs text-destructive/80 mt-0.5">
Regenerate a new set before you lose access to your authenticator app. Without codes, recovery needs an administrator.
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 h-7 px-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setRegenOpen(true)}
>
Regenerate now
</Button>
</div>
</div>
) : mfa.backupCodesRemaining <= 2 ? (
<div className="flex items-center gap-2 text-xs font-mono tabular-nums text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
<span>
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining, regenerate a fresh set
</span>
</div>
) : (
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{mfa.backupCodesRemaining} backup codes remaining
</div>
)}
{hasSso && (
<div className="flex items-start justify-between gap-3 rounded-md border border-card-border bg-background/40 p-3">
+45
View File
@@ -0,0 +1,45 @@
/**
* MFA input helpers shared by the challenge screen and the MFA dialogs.
*
* The server is authoritative: it strips whitespace on TOTPs and calls
* `MfaService.normalizeBackupCode` to accept backup codes with or without a
* separator. We still normalize on the client so the input shows the user a
* clean value, enforces a correct length cap, and stays consistent when a
* code is pasted from a password manager (which may include smart-dashes,
* line breaks, or trailing whitespace).
*/
/** Length of a raw backup code before any display formatting. */
export const BACKUP_CODE_RAW_LENGTH = 10;
/** Length of a backup code as displayed to the user: `ABCDE-FGHIJ`. */
export const BACKUP_CODE_DISPLAY_LENGTH = BACKUP_CODE_RAW_LENGTH + 1;
/** Length of the 6-digit TOTP used by every authenticator app we support. */
export const TOTP_LENGTH = 6;
/**
* Normalize an incoming TOTP value to exactly the characters the server
* will accept: digits only, capped at 6. Keeps typing fluid when a password
* manager injects an extra space or the user pastes ` 123 456 `.
*/
export function normalizeTotpInput(raw: string): string {
return (raw || '').replace(/\D+/g, '').slice(0, TOTP_LENGTH);
}
/**
* Normalize an incoming backup code and produce a display-formatted value.
*
* - Uppercases everything (backup codes are printed in uppercase).
* - Strips every character that is not `A-Z` or `0-9`, which drops spaces,
* line breaks, and any dash variant (`-`, en-dash, em-dash, figure-dash).
* - Caps at 10 raw characters so `maxLength` never truncates a pasted value
* mid-character.
* - Reintroduces a single `-` after the 5th character so the input mirrors
* the canonical `ABCDE-FGHIJ` layout users see in the enrolment dialog.
*/
export function normalizeBackupCodeInput(raw: string): { display: string; raw: string } {
const stripped = (raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, BACKUP_CODE_RAW_LENGTH);
if (stripped.length <= 5) return { display: stripped, raw: stripped };
return { display: `${stripped.slice(0, 5)}-${stripped.slice(5)}`, raw: stripped };
}