Files
umami/tests/api/two-factor.spec.ts
T

349 lines
14 KiB
TypeScript

import { expect, test } from './fixtures';
import { login } from './helpers/auth';
import { createUser, deleteUser } from './helpers/entities';
import { nextCode, wrongCode } from './helpers/totp';
/**
* Full two-factor lifecycle on a throwaway user. TOTP codes are single-use and
* bound to 30 s time steps, so several tests wait for the next step; the
* describe timeout is raised accordingly.
*/
test.describe('Two-factor authentication', () => {
test.describe.configure({ mode: 'serial', timeout: 120_000 });
let userId = '';
let username = '';
let password = '';
/** Regular session token for the throwaway user (issued before 2FA was enabled). */
let sessionToken = '';
let secret = '';
let lastCode = '';
let backupCodes: string[] = [];
let partialToken = '';
test.beforeAll(async ({ admin, api }) => {
const created = await createUser(admin);
userId = created.id;
username = created.username;
password = created.password;
sessionToken = await login(api, { username, password });
});
test.afterAll(async ({ admin }) => {
if (userId) {
// Clear any 2FA state and lockouts before removing the user.
await admin.post(`/api/admin/users/${userId}/2fa`, { required: false });
await admin.del(`/api/admin/users/${userId}/2fa`);
await deleteUser(admin, userId);
}
});
test('GET /api/2fa/status reports the initial state', async ({ api, apiKey }) => {
const session = api.bearer(sessionToken);
const response = await session.get('/api/2fa/status');
const anonymous = await api.get('/api/2fa/status');
const viaApiKey = await apiKey.get('/api/2fa/status');
expect(response.status).toBe(200);
expect(response.body).toEqual({
isEnabled: false,
isRequired: false,
requiredReason: null,
isConfigured: true,
globalRequired: false,
});
expect(anonymous.status).toBe(401);
// API keys are blocked on /api/2fa/*.
expect(viaApiKey.status).toBe(401);
});
test('POST /api/2fa/setup/confirm rejects invalid bodies and missing setups', async ({
api,
apiKey,
}) => {
const session = api.bearer(sessionToken);
const tooShort = await session.post('/api/2fa/setup/confirm', { token: '123' });
const missing = await session.post('/api/2fa/setup/confirm', {});
const noSetup = await session.post('/api/2fa/setup/confirm', { token: '000000' });
const anonymous = await api.post('/api/2fa/setup/confirm', { token: '000000' });
const viaApiKey = await apiKey.post('/api/2fa/setup/confirm', { token: '000000' });
expect(tooShort.status).toBe(400);
expect(missing.status).toBe(400);
expect(noSetup.status).toBe(400);
expect(noSetup.body.error.code).toBe('two-factor-error-no-pending-setup');
expect(anonymous.status).toBe(401);
expect(viaApiKey.status).toBe(401);
});
test('POST /api/2fa/setup/initiate returns a secret and QR code', async ({ api, apiKey }) => {
const session = api.bearer(sessionToken);
const response = await session.post('/api/2fa/setup/initiate');
const anonymous = await api.post('/api/2fa/setup/initiate');
const viaApiKey = await apiKey.post('/api/2fa/setup/initiate');
expect(response.status).toBe(200);
expect(response.body).toEqual({
qrCodeDataUrl: expect.stringMatching(/^data:image\/png;base64,/),
manualKey: expect.stringMatching(/^[A-Z2-7]+=*$/),
});
expect(anonymous.status).toBe(401);
expect(viaApiKey.status).toBe(401);
// Status stays disabled until the setup is confirmed.
const status = await session.get('/api/2fa/status');
expect(status.body.isEnabled).toBe(false);
});
test('POST /api/2fa/setup/cancel discards the pending setup', async ({ api, apiKey }) => {
const session = api.bearer(sessionToken);
const response = await session.post('/api/2fa/setup/cancel');
const again = await session.post('/api/2fa/setup/cancel');
const confirm = await session.post('/api/2fa/setup/confirm', { token: '000000' });
const anonymous = await api.post('/api/2fa/setup/cancel');
const viaApiKey = await apiKey.post('/api/2fa/setup/cancel');
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true });
// Cancelling is idempotent.
expect(again.status).toBe(200);
expect(again.body).toEqual({ ok: true });
expect(confirm.status).toBe(400);
expect(confirm.body.error.code).toBe('two-factor-error-no-pending-setup');
expect(anonymous.status).toBe(401);
expect(viaApiKey.status).toBe(401);
});
test('POST /api/2fa/setup/initiate can be repeated and rejects wrong codes', async ({ api }) => {
const session = api.bearer(sessionToken);
const response = await session.post('/api/2fa/setup/initiate');
expect(response.status).toBe(200);
secret = response.body.manualKey;
// A single wrong attempt (the lockout threshold is five).
const invalid = await session.post('/api/2fa/setup/confirm', { token: wrongCode(secret) });
expect(invalid.status).toBe(400);
expect(invalid.body.error).toMatchObject({
code: 'two-factor-error-invalid-code',
message: 'Invalid verification code',
status: 400,
});
});
test('POST /api/2fa/setup/confirm enables 2FA and returns backup codes', async ({ api }) => {
const session = api.bearer(sessionToken);
lastCode = await nextCode(secret);
const response = await session.post('/api/2fa/setup/confirm', { token: lastCode });
expect(response.status).toBe(200);
expect(response.body.backupCodes).toHaveLength(10);
expect(new Set(response.body.backupCodes).size).toBe(10);
for (const code of response.body.backupCodes) {
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
}
backupCodes = response.body.backupCodes;
const status = await session.get('/api/2fa/status');
expect(status.body).toMatchObject({ isEnabled: true, isRequired: false });
// Confirming again is rejected: there is no longer a pending setup.
const again = await session.post('/api/2fa/setup/confirm', { token: lastCode });
expect(again.status).toBe(400);
expect(again.body.error.code).toBe('two-factor-error-no-pending-setup');
});
test('POST /api/2fa/setup/initiate rejects users with 2FA enabled', async ({ api }) => {
const session = api.bearer(sessionToken);
const response = await session.post('/api/2fa/setup/initiate');
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('two-factor-error-already-enabled');
});
test('POST /api/auth/login returns a partial token when 2FA is enabled', async ({ api }) => {
const response = await api.post('/api/auth/login', { username, password });
expect(response.status).toBe(200);
expect(response.body).toEqual({ requiresTwoFactor: true, partialToken: expect.any(String) });
partialToken = response.body.partialToken;
// NOTE: the partial token is currently accepted as a full session by
// checkAuth (it only looks at payload.userId, not payload.type), which
// bypasses the second factor. Not asserted here; reported as a bug.
});
test('POST /api/2fa/verify rejects missing, invalid and non-partial tokens', async ({
api,
apiKey,
}) => {
const session = api.bearer(sessionToken);
const body = { token: '000000' };
const missing = await api.post('/api/2fa/verify', body);
const garbage = await api.bearer('not-a-token').post('/api/2fa/verify', body);
const fullSession = await session.post('/api/2fa/verify', body);
const viaApiKey = await apiKey.post('/api/2fa/verify', body);
expect(missing.status).toBe(401);
expect(missing.body.error.code).toBe('two-factor-error-missing-token');
expect(garbage.status).toBe(401);
expect(garbage.body.error.code).toBe('two-factor-error-invalid-partial-token');
expect(fullSession.status).toBe(401);
expect(fullSession.body.error.code).toBe('two-factor-error-invalid-partial-token');
expect(viaApiKey.status).toBe(401);
});
test('POST /api/2fa/verify validates the body', async ({ api }) => {
const partial = api.bearer(partialToken);
const empty = await partial.post('/api/2fa/verify', {});
const both = await partial.post('/api/2fa/verify', { token: '000000', backupCode: 'x' });
const extra = await partial.post('/api/2fa/verify', { token: '000000', extra: true });
const tooShort = await partial.post('/api/2fa/verify', { token: '12345' });
const emptyBackup = await partial.post('/api/2fa/verify', { backupCode: '' });
expect(empty.status).toBe(400);
expect(both.status).toBe(400);
expect(extra.status).toBe(400);
expect(tooShort.status).toBe(400);
expect(emptyBackup.status).toBe(400);
});
test('POST /api/2fa/verify rejects a code that was already used', async ({ api }) => {
// The code accepted by setup/confirm stays blocked for 90 s.
const response = await api.bearer(partialToken).post('/api/2fa/verify', { token: lastCode });
expect(response.status).toBe(400);
expect(response.body.error).toMatchObject({
code: 'two-factor-error-code-used',
message: 'Code already used',
});
});
test('POST /api/2fa/verify completes the login with a TOTP code', async ({ api }) => {
const code = await nextCode(secret, lastCode);
const response = await api.bearer(partialToken).post('/api/2fa/verify', { token: code });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
token: expect.any(String),
user: { id: userId, username, role: 'user', isAdmin: false, teams: expect.any(Array) },
});
expect(response.body).not.toHaveProperty('requiresTwoFactor');
lastCode = code;
// The issued token is a full session.
const me = await api.bearer(response.body.token).get('/api/me');
expect(me.status).toBe(200);
expect(me.body.user.id).toBe(userId);
});
test('POST /api/2fa/verify accepts a backup code once', async ({ api }) => {
const loginResponse = await api.post('/api/auth/login', { username, password });
expect(loginResponse.body.requiresTwoFactor).toBe(true);
const partial = api.bearer(loginResponse.body.partialToken);
const response = await partial.post('/api/2fa/verify', { backupCode: backupCodes[0] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ token: expect.any(String), user: { id: userId } });
// Backup codes are single-use (this counts as one failed attempt).
const reused = await api
.bearer((await api.post('/api/auth/login', { username, password })).body.partialToken)
.post('/api/2fa/verify', { backupCode: backupCodes[0] });
expect(reused.status).toBe(400);
expect(reused.body.error.code).toBe('two-factor-error-invalid-backup-code');
});
test('POST /api/2fa/disable validates the body and password', async ({ api, apiKey }) => {
const session = api.bearer(sessionToken);
const missingToken = await session.post('/api/2fa/disable', { password });
const missingPassword = await session.post('/api/2fa/disable', { token: '000000' });
const shortToken = await session.post('/api/2fa/disable', { password, token: '123' });
const wrongPassword = await session.post('/api/2fa/disable', {
password: 'wrong-password',
token: '000000',
});
const anonymous = await api.post('/api/2fa/disable', { password, token: '000000' });
const viaApiKey = await apiKey.post('/api/2fa/disable', { password, token: '000000' });
expect(missingToken.status).toBe(400);
expect(missingPassword.status).toBe(400);
expect(shortToken.status).toBe(400);
expect(wrongPassword.status).toBe(400);
expect(wrongPassword.body.error).toMatchObject({
code: 'two-factor-error-incorrect-password',
message: 'Incorrect password',
});
expect(anonymous.status).toBe(401);
expect(viaApiKey.status).toBe(401);
});
test('POST /api/2fa/disable is forbidden while 2FA is required', async ({ api, admin }) => {
const session = api.bearer(sessionToken);
const required = await admin.post(`/api/admin/users/${userId}/2fa`, { required: true });
expect(required.status).toBe(200);
try {
const status = await session.get('/api/2fa/status');
const response = await session.post('/api/2fa/disable', { password, token: '000000' });
expect(status.body).toMatchObject({
isEnabled: true,
isRequired: true,
requiredReason: 'user',
});
expect(response.status).toBe(403);
expect(response.body.error).toMatchObject({
code: 'two-factor-error-disable-not-allowed',
message: '2FA is required and cannot be disabled',
});
} finally {
const optional = await admin.post(`/api/admin/users/${userId}/2fa`, { required: false });
expect(optional.status).toBe(200);
}
});
test('POST /api/2fa/disable turns 2FA off', async ({ api, admin }) => {
const session = api.bearer(sessionToken);
const code = await nextCode(secret, lastCode);
const response = await session.post('/api/2fa/disable', { password, token: code });
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true });
lastCode = code;
const status = await session.get('/api/2fa/status');
const adminView = await admin.get(`/api/admin/users/${userId}/2fa`);
const loginResponse = await api.post('/api/auth/login', { username, password });
const again = await session.post('/api/2fa/disable', { password, token: code });
expect(status.body).toMatchObject({ isEnabled: false, isRequired: false });
expect(adminView.body).toEqual({ isEnabled: false });
// Login is back to a single step.
expect(loginResponse.status).toBe(200);
expect(loginResponse.body.token).toEqual(expect.any(String));
expect(loginResponse.body).not.toHaveProperty('requiresTwoFactor');
expect(again.status).toBe(400);
expect(again.body.error.code).toBe('two-factor-error-not-enabled');
});
});