mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 01:37:05 +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:
+72
-5
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user