mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-26 14:32:04 -04:00
159 lines
5.1 KiB
TypeScript
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;
|