Admins and supervisors without 2FA are sent a code at their next login, and verifying it turns 2FA on. They can change the channel but not turn it off. Older sessions without 2FA are rejected with TWO_FACTOR_REQUIRED, and promoting a user ends their sessions so they enrol at next login. The requirement is skipped when no email or WhatsApp channel can deliver a code, and TWO_FACTOR_REQUIRED_ROLES=false switches it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
process.env.JWT_SECRET = 'test-secret';
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ─────────
|
|
// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }.
|
|
function matches(row, where = {}) {
|
|
return Object.entries(where).every(([key, cond]) => {
|
|
const value = row[key];
|
|
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
|
|
if ('gt' in cond) return value > cond.gt;
|
|
if ('lt' in cond) return value < cond.lt;
|
|
}
|
|
if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime();
|
|
return value === cond;
|
|
});
|
|
}
|
|
|
|
function applyData(row, data) {
|
|
for (const [key, val] of Object.entries(data)) {
|
|
row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val;
|
|
}
|
|
}
|
|
|
|
function makeModel(defaults, relations = {}) {
|
|
let rows = [];
|
|
let seq = 0;
|
|
const withRelations = (row, include) => {
|
|
if (!row) return null;
|
|
const out = { ...row };
|
|
for (const key of Object.keys(include || {})) out[key] = relations[key](row);
|
|
return out;
|
|
};
|
|
return {
|
|
_rows: () => rows,
|
|
_reset: () => { rows = []; },
|
|
create: async ({ data }) => {
|
|
const row = { id: `id-${++seq}`, ...defaults(), ...data };
|
|
rows.push(row);
|
|
return { ...row };
|
|
},
|
|
findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include),
|
|
update: async ({ where, data }) => {
|
|
const row = rows.find(r => matches(r, where));
|
|
applyData(row, data);
|
|
return { ...row };
|
|
},
|
|
updateMany: async ({ where, data }) => {
|
|
const hit = rows.filter(r => matches(r, where));
|
|
hit.forEach(r => applyData(r, data));
|
|
return { count: hit.length };
|
|
},
|
|
deleteMany: async ({ where }) => {
|
|
const before = rows.length;
|
|
rows = rows.filter(r => !matches(r, where));
|
|
return { count: before - rows.length };
|
|
},
|
|
};
|
|
}
|
|
|
|
const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' };
|
|
|
|
const mockDb = {
|
|
twoFactorChallenge: makeModel(
|
|
() => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }),
|
|
{ user: () => USER },
|
|
),
|
|
trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })),
|
|
};
|
|
|
|
const mockSent = { emails: [], whatsapps: [] };
|
|
const mockWhatsApp = { configured: true, fail: false };
|
|
|
|
jest.mock('../src/config/db', () => mockDb);
|
|
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
|
jest.mock('../src/utils/email', () => ({
|
|
sendMail: async (mail) => { mockSent.emails.push(mail); },
|
|
isSmtpConfigured: () => true,
|
|
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
|
|
}));
|
|
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
|
|
jest.mock('../src/utils/whatsapp', () => ({
|
|
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
|
isConfigured: async () => mockWhatsApp.configured,
|
|
sendText: async (to, message) => {
|
|
if (mockWhatsApp.fail) throw new Error('session disconnected');
|
|
mockSent.whatsapps.push({ to, message });
|
|
},
|
|
}));
|
|
|
|
const {
|
|
MAX_ATTEMPTS,
|
|
availableMethods,
|
|
createChallenge,
|
|
verifyChallenge,
|
|
resendChallenge,
|
|
issueTrustedDevice,
|
|
isTrustedDevice,
|
|
clearTrustedDevices,
|
|
} = require('../src/utils/twoFactor');
|
|
|
|
const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1];
|
|
const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1];
|
|
const wrongCode = (code) => (code === '000000' ? '111111' : '000000');
|
|
|
|
beforeEach(() => {
|
|
mockDb.twoFactorChallenge._reset();
|
|
mockDb.trustedDevice._reset();
|
|
mockSent.emails = [];
|
|
mockSent.whatsapps = [];
|
|
mockWhatsApp.configured = true;
|
|
mockWhatsApp.fail = false;
|
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
jest.spyOn(console, 'info').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => jest.restoreAllMocks());
|
|
|
|
describe('availableMethods', () => {
|
|
test('offers WhatsApp only with a valid phone and a configured gateway', async () => {
|
|
expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']);
|
|
mockWhatsApp.configured = false;
|
|
expect(await availableMethods(USER)).toEqual(['email']);
|
|
mockWhatsApp.configured = true;
|
|
expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']);
|
|
});
|
|
|
|
test('offers nothing by email for a guest placeholder address', async () => {
|
|
expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']);
|
|
});
|
|
});
|
|
|
|
describe('createChallenge', () => {
|
|
test('emails the code and stores only its hash', async () => {
|
|
const res = await createChallenge(USER, 'login', 'email');
|
|
expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' });
|
|
const [row] = mockDb.twoFactorChallenge._rows();
|
|
expect(row.id).toBe(res.challengeId);
|
|
expect(row.codeHash).not.toContain(lastEmailCode());
|
|
expect(mockSent.emails[0].to).toBe(USER.email);
|
|
});
|
|
|
|
test('sends by WhatsApp when asked', async () => {
|
|
const res = await createChallenge(USER, 'login', 'whatsapp');
|
|
expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' });
|
|
expect(mockSent.whatsapps).toHaveLength(1);
|
|
expect(mockSent.emails).toHaveLength(0);
|
|
});
|
|
|
|
test('falls back to email when WhatsApp fails or is not configured', async () => {
|
|
mockWhatsApp.fail = true;
|
|
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
|
mockWhatsApp.fail = false;
|
|
mockWhatsApp.configured = false;
|
|
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
|
expect(mockSent.emails).toHaveLength(2);
|
|
});
|
|
|
|
test('does not fall back when allowFallback is false, and records no challenge', async () => {
|
|
mockWhatsApp.fail = true;
|
|
await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 });
|
|
expect(mockSent.emails).toHaveLength(0);
|
|
expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0);
|
|
});
|
|
|
|
test('a new challenge replaces the previous one for the same purpose', async () => {
|
|
const first = await createChallenge(USER, 'login', 'email');
|
|
const firstCode = lastEmailCode();
|
|
await createChallenge(USER, 'login', 'email');
|
|
await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/);
|
|
});
|
|
});
|
|
|
|
describe('verifyChallenge', () => {
|
|
test('accepts the right code exactly once', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
const code = lastEmailCode();
|
|
const challenge = await verifyChallenge(challengeId, code, 'login');
|
|
expect(challenge.userId).toBe(USER.id);
|
|
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
|
});
|
|
|
|
test('tolerates spaces in the submitted code', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
const code = lastEmailCode();
|
|
await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy();
|
|
});
|
|
|
|
test('locks the challenge after the maximum wrong attempts, even for the right code', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
const code = lastEmailCode();
|
|
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
|
|
await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow(
|
|
i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/
|
|
);
|
|
}
|
|
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
|
});
|
|
|
|
test('rejects an expired code', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
|
await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/);
|
|
});
|
|
|
|
test('rejects a code used for the wrong purpose or by another user', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'enable', 'email');
|
|
const code = lastEmailCode();
|
|
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
|
await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/);
|
|
await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy();
|
|
});
|
|
|
|
test('rejects missing or malformed input', async () => {
|
|
await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow();
|
|
await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow();
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/);
|
|
});
|
|
});
|
|
|
|
describe('resendChallenge', () => {
|
|
test('refuses inside the cooldown', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 });
|
|
expect(mockSent.emails).toHaveLength(1);
|
|
});
|
|
|
|
test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
const oldCode = lastEmailCode();
|
|
await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow();
|
|
mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000);
|
|
|
|
await resendChallenge(challengeId, 'login');
|
|
const newCode = lastEmailCode();
|
|
expect(mockSent.emails).toHaveLength(2);
|
|
expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0);
|
|
if (newCode !== oldCode) {
|
|
await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/);
|
|
}
|
|
await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy();
|
|
});
|
|
|
|
test('refuses once the challenge is too old or already used', async () => {
|
|
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
|
const row = mockDb.twoFactorChallenge._rows()[0];
|
|
row.lastSentAt = new Date(Date.now() - 61 * 1000);
|
|
row.createdAt = new Date(Date.now() - 31 * 60 * 1000);
|
|
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
|
|
|
row.createdAt = new Date();
|
|
row.usedAt = new Date();
|
|
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
|
});
|
|
});
|
|
|
|
describe('trusted devices', () => {
|
|
test('a freshly issued token is trusted for its own user only', async () => {
|
|
const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36');
|
|
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token);
|
|
expect(await isTrustedDevice(USER.id, token)).toBe(true);
|
|
expect(await isTrustedDevice('someone-else', token)).toBe(false);
|
|
});
|
|
|
|
test('unknown, malformed and expired tokens are not trusted', async () => {
|
|
const token = await issueTrustedDevice(USER.id, '');
|
|
expect(await isTrustedDevice(USER.id, 'nope')).toBe(false);
|
|
expect(await isTrustedDevice(USER.id, undefined)).toBe(false);
|
|
expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false);
|
|
mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
|
expect(await isTrustedDevice(USER.id, token)).toBe(false);
|
|
});
|
|
|
|
test('clearTrustedDevices forgets every device for the user', async () => {
|
|
const a = await issueTrustedDevice(USER.id, '');
|
|
const b = await issueTrustedDevice(USER.id, '');
|
|
await clearTrustedDevices(USER.id);
|
|
expect(await isTrustedDevice(USER.id, a)).toBe(false);
|
|
expect(await isTrustedDevice(USER.id, b)).toBe(false);
|
|
});
|
|
});
|