Files
sencho/e2e/mfa.spec.ts
T
Anso 7d78c9fe22 feat(auth): add TOTP two-factor authentication with backup codes (#615)
* feat(auth): add TOTP two-factor authentication with backup codes

Adds RFC 6238 time-based one-time password support to every tier,
integrated with the existing password and SSO login paths.

Backend:
- New MfaService wrapping otplib with a plus or minus 1 step tolerance,
  base32 secret generation, and hashed single-use backup codes (bcrypt).
- user_mfa and mfa_used_tokens tables in DatabaseService. The second
  table is a DB-backed replay blacklist, purged on a 60s interval.
- authMiddleware now recognizes an mfa_pending scope. A token carrying
  that scope is rejected on every route except the MFA challenge and
  logout, so no API surface is reachable before the second factor
  clears.
- /api/auth/login issues only a short-lived mfa_pending cookie when the
  user has MFA enrolled. /api/auth/login/mfa consumes that cookie,
  verifies the code (or backup code), and swaps in a real session.
- /api/auth/mfa/* routes for status, enrol/start, enrol/confirm,
  disable, backup-code regenerate, and SSO-bypass opt-in.
- Admin recovery path: POST /api/users/:id/mfa/reset clears the target's
  MFA state, bumps token_version, and writes an audit log entry.
- CLI emergency fallback: backend/src/cli/resetMfa.ts is wired via
  `npm run reset-mfa <username>` and also exported for tests.
- SSO flows (LDAP and OIDC) gate on user_mfa.sso_enforce_mfa before
  issuing a session; default behaviour keeps the SSO path frictionless.
- Per-user lockout after 5 consecutive failed codes (15 min).

Frontend:
- AppStatus gains an mfa-challenge branch driven by /api/auth/status.
- New MfaChallenge screen, MfaEnrollDialog (QR plus manual secret plus
  backup codes), MfaDisableDialog, MfaBackupCodesDialog.
- Account section shows a Two-factor authentication card with enrol,
  regenerate, disable, and the SSO-enforce toggle (shown only when SSO
  providers are configured).
- Users section gains a Reset 2FA action for admins.

Docs:
- New user guide at features/two-factor-authentication.mdx.
- New admin guide at operations/two-factor-admin.mdx.
- SSO page cross-links to the 2FA doc.

* fix(mfa): drop unused TEST_PASSWORD import and stale eslint disable

* fix(mfa): simplify e2e openAccountSettings helper to match working pattern

* fix(mfa): make e2e suite self-contained and always clean up

Test #2 called loginAs() before the MFA challenge step, which waited for
the dashboard indicator that never appears once the previous test enrolled
the user. That timeout skipped the rest of the serial block, including
the disable step, leaving MFA enabled and breaking every later spec.

Two fixes:

- Tests #2 and #3 now navigate directly to the login page instead of
  piggybacking on loginAs, which only handles the password-only path.
- A new afterAll hook unconditionally disables MFA via the API using two
  unused backup codes, so the DB is reset even if a test fails midway.

* fix(e2e): use backup code for mfa recovery to avoid totp replay race

The final recovery step in the backup-code replay test previously
generated a fresh TOTP to sign back in. When the timing landed inside
the same 30-second window that test #2 consumed, the server's replay
blacklist correctly rejected it, producing a ~50% flake rate. Backup
codes are single-use and sidestep the replay window, so the recovery
becomes deterministic.

* fix(e2e): drive mfa disable test through the challenge screen

Test #4 called loginAs after test #3 left MFA enabled, but loginAs
waits for the dashboard indicator and does not handle the challenge
screen, so it timed out. Drive the login manually, satisfy the
challenge with a backup code, and use a backup code for the disable
step too to avoid any TOTP replay-window race against earlier tests
in the serial block.
2026-04-15 18:45:51 -04:00

190 lines
9.1 KiB
TypeScript

/**
* Two-factor authentication (TOTP) E2E tests.
*
* These tests run serially and share mutable state (the enrolment secret and
* the freshly issued backup codes). The chain is:
* 1. Enrol via the Account section, capture secret and backup codes from the
* network responses so we do not have to scrape the DOM.
* 2. Log out, log back in, satisfy the TOTP challenge, land on the dashboard.
* 3. Log out, log back in, satisfy the challenge with a backup code,
* re-use the same backup code and confirm the second attempt fails.
* 4. Disable 2FA to leave the dev DB in a clean state for the next run.
*
* If a previous run aborted mid-way, the test user may already have MFA on.
* Run `node backend/dist/cli/resetMfa.js <username>` or wipe the dev DB first.
*/
import { test, expect, Page } from '@playwright/test';
import { loginAs, totpNow, TEST_USERNAME, TEST_PASSWORD, isDashboard } from './helpers';
async function logout(page: Page) {
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: /log out/i }).click();
// The MfaChallenge / Login screen has no dashboard indicator.
await expect.poll(async () => isDashboard(page), { timeout: 5_000 }).toBe(false);
}
async function openAccountSettings(page: Page) {
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: /settings/i }).click();
await expect(page.getByRole('heading', { name: /Account & Security/i })).toBeVisible();
}
/** Fill a login form (no MFA branch). */
async function fillLoginForm(page: Page, username: string, password: string) {
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
}
test.describe.serial('Two-factor authentication', () => {
let secret = '';
let backupCodes: string[] = [];
// Safety net: if any test above fails, Playwright skips the rest of the
// serial block, so the "disable 2FA" test never runs and the shared test
// user stays MFA-enabled in the dev DB. That wrecks every subsequent spec
// (nodes, stacks, screenshots) because their loginAs helper does not know
// about the challenge screen. afterAll always runs, so we clear MFA here
// via the API using whatever enrolment state we captured.
test.afterAll(async ({ request }) => {
if (!secret || backupCodes.length < 2) return;
try {
// Use backup codes for both steps: they are single-use and sidestep
// the TOTP replay blacklist, so we do not need to reason about which
// 30-second window we are currently in.
const loginBackup = backupCodes[backupCodes.length - 2];
const disableBackup = backupCodes[backupCodes.length - 1];
await request.post('/api/auth/login', {
data: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
const loginRes = await request.post('/api/auth/login/mfa', {
data: { code: loginBackup, isBackupCode: true },
});
if (!loginRes.ok()) return;
await request.post('/api/auth/mfa/disable', {
data: { code: disableBackup, isBackupCode: true },
});
} catch {
// Best effort; if this fails the next full-suite run will need a
// manual DB wipe or CLI reset.
}
});
test('enrol from Account settings captures secret and backup codes', async ({ page }) => {
await loginAs(page, TEST_USERNAME, TEST_PASSWORD);
await openAccountSettings(page);
// Capture the raw base32 secret from the enroll/start response so we
// do not need to strip formatting spaces off the DOM value.
const startPromise = page.waitForResponse(
(r) => r.url().includes('/api/auth/mfa/enroll/start') && r.status() === 200,
);
await page.getByRole('button', { name: /Set up 2FA/i }).click();
const startRes = await startPromise;
const startBody = await startRes.json();
secret = startBody.secret;
expect(secret).toMatch(/^[A-Z2-7]+$/); // base32 alphabet
// Step 1 (QR) -> Next
await page.getByRole('button', { name: /^Next$/ }).click();
// Step 2 (Confirm): enter a fresh TOTP and capture the backup codes.
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;
expect(backupCodes.length).toBe(10);
// Step 3 (Backup codes) -> acknowledge.
await page.getByRole('button', { name: /saved these/i }).click();
// Card now shows the Enabled badge.
await expect(page.getByText(/^Enabled$/)).toBeVisible();
});
test('login with a valid TOTP code 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.
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.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 works once and cannot be replayed', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
const code = backupCodes[0];
expect(code).toBeTruthy();
// First use: should succeed.
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(code);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
// Log out and try the same backup code again: should fail.
await logout(page);
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(code);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
// Error should be visible and we should still be on the challenge screen.
await expect(page.locator('.text-destructive')).toBeVisible();
expect(await isDashboard(page)).toBe(false);
// Recover using a fresh backup code. Using a TOTP here races the
// 30-second window against the one test #2 consumed, which the server
// (correctly) rejects as a replay when the boundary falls the wrong
// way. Backup codes are single-use and sidestep that blacklist.
await page.locator('#mfa-code').clear();
await page.locator('#mfa-code').fill(backupCodes[1]);
await page.getByRole('button', { name: /Verify and sign in/i }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
test('disable 2FA with a valid code removes the challenge on next login', async ({ page }) => {
// loginAs does not understand the MFA challenge screen, so drive the
// login manually. Use a backup code for both the challenge and the
// disable step so we do not race the TOTP replay blacklist against
// codes consumed by earlier tests in this serial block.
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[2]);
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 page.getByRole('button', { name: /Disable 2FA/i }).click();
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
await page.locator('#mfa-disable-code').fill(backupCodes[3]);
await page.getByRole('button', { name: /^Disable$/ }).click();
// Card flips back to the "Set up 2FA" call to action.
await expect(page.getByRole('button', { name: /Set up 2FA/i })).toBeVisible();
// Close settings, log out, log back in without the MFA challenge.
await page.keyboard.press('Escape').catch(() => {});
await logout(page);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
});