dragonflight/services/mam-api/test/routes/totp.test.js

144 lines
7 KiB
JavaScript
Raw Normal View History

// Integration test for the TOTP two-step login + recovery codes.
//
// Mounts the real auth router with a session store on the throwaway test DB.
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
// → complete with a generated code → and the recovery-code single-use path.
// Skips when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { generateToken } from '../../src/auth/totp.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithAuth(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
process.env.SESSION_SECRET = 'test';
const ConnectPg = (await import('connect-pg-simple')).default(session);
const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use(session({
store: new ConnectPg({ pool, tableName: 'sessions' }),
secret: 'test', name: 'dragonflight.sid',
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1/auth', authRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
const J = (cookie, body) => ({
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body),
});
async function loginPassword(baseUrl, username, password) {
const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
return { r, body: await r.json().catch(() => ({})), cookie };
}
test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
// 1. Password login (no TOTP yet) → 200 with a session cookie.
const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
assert.equal(first.r.status, 200);
assert.ok(!first.body.mfa_required);
const cookie = first.cookie;
// 2. Enroll: setup returns a secret; enable confirms with a live code.
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
assert.match(setup.secret, /^[A-Z2-7]+$/);
const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
assert.equal(enableRes.status, 200);
const enableBody = await enableRes.json();
assert.equal(enableBody.enabled, true);
assert.equal(enableBody.recovery_codes.length, 10);
// 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
assert.equal(second.r.status, 200);
assert.equal(second.body.mfa_required, true);
assert.ok(second.body.ticket);
assert.ok(!second.cookie, 'no session cookie should be set before the second factor');
// 4. Wrong code → 401; the ticket is now spent.
const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
assert.equal(bad.status, 401);
// 5. New login + correct code → 200 with a session cookie.
const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
assert.equal(ok.status, 200);
assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);
await close();
} finally { await pool.end(); }
});
test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
const recovery = enableBody.recovery_codes[0];
// Use a recovery code to complete a fresh login.
const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
assert.equal(use1.status, 200, 'recovery code should complete login once');
// The same recovery code must NOT work a second time.
const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
assert.equal(use2.status, 401, 'a spent recovery code must be rejected');
await close();
} finally { await pool.end(); }
});
test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));
// Disabling requires the password.
const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
assert.equal(wrongPw.status, 400);
const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
assert.equal(disabled.status, 204);
// Password login is single-factor again.
const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
assert.equal(relog.r.status, 200);
assert.ok(!relog.body.mfa_required);
await close();
} finally { await pool.end(); }
});