nodebb-plugin-2factor
Version:
In addition to regular authentication via username/password or SSO, a second layer of security can be configured, permitting access only if:
251 lines (208 loc) • 6.74 kB
JavaScript
const parent = module.parent.exports;
const passport = require.main.require('passport');
const nconf = require.main.require('nconf');
const winston = require.main.require('winston');
const validator = require.main.require('validator');
const async = require('async');
const fs = require('fs');
const path = require('path');
const base64url = require('base64url');
const groups = require.main.require('./src/groups');
const user = require.main.require('./src/user');
const meta = require.main.require('./src/meta');
const helpers = require.main.require('./src/controllers/helpers');
const Controllers = module.exports;
async function getGroups(set) {
let groupNames = await groups.getGroups(set, 0, -1);
groupNames = groupNames.filter(groupName => groupName && !groups.isPrivilegeGroup(groupName));
return groupNames.map(groupName => ({
name: validator.escape(String(groupName)),
value: validator.escape(String(groupName)),
}));
}
Controllers.renderChoices = async (req, res) => {
const uid = res.locals['2factor'] || req.uid;
const [hasAuthn, hasTotp, hasBackupCodes] = await Promise.all([
parent.hasAuthn(uid),
parent.hasTotp(uid),
parent.hasBackupCodes(uid),
]);
const count = [hasAuthn, hasTotp, hasBackupCodes].reduce((count, cur) => count + (cur ? 1 : 0), 0);
if (count === 1) {
switch (true) {
case hasAuthn:
helpers.redirect(res, `/login/2fa/authn?single=1&next=${req.query.next || '/'}`);
break;
case hasTotp:
helpers.redirect(res, `/login/2fa/totp?single=1&next=${req.query.next || '/'}`);
break;
case hasBackupCodes:
helpers.redirect(res, `/login/2fa/backup?single=1&next=${req.query.next || '/'}`);
break;
}
return;
}
res.render('2fa-choices', {
hasAuthn,
hasTotp,
hasBackupCodes,
next: req.query.next,
title: '[[2factor:title]]',
});
};
Controllers.renderTotpChallenge = async (req, res, next) => {
const uid = res.locals['2factor'] || req.uid;
const single = parseInt(req.query.single, 10) === 1;
if (req.session.tfa === true && !req.session.tfaForce) {
return res.redirect(nconf.get('relative_path') + (req.query.next || '/'));
}
const error = req.flash('error');
if (error.includes('[[2factor:login.failure]]')) {
const main = require('..');
main.handle2faFailure(uid);
}
if (!await parent.hasTotp(uid)) {
return next();
}
setTimeout(() => {
res.render('login-totp', {
single,
error: error[0],
next: req.query.next,
});
}, error.length ? 2500 : undefined);
};
Controllers.renderAuthnChallenge = async (req, res, next) => {
const uid = res.locals['2factor'] || req.uid;
const single = parseInt(req.query.single, 10) === 1;
if (req.session.tfa === true && ((req.query.next && !req.query.next.startsWith('/admin')) || !req.session.tfaForce)) {
return res.redirect(nconf.get('relative_path') + (req.query.next || '/'));
}
if (!await parent.hasAuthn(uid)) {
return next();
}
const keyIds = await parent.getAuthnKeyIds(uid);
let authnOptions;
if (keyIds.length) {
authnOptions = await parent._f2l.assertionOptions();
authnOptions.allowCredentials = keyIds.map(keyId => ({
id: keyId,
type: 'public-key',
transports: ['usb', 'ble', 'nfc'],
}));
authnOptions.challenge = base64url(authnOptions.challenge);
req.session.authRequest = authnOptions.challenge;
}
res.render('login-authn', {
single,
authnOptions,
next: req.query.next,
});
};
Controllers.processTotpLogin = function (req, res, next) {
passport.authenticate('totp', {
failureRedirect: `${nconf.get('relative_path')}/login/2fa/totp`,
failureFlash: '[[2factor:login.failure]]',
keepSessionInfo: true,
})(req, res, next);
};
Controllers.renderBackup = async (req, res, next) => {
const uid = res.locals['2factor'] || req.uid;
const single = parseInt(req.query.single, 10) === 1;
if (req.session.tfa === true && ((req.query.next && !req.query.next.startsWith('/admin')) || !req.session.tfaForce)) {
return res.redirect(nconf.get('relative_path') + (req.query.next || '/'));
}
const error = req.flash('error');
if (!await parent.hasKey(uid)) {
return next();
}
setTimeout(() => {
res.render('login-backup', {
single,
error: error[0],
next: req.query.next,
});
}, error.length ? 2500 : undefined);
};
Controllers.generateBackupCodes = function (req, res) {
parent.generateBackupCodes(req.user.uid, (err, codes) => {
if (err) {
winston.error(`[plugin/2factor] Could not generate new backup codes for uid ${req.user.uid}! Error: ${err.message}`);
return res.sendStatus(500);
}
res.status(200).json({
codes: codes,
});
});
};
Controllers.processBackup = function (req, res, next) {
parent.useBackupCode(req.body.code, req.user.uid, (err, success) => {
if (err || !success) {
req.flash('error', !err ? '[[2factor:backup.failure]]' : err.message);
res.redirect(`${nconf.get('relative_path')}/login/2fa/backup`);
} else {
// Success!
next();
}
});
};
Controllers.renderAdminPage = async function (req, res, next) {
const groups = await getGroups('groups:createtime');
async.parallel({
image: async.apply(fs.readFile, path.join(__dirname, '../screenshots/profile.png'), {
encoding: 'base64',
}),
users: async.apply(parent.getUsers),
}, (err, data) => {
if (err) {
return next(err);
}
data.groups = groups;
data.title = '[[2factor:title]]';
res.render('admin/plugins/2factor', data);
});
};
Controllers.renderSettings = async (req, res) => {
const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']);
if (res.locals.uid !== req.uid) {
return helpers.notAllowed(req, res);
}
const breadcrumbs = helpers.buildBreadcrumbs([
{
text: username,
url: `/user/${userslug}`,
},
{
text: '[[2factor:title]]',
},
]);
let { tfaEnforcedGroups } = await meta.settings.get('2factor');
let forceTfa = false;
tfaEnforcedGroups = JSON.parse(tfaEnforcedGroups || '[]');
if (tfaEnforcedGroups.length && (await groups.isMemberOfGroups(req.user.uid, tfaEnforcedGroups)).includes(true)) {
forceTfa = true;
}
const hasTotp = await parent.hasTotp(req.user.uid);
const hasAuthn = await parent.hasAuthn(req.user.uid);
res.render('account/2factor', {
...res.locals.userData,
title: '[[2factor:title]]',
breadcrumbs,
forceTfa,
hasTotp,
hasAuthn,
backupCodeCount: await parent.countBackupCodes(req.user.uid),
});
};
Controllers.renderAccessNotificationHelp = (req, res, next) => {
const { when } = req.query;
if (!when) {
return next();
}
const date = new Date(parseInt(when, 10));
res.render('2fa-access-notification', {
timeString: date.toLocaleTimeString(date),
dateString: date.toLocaleDateString(date),
});
};
;