Files
picpeak/backend/__tests__/routes/adminMfa.test.js
T
Paul NothaftandPaul Nothaft cdde937d7f fix(backend): reject a replayed TOTP code within its validity window (#1389)
* fix(backend): reject a replayed TOTP code within its validity window

verifyTotp() was stateless — otplib's window:1 tolerance meant the
same 6-digit code could complete two independent logins inside its
~90s validity window. Track each admin's last-consumed step and
reject a code that doesn't advance past it.

* fix(backend): make the TOTP replay-tracking persist atomic

verifyTotpEncryptedStep() read two_factor_last_used_step, then a plain
UPDATE wrote the new step with no conditional guard — two concurrent
requests carrying the same captured code could both pass the check
before either UPDATE landed. The persist is now a conditional UPDATE
(only advances the step, checked via affected-row count), so a losing
concurrent request is correctly treated as a replay.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-09-11 10:40:27 +02:00

559 lines
23 KiB
JavaScript

/**
* HTTP-level tests for the admin TOTP MFA feature (#738).
*
* Two surfaces:
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
* /api/admin/auth (src/routes/adminAuth.js).
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
* (src/routes/auth.js, mounted /api/auth).
*
* Uses the same real-SQLite harness as the CRM route tests
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
* generated in-test via otplib's authenticator against the secret the
* /setup endpoint returns in plaintext.
*
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
* first require of db.js — mirror adminCrmAuth.test.js exactly.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
// tests don't need a token. Be explicit so a leaked env can't flip it on.
delete process.env.RECAPTCHA_SECRET_KEY;
const request = require('supertest');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
const mfaService = require('../../src/services/mfaService');
jest.setTimeout(120000);
let db;
let cleanup;
let adminApp; // /api/admin/auth (enrollment)
let authApp; // /api/auth (login challenge)
/**
* Seed a bare admin (password known) and return its id + login creds.
* seedMinimal always creates username 'tester'; we need distinct rows per
* scenario, so insert directly with a unique username/email.
*/
async function seedAdmin({ username, superAdmin = false } = {}) {
const password = 'correct-horse';
const passwordHash = await bcrypt.hash(password, 4);
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
const row = {
username: uname,
email: `${uname}@example.com`,
password_hash: passwordHash,
must_change_password: false,
is_active: true,
created_at: new Date(),
};
if (superAdmin) {
const role = await db('roles').where({ name: 'super_admin' }).first();
if (!role) throw new Error('super_admin role not seeded');
row.role_id = role.id;
}
const inserted = await db('admin_users').insert(row).returning('id');
const id = inserted[0]?.id ?? inserted[0];
return { id, username: uname, password };
}
/** Run the full setup→enable enrollment against the live app. Returns
* the plaintext TOTP secret (for later login codes) and recovery codes. */
async function enroll(adminId) {
const token = mintAdminToken(adminId);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(setup.status).toBe(200);
const secret = setup.body.secret;
const enable = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(enable.status).toBe(200);
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.secret).toEqual(expect.any(String));
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
// Not yet enabled: status must still report disabled.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
// And the row stores an encrypted secret (not the plaintext one).
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeTruthy();
expect(row.two_factor_secret).not.toBe(res.body.secret);
expect(Number(row.two_factor_enabled)).toBe(0);
});
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
const admin = await seedAdmin();
const { recoveryCodes, token } = await enroll(admin.id);
expect(Array.isArray(recoveryCodes)).toBe(true);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.status).toBe(200);
expect(status.body.enabled).toBe(true);
expect(status.body.recoveryCodesRemaining).toBe(10);
expect(status.body.enrolledAt).toBeTruthy();
});
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
const valid = authenticator.generate(setup.body.secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrong });
expect(res.status).toBe(400);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
});
it('enable before setup is rejected', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '123456' });
// No provisional secret → ValidationError (400).
expect(res.status).toBe(400);
});
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
expect(noToken.status).toBe(401);
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
expect(setup.status).toBe(401);
});
// Regression guard for #735: super_admin used to be blocked from enrolling.
// Enrollment operates on req.admin.id and is role-agnostic — assert a
// super_admin can complete the full setup→enable flow.
it('#735 regression — a super_admin can enroll in MFA', async () => {
const admin = await seedAdmin({ superAdmin: true });
const { recoveryCodes, token } = await enroll(admin.id);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(true);
});
});
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
it('requires a valid code; a wrong code is rejected and state persists', async () => {
const admin = await seedAdmin();
const { token } = await enroll(admin.id);
const bad = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '000000' });
expect(bad.status).toBe(400);
const stillOn = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(stillOn.body.enabled).toBe(true);
});
it('a valid TOTP disables MFA and clears the stored secret', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(res.status).toBe(200);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
expect(status.body.recoveryCodesRemaining).toBe(0);
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
});
// Concurrency regression: a plain UPDATE with no conditional guard let two
// requests carrying the same captured code both read the same
// two_factor_last_used_step and both persist, defeating replay protection.
// The guarded UPDATE (mfaService.persistTotpStep) makes only the first
// writer's affected-row count > 0; the loser must be rejected.
it('two concurrent disable requests with the SAME captured code: only one succeeds', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const code = authenticator.generate(secret);
const [r1, r2] = await Promise.all([
request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
});
});
describe('MFA regenerate recovery codes — /api/admin/auth/mfa/recovery-codes', () => {
it('a valid TOTP regenerates the recovery codes and persists the step', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(res.status).toBe(200);
expect(res.body.recoveryCodes).toHaveLength(10);
});
it('a wrong code is rejected (400)', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const valid = authenticator.generate(secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrong });
expect(res.status).toBe(400);
});
// Concurrency regression (see the disable test above for the mechanism):
// this is the endpoint called out as the worst lost-update case, since it
// both rotates the recovery codes and (previously) persisted the step in
// one unconditional UPDATE.
it('two concurrent regenerations with the SAME captured code: only one succeeds', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const code = authenticator.generate(secret);
const [r1, r2] = await Promise.all([
request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
request(adminApp)
.post('/api/admin/auth/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
.send({ code }),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
const winner = r1.status === 200 ? r1 : r2;
expect(winner.body.recoveryCodes).toHaveLength(10);
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_last_used_step).not.toBeNull();
});
});
describe('mfaService.persistTotpStep — atomic replay-tracking persist', () => {
// Deterministic simulation of the race: two "concurrent" requests that
// read the SAME two_factor_last_used_step and computed the SAME totpStep
// from the same captured code. Calling persistTotpStep twice in a row with
// that identical totpStep reproduces exactly the DB-level outcome of a
// true race, without relying on event-loop timing.
it('the second writer with the same totpStep affects 0 rows and is rejected', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const row = await db('admin_users').where({ id: admin.id }).first();
const code = authenticator.generate(secret);
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
expect(totpStep).toEqual(expect.any(Number));
const first = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
expect(first).toBe(true);
// The row's two_factor_last_used_step has now already advanced to
// totpStep by the time this "losing" write runs — the guard condition
// (whereNull OR < totpStep) is false, so 0 rows are affected.
const second = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
expect(second).toBe(false);
const after = await db('admin_users').where({ id: admin.id }).first();
expect(Number(after.two_factor_last_used_step)).toBe(totpStep);
});
it('succeeds when the new step advances past the current one', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const row = await db('admin_users').where({ id: admin.id }).first();
const code = authenticator.generate(secret);
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
const ok = await mfaService.persistTotpStep(db, admin.id, totpStep, {});
expect(ok).toBe(true);
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
const nextCode = nextStepAuthenticator.generate(secret);
const nextStep = mfaService.verifyTotpEncryptedStep(nextCode, row.two_factor_secret, totpStep);
expect(nextStep).toBeGreaterThan(totpStep);
const advanced = await mfaService.persistTotpStep(db, admin.id, nextStep, {});
expect(advanced).toBe(true);
});
});
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBe(true);
expect(res.body.mfaToken).toEqual(expect.any(String));
expect(res.body.user).toBeUndefined(); // no completed session
// No admin auth cookie should have been set on the challenge response.
const cookies = res.headers['set-cookie'] || [];
expect(cookies.join(';')).not.toMatch(/adminToken/i);
});
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
const admin = await seedAdmin();
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBeUndefined();
expect(res.body.user).toBeDefined();
expect(res.body.user.username).toBe(admin.username);
});
it('login/mfa with a valid TOTP completes the session', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const { mfaToken } = challenge.body;
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken, code: authenticator.generate(secret) });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
expect(res.body.user.id).toBe(admin.id);
});
// GHSA-qcwx-r25m-j869: verifyTotp() was stateless, so otplib's window:1
// tolerance let the same 6-digit code complete two independent logins
// within its ~90s validity window. mfaService now tracks each admin's
// last-consumed TOTP step and rejects a code that doesn't advance past it.
it('#GHSA-qcwx-r25m-j869 — a TOTP code cannot be replayed into a second login', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const code = authenticator.generate(secret);
// First use of the code completes a login.
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const first = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c1.body.mfaToken, code });
expect(first.status).toBe(200);
expect(first.body.user).toBeDefined();
// Replaying the SAME code for an independent second login must fail,
// even though otplib's window:1 tolerance still considers it valid.
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const replay = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c2.body.mfaToken, code });
expect(replay.status).toBe(401);
expect(replay.body.code).toBe('MFA_INVALID');
expect(replay.body.user).toBeUndefined();
// A freshly generated code for the NEXT TOTP step is not a replay and
// succeeds. Generated via a cloned authenticator with a future epoch
// rather than mocking Date.now(), so mfaService's own step computation
// (real Date.now()) still lands the match one step ahead.
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
const nextCode = nextStepAuthenticator.generate(secret);
const c3 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const third = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c3.body.mfaToken, code: nextCode });
expect(third.status).toBe(200);
expect(third.body.user).toBeDefined();
expect(third.body.user.id).toBe(admin.id);
});
// Concurrency regression: verifyTotpEncryptedStep()'s "does this advance"
// check was read against a snapshot taken earlier in the request, then a
// PLAIN update persisted the step — two concurrent requests carrying the
// SAME captured code could both pass the check and both complete a login
// before either write landed. The persist is now a conditional UPDATE
// (mfaService.persistTotpStep), so only the first writer's affected-row
// count is > 0 and the other is correctly treated as a replay.
it('two concurrent login/mfa requests with the SAME captured code: only one completes', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const code = authenticator.generate(secret);
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const [r1, r2] = await Promise.all([
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c1.body.mfaToken, code }),
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c2.body.mfaToken, code }),
]);
expect([r1.status, r2.status].sort()).toEqual([200, 401]);
const winner = r1.status === 200 ? r1 : r2;
const loser = r1.status === 200 ? r2 : r1;
expect(winner.body.user).toBeDefined();
expect(loser.body.user).toBeUndefined();
expect(loser.body.code).toBe('MFA_INVALID');
});
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const valid = authenticator.generate(secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
expect(res.status).toBe(401);
expect(res.body.code).toBe('MFA_INVALID');
expect(res.body.user).toBeUndefined();
});
it('a recovery code logs in and is then single-use (second use fails)', async () => {
const admin = await seedAdmin();
const { recoveryCodes } = await enroll(admin.id);
const recovery = recoveryCodes[0];
// First challenge + recovery-code exchange succeeds.
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const first = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c1.body.mfaToken, code: recovery });
expect(first.status).toBe(200);
expect(first.body.user).toBeDefined();
// recoveryCodesRemaining dropped by one.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
expect(status.body.recoveryCodesRemaining).toBe(9);
// Second use of the SAME recovery code must fail.
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const second = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c2.body.mfaToken, code: recovery });
expect(second.status).toBe(401);
expect(second.body.code).toBe('MFA_INVALID');
});
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
expect(res.status).toBe(401);
});
});