import { Router } from 'express'; import { handleErrorAsync } from '../../src/helpers.ts'; import speakeasy from 'speakeasy'; import { saveAuthenticator, findAuthenticatorsByUser, invalidateAuthenticator, issueAuthentication, normalise, fetchLoginAttempts, saveLoginAttempts, } from './user.ts'; import { longtimeCookieSetting } from '../../src/cookieSettings.ts'; import auditLog from '../audit.ts'; import type { Database } from '../db.ts'; import type { UserRow } from './user.ts'; export type UserMfa = T & { mfa: boolean; mfaRequired?: boolean; authenticated?: boolean; }; export const addMfaInfo = async >(db: Database, user: T, guard = false): Promise> => { const auths = await findAuthenticatorsByUser(db, user, 'mfa_secret'); const userMfa: UserMfa = { ...user, mfa: auths.length > 0, }; if (auths.length && guard) { userMfa.mfaRequired = true; userMfa.authenticated = false; } return userMfa; }; const disableMfa = async (db: Database, user: Pick) => { for (const authenticator of [ ...await findAuthenticatorsByUser(db, user, 'mfa_secret'), ...await findAuthenticatorsByUser(db, user, 'mfa_recovery'), ]) { await invalidateAuthenticator(db, authenticator.id); } }; const router = Router(); router.get('/mfa/get-url', handleErrorAsync(async (req, res) => { if (!req.user || req.user.mfa) { return res.status(401).json({ error: 'Unauthorised' }); } const secret = speakeasy.generateSecret({ length: 16, name: global.translations.title, }); return res.json(secret); })); router.post('/mfa/init', handleErrorAsync(async (req, res) => { if (!req.user || req.user.mfa) { return res.status(401).json({ error: 'Unauthorised' }); } const verified = speakeasy.totp.verify({ secret: req.body.secret, encoding: 'base32', token: req.body.token, }); if (!verified) { return res.status(400).json({ error: 'Invalid token' }); } await saveAuthenticator(req.db, 'mfa_secret', req.user, req.body.secret); const recoveryCodes = []; for (let i = 0; i < 5; i++) { const code = speakeasy.generateSecretASCII(24); recoveryCodes.push(code); await saveAuthenticator(req.db, 'mfa_recovery', req.user, code); } const token = await issueAuthentication(req.db, req.user); await auditLog(req, 'auth/mfa_initialised'); return res.cookie('token', token, longtimeCookieSetting).json(recoveryCodes); })); router.post('/mfa/validate', handleErrorAsync(async (req, res) => { if (!req.rawUser || !req.rawUser.mfaRequired) { await auditLog(req, 'auth/mfa_token_expired'); return res.json({ error: 'user.tokenExpired' }); } if (req.body.recovery) { for (const authenticator of await findAuthenticatorsByUser(req.db, req.rawUser, 'mfa_recovery')) { if (authenticator.payload === req.body.code.trim()) { await disableMfa(req.db, req.rawUser); const token = await issueAuthentication(req.db, req.rawUser, true, false, { mfa: false, mfaRequired: false }); await auditLog(req, 'auth/mfa_recovery_successful'); return res.cookie('token', token, longtimeCookieSetting).json({ token }); } } await auditLog(req, 'auth/mfa_recovery_failed'); return res.json({ error: 'user.code.invalid' }); } const authenticator = (await findAuthenticatorsByUser(req.db, req.rawUser, 'mfa_secret'))[0]; const { attemptCount, firstAttemptAt, block } = await fetchLoginAttempts(req.db, authenticator.userId!); if (block) { await auditLog(req, 'auth/mfa_too_many_attempts'); return res.json({ error: 'user.tooManyAttempts' }); } let tokenValidates = speakeasy.totp.verify({ secret: authenticator.payload, encoding: 'base32', token: normalise(req.body.code), window: 6, }); if (process.env.NODE_ENV === 'development' && normalise(req.body.code) === '999999') { tokenValidates = true; } if (!tokenValidates) { await saveLoginAttempts(req.db, authenticator.userId!, attemptCount + 1, firstAttemptAt || new Date()); await auditLog(req, 'auth/mfa_validation_failed'); return res.json({ error: 'user.code.invalid' }); } const token = await issueAuthentication(req.db, req.rawUser, true, false, { mfaRequired: false }); await auditLog(req, 'auth/mfa_validation_successful'); return res.cookie('token', token, longtimeCookieSetting).json({ token }); })); router.post('/mfa/disable', handleErrorAsync(async (req, res) => { if (!req.user || !req.user.mfa) { return res.status(401).json({ error: 'Unauthorised' }); } await disableMfa(req.db, req.user); const token = await issueAuthentication(req.db, req.user); await auditLog(req, 'auth/mfa_disabled'); return res.cookie('token', token, longtimeCookieSetting).json({ token }); })); export default router;