auth: two-step sign-in (TOTP) for admins, with recovery codes
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1 step) with node:crypto only, checked against the RFC's test vectors. Migration 0003: users.totp_recovery_hashes and totp_last_step. Admins turn it on from My account (the header's "Account"): scan the QR or type the key, confirm a code, get 10 recovery codes once. Login asks for the code only after the password is right (401 totp_required, no session); a wrong code is a failed login (rate limit, audit). A step signs in once (totp_last_step updated atomically) and a recovery code once (array_remove guarded by any()). Off and new codes need the password; another admin can turn it off for a lost phone, which signs that user out. Users list shows it (twoStep). Also, found by the full e2e chain on one shared database: - Linphone tokens: only the exact token issued is accepted. The last base64 character can carry ignored bits, so another spelling decoded to the same signed token. - Test scripts no longer assume a fresh system: m3-t3 picks Dan in the test box, m8-check dials whatever slot the park took (findslot = next), m10-t4 uses the admin's existing extension, m11-t1 clears the public IP on its own first (local nets are in the transports while one is set, and transports change only on a restart). e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes. Task: M11-T1
This commit is contained in:
1 parent
65e777ab41
commit
8c750c67ca
31 files changed
+4842
-43
No files matched your search
@@ -19,6 +19,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
role: "USER-DEFINED:user_role|NO|'user'::user_role",
|
||||
is_active: 'boolean|NO|true',
|
||||
totp_secret_enc: 'text|YES|',
|
||||
totp_recovery_hashes: "ARRAY:_text|NO|'{}'::text[]",
|
||||
totp_last_step: 'bigint|YES|',
|
||||
must_change_password: 'boolean|NO|false',
|
||||
last_login_at: 'timestamp with time zone|YES|',
|
||||
...ts,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T1: two-step sign-in for admins (08 §2.4).
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { stepAt, totpCode } from '../src/auth/totp.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('two-step sign-in', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let cookie: string;
|
||||
let secret: string;
|
||||
let recovery: string[];
|
||||
const req = (method: string, url: string, payload?: unknown, c = cookie) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie: c },
|
||||
});
|
||||
const login = (totp?: string): Promise<LightMyRequestResponse> =>
|
||||
t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { email: ADMIN.email, password: ADMIN.password, ...(totp ? { totp } : {}) },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
beforeEach(() => resetLoginLimiter());
|
||||
|
||||
it('turns on with a code from the scanned secret; gives 10 recovery codes once', async () => {
|
||||
expect((await req('GET', '/api/auth/totp')).json()).toEqual({
|
||||
enabled: false,
|
||||
recoveryCodesLeft: 0,
|
||||
});
|
||||
const setup = (await req('POST', '/api/auth/totp/setup', {})).json();
|
||||
secret = setup.secret;
|
||||
expect(setup.otpauthUrl).toMatch(/^otpauth:\/\/totp\/.+\?secret=[A-Z2-7]{32}&issuer=/);
|
||||
expect(setup.qr).toMatch(/^data:image\/png;base64,/);
|
||||
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000' });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const ok = await req('POST', '/api/auth/totp/enable', {
|
||||
secret,
|
||||
code: totpCode(secret, stepAt()),
|
||||
});
|
||||
expect(ok.statusCode, ok.body).toBe(200);
|
||||
recovery = ok.json().recoveryCodes;
|
||||
expect(recovery).toHaveLength(10);
|
||||
expect((await req('GET', '/api/auth/totp')).json()).toEqual({
|
||||
enabled: true,
|
||||
recoveryCodesLeft: 10,
|
||||
});
|
||||
expect(
|
||||
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456' })).statusCode,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
it('login asks for the code after the password; a code works once', async () => {
|
||||
const noCode = await login();
|
||||
expect(noCode.statusCode).toBe(401);
|
||||
expect(noCode.json().error.code).toBe('totp_required');
|
||||
expect(noCode.headers['set-cookie']).toBeUndefined();
|
||||
expect((await login('000000')).json().error.code).toBe('totp_invalid');
|
||||
// The step used to turn it on is spent; the next one (within the ±1 window) works, once.
|
||||
const next = totpCode(secret, stepAt() + 1);
|
||||
expect((await login(next)).statusCode).toBe(200);
|
||||
expect((await login(next)).json().error.code).toBe('totp_invalid');
|
||||
// A wrong password never reveals that a code is needed.
|
||||
const wrongPw = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { email: ADMIN.email, password: 'nope-nope-nope' },
|
||||
});
|
||||
expect(wrongPw.json().error.code).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('a recovery code works once, typed any way', async () => {
|
||||
const code = recovery[0]!;
|
||||
expect((await login(code.toUpperCase())).statusCode).toBe(200);
|
||||
expect((await login(code)).statusCode).toBe(401);
|
||||
expect((await req('GET', '/api/auth/totp')).json().recoveryCodesLeft).toBe(9);
|
||||
});
|
||||
|
||||
it('users list shows it; another admin can turn it off; off needs the password', async () => {
|
||||
const list = (await req('GET', '/api/users')).json().items;
|
||||
expect(list.find((u: { email: string }) => u.email === ADMIN.email).twoStep).toBe(true);
|
||||
expect((await req('POST', '/api/auth/totp/disable', { password: 'wrong' })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
const off = await req('POST', '/api/auth/totp/disable', { password: ADMIN.password });
|
||||
expect(off.statusCode).toBe(200);
|
||||
expect((await login()).statusCode).toBe(200);
|
||||
// the admin reset (for a lost phone)
|
||||
const me = (await req('GET', '/api/auth/me')).json().user;
|
||||
expect((await req('POST', `/api/users/${me.id}/reset-two-step`, {})).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('is for admins only', async () => {
|
||||
cookie = await t.login(ADMIN.email, ADMIN.password); // the reset ended my sessions
|
||||
const u = (
|
||||
await req('POST', '/api/users', { email: 'plain@example.com', displayName: 'Plain' })
|
||||
).json();
|
||||
await t.clearMustChange('plain@example.com');
|
||||
const plain = await t.login('plain@example.com', u.temporaryPassword);
|
||||
expect((await req('POST', '/api/auth/totp/setup', {}, plain)).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user