From 90a5c2b87c7f5e5f7e74dcf463ad18570caa59c8 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 10 Jun 2026 14:12:18 +0200 Subject: [PATCH] add comprehensive 2 factor authentication e2e tests --- e2e/two-factor.spec.ts | 192 +++++++++++++++++++++++++++++++++++++++++ e2e/utils/totp.ts | 58 +++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 e2e/two-factor.spec.ts create mode 100644 e2e/utils/totp.ts diff --git a/e2e/two-factor.spec.ts b/e2e/two-factor.spec.ts new file mode 100644 index 00000000..9db37b2d --- /dev/null +++ b/e2e/two-factor.spec.ts @@ -0,0 +1,192 @@ +import { test, expect } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; +import { generateTotpCode, generateInvalidTotpCode } from './utils/totp'; +import type { Page } from '@playwright/test'; + +async function goToProfilePage(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); +} + +/** + * ConfirmsPassword only opens the dialog when the password has not been + * confirmed recently, so fill it only when it actually shows up. + */ +async function confirmPasswordIfPrompted(page: Page) { + const dialog = page.getByRole('dialog'); + const appeared = await dialog + .waitFor({ state: 'visible', timeout: 2500 }) + .then(() => true) + .catch(() => false); + if (appeared) { + await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD); + await dialog.getByRole('button', { name: 'Confirm' }).click(); + await expect(dialog).not.toBeVisible(); + } +} + +/** + * Enables 2FA from the profile page and returns the TOTP secret (setup key) + * and the recovery codes fetched right after enabling. + */ +async function enableTwoFactor(page: Page): Promise<{ secret: string; recoveryCodes: string[] }> { + await goToProfilePage(page); + await page + .getByText('You have not enabled two factor authentication.') + .locator('..') + .getByRole('button', { name: 'Enable' }) + .click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + const recoveryCodesResponse = page.waitForResponse( + (response) => + response.url().includes('/user/two-factor-recovery-codes') && + response.request().method() === 'GET' + ); + await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD); + await dialog.getByRole('button', { name: 'Confirm' }).click(); + + await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible(); + const recoveryCodes: string[] = await (await recoveryCodesResponse).json(); + + const setupKeyText = await page.getByText('Setup Key:').textContent(); + const secret = setupKeyText!.replace('Setup Key:', '').trim(); + expect(secret.length).toBeGreaterThan(0); + + return { secret, recoveryCodes }; +} + +/** + * Confirms a freshly enabled 2FA setup with a valid TOTP code. + */ +async function confirmTwoFactor(page: Page, secret: string) { + await page.getByLabel('Code').fill(generateTotpCode(secret)); + await page.getByRole('button', { name: 'Confirm', exact: true }).click(); + await confirmPasswordIfPrompted(page); + await expect(page.getByText('You have enabled two factor authentication.')).toBeVisible(); +} + +async function logout(page: Page) { + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out', { exact: true }).click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); +} + +/** + * Reads the email of the current user from the profile form, waiting until + * the user query has populated it. + */ +async function getProfileEmail(page: Page): Promise { + await goToProfilePage(page); + const emailInput = page.getByLabel('Email', { exact: true }); + await expect(emailInput).toHaveValue(/@/); + return await emailInput.inputValue(); +} + +async function loginUntilTwoFactorChallenge(page: Page, email: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/login'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(TEST_USER_PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/two-factor-challenge'); +} + +test('test that 2FA can be confirmed with a TOTP code and shows recovery codes', async ({ + page, +}) => { + const { secret, recoveryCodes } = await enableTwoFactor(page); + await confirmTwoFactor(page, secret); + + await expect(page.getByText('Store these recovery codes')).toBeVisible(); + expect(recoveryCodes.length).toBeGreaterThan(0); + for (const code of recoveryCodes) { + await expect(page.getByText(code)).toBeVisible(); + } + + // The confirmed state survives a reload + await page.reload(); + await expect(page.getByText('You have enabled two factor authentication.')).toBeVisible(); +}); + +test('test that 2FA confirmation fails with an invalid TOTP code', async ({ page }) => { + const { secret } = await enableTwoFactor(page); + + await page.getByLabel('Code').fill(generateInvalidTotpCode(secret)); + await page.getByRole('button', { name: 'Confirm', exact: true }).click(); + await confirmPasswordIfPrompted(page); + + await expect(page.getByRole('alert')).toContainText( + 'The provided two factor authentication code was invalid.' + ); + await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible(); +}); + +test('test that recovery codes can be regenerated', async ({ page }) => { + const { secret, recoveryCodes } = await enableTwoFactor(page); + await confirmTwoFactor(page, secret); + + const newCodesResponse = page.waitForResponse( + (response) => + response.url().includes('/user/two-factor-recovery-codes') && + response.request().method() === 'GET' + ); + await page.getByRole('button', { name: 'Regenerate Recovery Codes' }).click(); + await confirmPasswordIfPrompted(page); + const newCodes: string[] = await (await newCodesResponse).json(); + + expect(newCodes).not.toEqual(recoveryCodes); + await expect(page.getByText(newCodes[0])).toBeVisible(); + await expect(page.getByText(recoveryCodes[0])).not.toBeVisible(); +}); + +test('test that 2FA can be disabled', async ({ page }) => { + const { secret } = await enableTwoFactor(page); + await confirmTwoFactor(page, secret); + + await page.getByRole('button', { name: 'Disable' }).click(); + await confirmPasswordIfPrompted(page); + await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible(); + + // The disabled state survives a reload + await page.reload(); + await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible(); +}); + +test('test that login challenges for a TOTP code and rejects an invalid code', async ({ page }) => { + const email = await getProfileEmail(page); + + const { secret } = await enableTwoFactor(page); + await confirmTwoFactor(page, secret); + await logout(page); + + await loginUntilTwoFactorChallenge(page, email); + + await page.getByLabel('Code').fill(generateInvalidTotpCode(secret)); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByRole('alert')).toContainText( + 'The provided two factor authentication code was invalid.' + ); + + // Fortify rejects replayed codes, and the current window's code was + // already consumed when confirming the setup — use the next window's + // code, which the +/- 1 step verification window also accepts. + await page.getByLabel('Code').fill(generateTotpCode(secret, Date.now() + 30_000)); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); +}); + +test('test that login works with a recovery code', async ({ page }) => { + const email = await getProfileEmail(page); + + const { secret, recoveryCodes } = await enableTwoFactor(page); + await confirmTwoFactor(page, secret); + await logout(page); + + await loginUntilTwoFactorChallenge(page, email); + + await page.getByRole('button', { name: 'Use a recovery code' }).click(); + await page.getByLabel('Recovery Code').fill(recoveryCodes[0]); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); +}); diff --git a/e2e/utils/totp.ts b/e2e/utils/totp.ts new file mode 100644 index 00000000..11c929cb --- /dev/null +++ b/e2e/utils/totp.ts @@ -0,0 +1,58 @@ +import { createHmac } from 'node:crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +function base32Decode(input: string): Buffer { + const normalized = input + .toUpperCase() + .replace(/=+$/, '') + .replace(/[^A-Z2-7]/g, ''); + let bits = 0; + let value = 0; + const bytes: number[] = []; + for (const char of normalized) { + value = (value << 5) | BASE32_ALPHABET.indexOf(char); + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +/** + * Generates a 6-digit TOTP code (RFC 6238, SHA-1, 30 second period) for the + * given base32 secret — the "Setup Key" shown while enabling 2FA. + */ +export function generateTotpCode(base32Secret: string, atMs: number = Date.now()): string { + const counter = Math.floor(atMs / 1000 / 30); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac('sha1', base32Decode(base32Secret)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const code = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + return (code % 1_000_000).toString().padStart(6, '0'); +} + +/** + * Generates a syntactically valid TOTP code that is guaranteed to be rejected, + * by using a timestamp far outside the accepted verification window. + */ +export function generateInvalidTotpCode(base32Secret: string): string { + const validNow = [ + generateTotpCode(base32Secret, Date.now() - 30_000), + generateTotpCode(base32Secret), + generateTotpCode(base32Secret, Date.now() + 30_000), + ]; + for (let minutes = 10; ; minutes++) { + const candidate = generateTotpCode(base32Secret, Date.now() + minutes * 60_000); + if (!validNow.includes(candidate)) { + return candidate; + } + } +}