feat: add admin authenticator 2FA and configurable login email alerts
This commit is contained in:
1 parent
bee3b990f9
commit
52cc865140
21 files changed
+1961
-20
No files matched your search
@@ -48,7 +48,11 @@ test('admin creates and edits recipes with recoverable drafts and image upload',
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Rezepttitel', exact: true })
|
||||
.fill('Browser-Küchenrezept verbessert');
|
||||
const saved = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/recipes/') && r.request().method() === 'PUT',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Rezept speichern' }).click();
|
||||
expect((await saved).status()).toBe(200);
|
||||
await expect(page.getByRole('status')).toContainText('erfolgreich gespeichert');
|
||||
await page.goto('/admin');
|
||||
await expect(
|
||||
@@ -109,6 +113,7 @@ test('JSON and XML import with a real preview, duplicate prevention and export',
|
||||
await expect(page.locator('.import-preview')).toContainText('200 g Pasta');
|
||||
await page.getByRole('button', { name: '1 Rezept(e) jetzt importieren' }).click();
|
||||
await expect(page.getByRole('heading', { name: recipe.title })).toBeVisible();
|
||||
await page.reload();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Importieren', exact: true }).click();
|
||||
await page.getByLabel('Rezeptdaten').fill(exportRecipes([pasta], 'json'));
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { SMTPServer } from 'smtp-server';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
|
||||
test('encrypted SMTP credentials, real TLS delivery and optional login alerts', async ({
|
||||
request,
|
||||
}) => {
|
||||
const messages: string[] = [];
|
||||
const recipients: string[] = [];
|
||||
const server = new SMTPServer({
|
||||
secure: true,
|
||||
key: readFileSync(join(process.env.TEST_TLS_DIR!, 'key.pem')),
|
||||
cert: readFileSync(join(process.env.TEST_TLS_DIR!, 'cert.pem')),
|
||||
onAuth(auth, _session, done) {
|
||||
if (auth.username === 'testmailer' && auth.password === 'smtp-test-password')
|
||||
done(null, { user: 'testmailer' });
|
||||
else done(new Error('Invalid credentials'));
|
||||
},
|
||||
onData(stream, session, done) {
|
||||
let message = '';
|
||||
stream.on('data', (chunk) => {
|
||||
message += chunk.toString();
|
||||
});
|
||||
stream.on('end', () => {
|
||||
messages.push(message);
|
||||
recipients.push(...session.envelope.rcptTo.map((r) => r.address));
|
||||
done();
|
||||
});
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(15440, '127.0.0.1', resolve);
|
||||
});
|
||||
const db = postgres(process.env.TEST_DATABASE_URL!, { max: 1 });
|
||||
const password = process.env.TEST_PASSWORD!;
|
||||
const headers = {
|
||||
origin: 'http://localhost:3100',
|
||||
'x-forwarded-for': '1.2.3.4, 2001:db8::123',
|
||||
'user-agent': 'HamHam-TestBrowser',
|
||||
};
|
||||
const mail = {
|
||||
host: '127.0.0.1',
|
||||
port: 15440,
|
||||
secure: true,
|
||||
username: 'testmailer',
|
||||
password: 'smtp-test-password',
|
||||
from: 'hamham@example.org',
|
||||
};
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/auth/login', {
|
||||
headers,
|
||||
data: { username: 'testadmin', password },
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await request.put('/api/settings/mail', {
|
||||
headers,
|
||||
data: { password, email: 'admin@example.org', loginNotifications: true, mail },
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
expect((await request.post('/api/settings/mail/test', { headers })).status()).toBe(200);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(recipients).toEqual(['admin@example.org']);
|
||||
await request.post('/api/auth/logout', { headers });
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/auth/login', {
|
||||
headers,
|
||||
data: { username: 'testadmin', password },
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await (await request.get('/api/auth/security')).json()).events[0].mail_status,
|
||||
)
|
||||
.toBe('sent');
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[1]).toContain('HamHam-TestBrowser');
|
||||
expect(messages[1]).toContain('2001:db8::123');
|
||||
expect(messages[1]).not.toContain('1.2.3.4');
|
||||
expect(messages[1]).not.toContain(password);
|
||||
expect(
|
||||
(
|
||||
await request.put('/api/settings/mail', {
|
||||
headers,
|
||||
data: {
|
||||
password,
|
||||
email: 'admin@example.org',
|
||||
loginNotifications: false,
|
||||
mail: { ...mail, password: '' },
|
||||
},
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
expect((await request.post('/api/settings/mail/test', { headers })).status()).toBe(200);
|
||||
expect(messages).toHaveLength(3); // Blank password preserved the encrypted credential.
|
||||
await request.post('/api/auth/logout', { headers });
|
||||
await request.post('/api/auth/login', { headers, data: { username: 'testadmin', password } });
|
||||
expect((await (await request.get('/api/auth/security')).json()).events[0].mail_status).toBe(
|
||||
'disabled',
|
||||
);
|
||||
expect(messages).toHaveLength(3);
|
||||
} finally {
|
||||
await db`UPDATE admin_security SET login_notifications = false`;
|
||||
await db`DELETE FROM mail_config`;
|
||||
await db.end();
|
||||
await new Promise<void>((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
authenticator,
|
||||
createRecoveryCodes,
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
recoveryIndex,
|
||||
totpCounter,
|
||||
} from '../../src/lib/auth-crypto';
|
||||
import { loginContext } from '../../src/lib/login-context';
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
describe('Admin two-factor primitives', () => {
|
||||
const secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
|
||||
it('matches RFC 6238 SHA1 vectors truncated to six digits', () => {
|
||||
expect(authenticator(secret).generate({ timestamp: 59000 })).toBe('287082');
|
||||
expect(authenticator(secret).generate({ timestamp: 1111111109000 })).toBe('081804');
|
||||
});
|
||||
it('accepts only the adjacent time window and prevents replay', () => {
|
||||
const time = 1700000000000,
|
||||
counter = Math.floor(time / 30000);
|
||||
const code = authenticator(secret).generate({ timestamp: time });
|
||||
expect(totpCounter(secret, code, -1, time)).toBe(counter);
|
||||
expect(totpCounter(secret, code, counter, time)).toBeNull();
|
||||
expect(
|
||||
totpCounter(secret, authenticator(secret).generate({ timestamp: time - 90000 }), -1, time),
|
||||
).toBeNull();
|
||||
expect(totpCounter(secret, 'abcdef', -1, time)).toBeNull();
|
||||
});
|
||||
it('encrypts secrets with authenticated purpose binding and randomized IVs', () => {
|
||||
vi.stubEnv('AUTH_ENCRYPTION_KEY', 'ab'.repeat(32));
|
||||
const encrypted = encryptSecret(secret, 'totp:admin');
|
||||
expect(encrypted).not.toContain(secret);
|
||||
expect(encryptSecret(secret, 'totp:admin')).not.toBe(encrypted);
|
||||
expect(decryptSecret(encrypted, 'totp:admin')).toBe(secret);
|
||||
expect(() => decryptSecret(encrypted, 'smtp')).toThrow();
|
||||
const parts = encrypted.split('.');
|
||||
const data = Buffer.from(parts[2], 'base64url');
|
||||
data[0] ^= 1;
|
||||
parts[2] = data.toString('base64url');
|
||||
expect(() => decryptSecret(parts.join('.'), 'totp:admin')).toThrow();
|
||||
});
|
||||
it('requires a persistent 256-bit encryption key', () => {
|
||||
vi.stubEnv('AUTH_ENCRYPTION_KEY', '');
|
||||
expect(() => encryptSecret(secret, 'test')).toThrow(/AUTH_ENCRYPTION_KEY/);
|
||||
});
|
||||
it('generates independent recovery codes and checks their hashes', () => {
|
||||
const { codes, hashes } = createRecoveryCodes();
|
||||
expect(new Set(codes).size).toBe(10);
|
||||
expect(recoveryIndex(hashes, codes[2].toLowerCase())).toBe(2);
|
||||
expect(
|
||||
recoveryIndex(
|
||||
hashes.filter((_, n) => n !== 2),
|
||||
codes[2],
|
||||
),
|
||||
).toBe(-1);
|
||||
expect(recoveryIndex(hashes, '000000')).toBe(-1);
|
||||
expect(hashes).not.toContain(codes[0]);
|
||||
});
|
||||
});
|
||||
describe('Reported login origin', () => {
|
||||
it('does not trust the client-controlled first forwarded address', () => {
|
||||
vi.stubEnv('TRUST_PROXY', 'false');
|
||||
const context = loginContext(
|
||||
new Request('https://hamham.test', {
|
||||
headers: { 'x-forwarded-for': '1.2.3.4, 2001:db8::1', 'user-agent': 'Test browser' },
|
||||
}),
|
||||
);
|
||||
expect(context.ip).toBe('2001:db8::1');
|
||||
expect(context.ipSource).toContain('nicht verifiziert');
|
||||
expect(
|
||||
loginContext(
|
||||
new Request('https://hamham.test', { headers: { 'x-forwarded-for': 'not-an-ip' } }),
|
||||
).ip,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user