mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-10-01 00:57:23 -04:00

the #shared alias used by Nuxt cannot be easily disabled and to prevent breackage with jiti, we make use of it
183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
import { Router } from 'express';
|
|
import { getH3Event } from 'h3-express';
|
|
import speakeasy from 'speakeasy';
|
|
|
|
import { auditLog } from '../audit.ts';
|
|
import type { Database } from '../db.ts';
|
|
import { env } from '../env.ts';
|
|
|
|
import {
|
|
saveAuthenticator,
|
|
findAuthenticatorsByUser,
|
|
invalidateAuthenticator,
|
|
issueAuthentication,
|
|
normalise,
|
|
fetchLoginAttempts,
|
|
saveLoginAttempts,
|
|
} from './user.ts';
|
|
import type { UserRow } from './user.ts';
|
|
|
|
import { longtimeCookieSetting } from '#shared/cookieSettings.ts';
|
|
import { handleErrorAsync } from '#shared/helpers.ts';
|
|
import { getLocale, loadTranslator } from '~~/server/data.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 locale = getLocale(getH3Event(req));
|
|
const translator = await loadTranslator(locale);
|
|
|
|
const secret = speakeasy.generateSecret({
|
|
length: 16,
|
|
name: translator.translate('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, getLocale(getH3Event(req)), 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' });
|
|
}
|
|
|
|
const locale = getLocale(getH3Event(req));
|
|
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,
|
|
locale,
|
|
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 as string,
|
|
encoding: 'base32',
|
|
token: normalise(req.body.code),
|
|
window: 6,
|
|
});
|
|
|
|
if (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, locale, 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, getLocale(getH3Event(req)), req.user);
|
|
|
|
await auditLog(req, 'auth/mfa_disabled');
|
|
return res.cookie('token', token, longtimeCookieSetting).json({ token });
|
|
}));
|
|
|
|
export default router;
|