2024-06-26 13:59:53 +02:00

159 lines
5.1 KiB
TypeScript

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> = T & {
mfa: boolean;
mfaRequired?: boolean;
authenticated?: boolean;
};
export const addMfaInfo = async <T extends Pick<UserRow, 'id'>>(db: Database, user: T, guard = false): Promise<UserMfa<T>> => {
const auths = await findAuthenticatorsByUser(db, user, 'mfa_secret');
const userMfa: UserMfa<T> = {
...user,
mfa: auths.length > 0,
};
if (auths.length && guard) {
userMfa.mfaRequired = true;
userMfa.authenticated = false;
}
return userMfa;
};
const disableMfa = async (db: Database, user: Pick<UserRow, 'id'>) => {
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;