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>
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
/**
|
|
* Two-factor authentication helpers: 6-digit codes delivered by email or
|
|
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
|
|
*
|
|
* Neither the code nor the device token is stored in the clear — only an
|
|
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const prisma = require('../config/db');
|
|
const { describeUserAgent } = require('./userAgent');
|
|
|
|
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
|
|
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
|
|
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
|
|
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
|
|
const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
const METHODS = ['email', 'whatsapp'];
|
|
|
|
// Roles that must use two-factor authentication.
|
|
const REQUIRED_ROLES = ['admin', 'supervisor'];
|
|
|
|
// Application error carrying the HTTP status the controller should respond with.
|
|
function fail(message, statusCode = 400) {
|
|
const err = new Error(message);
|
|
err.statusCode = statusCode;
|
|
return err;
|
|
}
|
|
|
|
function generateCode() {
|
|
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
}
|
|
|
|
function hmac(value) {
|
|
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
|
|
}
|
|
|
|
// Namespaced so a code hash can never collide with a device-token hash.
|
|
const hashCode = (code) => hmac(`2fa-code:${code}`);
|
|
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
|
|
|
|
function safeEqual(a, b) {
|
|
const ba = Buffer.from(String(a));
|
|
const bb = Buffer.from(String(b));
|
|
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
|
}
|
|
|
|
function hasRealEmail(user) {
|
|
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
|
|
}
|
|
|
|
function maskEmail(email) {
|
|
const [local, domain] = String(email || '').split('@');
|
|
if (!local || !domain) return '•••';
|
|
return `${local[0]}•••@${domain}`;
|
|
}
|
|
|
|
function maskPhone(phone) {
|
|
const digits = String(phone || '').replace(/\D/g, '');
|
|
return `••• ••• ${digits.slice(-4)}`;
|
|
}
|
|
|
|
/** Channels this user could receive a code on right now. */
|
|
async function availableMethods(user) {
|
|
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
|
const methods = [];
|
|
if (hasRealEmail(user)) methods.push('email');
|
|
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
|
|
return methods;
|
|
}
|
|
|
|
/**
|
|
* True when this user's role must use two-factor authentication.
|
|
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
|
|
*/
|
|
function requiresTwoFactor(user) {
|
|
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
|
|
return REQUIRED_ROLES.includes(user?.role);
|
|
}
|
|
|
|
/**
|
|
* The channel a required-2FA user should be enrolled on, or null when no channel
|
|
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
|
|
* requirement is skipped rather than locking the account out.
|
|
*/
|
|
async function enforcementMethod(user) {
|
|
const { isSmtpConfigured } = require('./email');
|
|
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
|
|
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
|
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
|
|
return null;
|
|
}
|
|
|
|
/** True when this user must enrol in 2FA before using their account. */
|
|
async function mustEnrolTwoFactor(user) {
|
|
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
|
|
return (await enforcementMethod(user)) !== null;
|
|
}
|
|
|
|
/**
|
|
* Sends the code and reports where it actually went. A WhatsApp failure (session
|
|
* disconnected, not configured) falls back to email so a user is never locked out
|
|
* by the WhatsApp gateway being down. Throws when no channel could deliver.
|
|
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
|
|
*/
|
|
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
|
|
const { getSettingSync } = require('./settingsCache');
|
|
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
|
|
}
|
|
|
|
if (method === 'whatsapp') {
|
|
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
|
|
const { buildWATwoFactorCode } = require('./waMessages');
|
|
try {
|
|
// sendText silently no-ops when WhatsApp isn't set up, so check first.
|
|
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
|
|
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
|
|
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
|
|
} catch (e) {
|
|
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
|
|
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
|
|
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
|
}
|
|
}
|
|
|
|
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
|
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
|
|
try {
|
|
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
|
|
} catch (e) {
|
|
console.warn('[2fa] Email code failed:', e?.message || e);
|
|
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
|
}
|
|
return { method: 'email', destination: maskEmail(user.email) };
|
|
}
|
|
|
|
/**
|
|
* Sends a fresh code and records the challenge. Any earlier challenge of the same
|
|
* purpose for this user is discarded, so only the newest code works.
|
|
*/
|
|
async function createChallenge(user, purpose, method, options) {
|
|
const code = generateCode();
|
|
const sent = await deliverCode(user, method, code, options);
|
|
|
|
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
|
|
const challenge = await prisma.twoFactorChallenge.create({
|
|
data: {
|
|
userId: user.id,
|
|
purpose,
|
|
method: sent.method,
|
|
codeHash: hashCode(code),
|
|
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
|
},
|
|
});
|
|
|
|
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
|
|
}
|
|
|
|
/**
|
|
* Checks a submitted code. Returns the challenge (with its user) on success and
|
|
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
|
|
*/
|
|
async function verifyChallenge(challengeId, code, purpose, userId) {
|
|
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
|
|
if (!challengeId || typeof challengeId !== 'string') throw invalid();
|
|
const submitted = String(code ?? '').replace(/\D/g, '');
|
|
|
|
// Count the attempt before comparing, atomically, so parallel guesses can't
|
|
// slip past the attempt limit.
|
|
const counted = await prisma.twoFactorChallenge.updateMany({
|
|
where: {
|
|
id: challengeId,
|
|
purpose,
|
|
...(userId ? { userId } : {}),
|
|
usedAt: null,
|
|
expiresAt: { gt: new Date() },
|
|
attempts: { lt: MAX_ATTEMPTS },
|
|
},
|
|
data: { attempts: { increment: 1 } },
|
|
});
|
|
if (counted.count !== 1) throw invalid();
|
|
|
|
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
|
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
|
|
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
|
|
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
|
|
}
|
|
|
|
// Single use — if two requests race with the right code, only one wins.
|
|
const used = await prisma.twoFactorChallenge.updateMany({
|
|
where: { id: challengeId, usedAt: null },
|
|
data: { usedAt: new Date() },
|
|
});
|
|
if (used.count !== 1) throw invalid();
|
|
|
|
return challenge;
|
|
}
|
|
|
|
/** Replaces the code on an existing challenge and sends it again. */
|
|
async function resendChallenge(challengeId, purpose, userId) {
|
|
const expired = () => fail('This verification has expired. Please start again.');
|
|
if (!challengeId || typeof challengeId !== 'string') throw expired();
|
|
|
|
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
|
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
|
|
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
|
|
|
|
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
|
|
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
|
|
|
|
// Claim the send slot first so two simultaneous resends can't both go out.
|
|
const claimed = await prisma.twoFactorChallenge.updateMany({
|
|
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
|
|
data: { lastSentAt: new Date() },
|
|
});
|
|
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
|
|
|
|
const code = generateCode();
|
|
const sent = await deliverCode(challenge.user, challenge.method, code);
|
|
await prisma.twoFactorChallenge.update({
|
|
where: { id: challengeId },
|
|
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
|
|
});
|
|
|
|
return { challengeId, method: sent.method, destination: sent.destination };
|
|
}
|
|
|
|
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
|
|
async function issueTrustedDevice(userId, userAgent) {
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
|
|
await prisma.trustedDevice.create({
|
|
data: {
|
|
userId,
|
|
tokenHash: hashDeviceToken(token),
|
|
device: describeUserAgent(userAgent),
|
|
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
|
|
},
|
|
});
|
|
return token;
|
|
}
|
|
|
|
/** True when `token` is an unexpired trusted-device token belonging to this user. */
|
|
async function isTrustedDevice(userId, token) {
|
|
if (!token || typeof token !== 'string') return false;
|
|
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
|
|
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
|
|
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
|
return true;
|
|
}
|
|
|
|
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
|
|
async function clearTrustedDevices(userId) {
|
|
try {
|
|
await prisma.trustedDevice.deleteMany({ where: { userId } });
|
|
} catch (e) {
|
|
console.error('Failed to clear trusted devices:', e?.message);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
METHODS,
|
|
REQUIRED_ROLES,
|
|
requiresTwoFactor,
|
|
enforcementMethod,
|
|
mustEnrolTwoFactor,
|
|
MAX_ATTEMPTS,
|
|
generateCode,
|
|
hashCode,
|
|
hashDeviceToken,
|
|
maskEmail,
|
|
maskPhone,
|
|
hasRealEmail,
|
|
availableMethods,
|
|
createChallenge,
|
|
verifyChallenge,
|
|
resendChallenge,
|
|
issueTrustedDevice,
|
|
isTrustedDevice,
|
|
clearTrustedDevices,
|
|
};
|