204 lines
9.2 KiB
TypeScript
204 lines
9.2 KiB
TypeScript
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();
|
|
}
|
|
});
|