import { test, expect } from '@playwright/test'; import { authenticator } from '../../src/lib/auth-crypto'; import postgres from 'postgres'; const headers = { origin: 'http://localhost:3100' }; test('authenticator enrollment, challenge isolation, replay protection, recovery and private mail settings', async ({ page, playwright, }) => { const other = await playwright.request.newContext({ baseURL: 'http://localhost:3100' }); const db = postgres(process.env.TEST_DATABASE_URL!, { max: 1 }); const password = process.env.TEST_PASSWORD!; await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); await page.goto('/admin'); await page.getByLabel('Benutzername', { exact: true }).fill('testadmin'); await page.getByLabel('Passwort', { exact: true }).fill(password); await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.getByRole('button', { name: 'Einstellungen', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Mail & Benachrichtigungen' })).toBeVisible(); await page.getByLabel('Aktuelles Passwort für 2FA').fill(password); await page.getByRole('button', { name: '2FA einrichten', exact: true }).click(); await expect( page.getByRole('img', { name: 'QR-Code für die HamHam-Authenticator-Einrichtung' }), ).toBeVisible(); const secret = await page.locator('.authenticator-setup code').textContent(); const oldCode = authenticator(secret!).generate({ timestamp: Date.now() - 30000 }); await page .getByLabel('Authenticator- oder Wiederherstellungscode', { exact: true }) .fill(oldCode); await page.getByRole('button', { name: '2FA aktivieren', exact: true }).click(); await expect(page.locator('.recovery-codes li')).toHaveCount(10); const recovery = await page.locator('.recovery-codes li code').allTextContents(); try { expect((await other.get('/api/auth/security')).status()).toBe(401); // Enrollment revoked the old session. // Password alone never grants an admin session when 2FA is enabled. const login = await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password }, }); expect(await login.json()).toEqual({ twoFactorRequired: true }); expect(login.headers()['set-cookie']).not.toContain('hamham_session'); expect((await other.get('/api/export')).status()).toBe(401); expect((await other.get('/api/auth/security')).status()).toBe(401); expect( ( await other.post('/api/auth/verify', { headers: { origin: 'https://foreign.example' }, data: { code: recovery[0] }, }) ).status(), ).toBe(403); expect( (await other.post('/api/auth/verify', { headers, data: { code: 'invalid' } })).status(), ).toBe(401); const code = authenticator(secret!).generate(); expect((await other.post('/api/auth/verify', { headers, data: { code } })).status()).toBe(200); expect((await other.post('/api/auth/verify', { headers, data: { code } })).status()).toBe(401); await other.post('/api/auth/logout', { headers }); await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); expect((await other.post('/api/auth/verify', { headers, data: { code } })).status()).toBe(401); expect( (await other.post('/api/auth/verify', { headers, data: { code: recovery[0] } })).status(), ).toBe(200); const security = await (await other.get('/api/auth/security')).json(); expect(security.recoveryRemaining).toBe(9); expect(JSON.stringify(security)).not.toContain(secret!); expect(security.events[0].factor).toContain('Wiederherstellung'); const mail = { host: '127.0.0.1', port: 1, secure: true, username: 'test', password: 'smtp-secret-for-test', from: 'hamham@example.org', }; expect( ( await other.put('/api/settings/mail', { headers, data: { email: 'admin@example.org', loginNotifications: true, mail, password, code: recovery[1], }, }) ).status(), ).toBe(200); const settings = await (await other.get('/api/auth/security')).json(); expect(settings.mail.passwordConfigured).toBe(true); expect(JSON.stringify(settings)).not.toContain(mail.password); expect(JSON.stringify(await (await other.get('/api/settings')).json())).not.toContain( mail.password, ); const [stored] = await db`SELECT data FROM mail_config WHERE id = 1`; expect(stored.data.password).not.toContain(mail.password); expect((await other.post('/api/settings/mail/test', { headers })).status()).toBe(502); await other.post('/api/auth/logout', { headers }); // A challenge cannot be retried indefinitely, even with a valid recovery code afterwards. await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); for (let n = 0; n < 5; n++) expect( (await other.post('/api/auth/verify', { headers, data: { code: 'invalid' } })).status(), ).toBe(401); expect( (await other.post('/api/auth/verify', { headers, data: { code: recovery[2] } })).status(), ).toBe(401); await other.post('/api/auth/logout', { headers }); // Real browser login via the second screen, without asking for the password again. await page.getByRole('button', { name: 'Abmelden', exact: true }).click(); await page.getByLabel('Benutzername', { exact: true }).fill('testadmin'); await page.getByLabel('Passwort', { exact: true }).fill(password); await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await expect(page.getByLabel('Passwort', { exact: true })).toHaveCount(0); await page .getByLabel('Authenticator- oder Wiederherstellungscode', { exact: true }) .fill(recovery[2]); await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Hallo, testadmin.' })).toBeVisible(); await page.getByRole('button', { name: 'Einstellungen', exact: true }).click(); // Failed notification delivery must not undo a successful authenticated login. await expect .poll( async () => (await (await page.request.get('/api/auth/security')).json()).events[0].mail_status, ) .toBe('failed'); await page.screenshot({ path: test.info().outputPath('security-settings.png'), fullPage: true, }); await page.setViewportSize({ width: 390, height: 844 }); expect( await page.evaluate( () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, ), ).toBe(true); await page.screenshot({ path: test.info().outputPath('security-settings-mobile.png'), fullPage: true, }); await db`UPDATE admin_security SET login_notifications = false`; // Expired challenges cannot consume an otherwise valid recovery code. await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); await db`UPDATE login_challenges SET expires = now() - interval '1 second'`; expect( (await other.post('/api/auth/verify', { headers, data: { code: recovery[4] } })).status(), ).toBe(401); // Two independent challenges racing for one recovery code yield only one session. const concurrent = await playwright.request.newContext({ baseURL: 'http://localhost:3100' }); try { for (const client of [other, concurrent]) { expect( ( await client.post('/api/auth/login', { headers, data: { username: 'testadmin', password }, }) ).status(), ).toBe(200); } const results = await Promise.all( [other, concurrent].map((client) => client.post('/api/auth/verify', { headers, data: { code: recovery[4] } }), ), ); expect(results.map((result) => result.status()).sort()).toEqual([200, 401]); // Regeneration revokes other sessions and replaces all previous codes. const regenerated = await page.request.post('/api/auth/2fa/recovery', { headers, data: { password, code: recovery[5] }, }); expect(regenerated.status()).toBe(200); const newCodes = (await regenerated.json()).codes as string[]; expect(newCodes).toHaveLength(10); for (const client of [other, concurrent]) expect((await client.get('/api/auth/security')).status()).toBe(401); await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); expect( (await other.post('/api/auth/verify', { headers, data: { code: recovery[6] } })).status(), ).toBe(401); recovery[3] = newCodes[0]; } finally { await concurrent.dispose(); } expect( ( await page.request.post('/api/auth/2fa/disable', { headers, data: { password, code: recovery[3] }, }) ).status(), ).toBe(200); expect((await (await page.request.get('/api/auth/security')).json()).enabled).toBe(false); } finally { await db`DELETE FROM admin_security WHERE admin_id = (SELECT id FROM admins WHERE username = 'testadmin')`; await db`DELETE FROM login_challenges`; await db`DELETE FROM mail_config`; await db.end(); await other.dispose(); } });