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:
472 lines (396 loc) • 15.3 kB
JavaScript
const passport = require.main.require('passport');
const passportTotp = require('passport-totp').Strategy;
const notp = require('notp');
const { Fido2Lib } = require('fido2-lib');
const base64url = require('base64url');
const db = require.main.require('./src/database');
const nconf = require.main.require('nconf');
const async = require.main.require('async');
const winston = require.main.require('winston');
const user = require.main.require('./src/user');
const meta = require.main.require('./src/meta');
const groups = require.main.require('./src/groups');
const plugins = require.main.require('./src/plugins');
const notifications = require.main.require('./src/notifications');
const utils = require.main.require('./src/utils');
const routeHelpers = require.main.require('./src/routes/helpers');
const controllerHelpers = require.main.require('./src/controllers/helpers');
const SocketPlugins = require.main.require('./src/socket.io/plugins');
const atob = base64str => Buffer.from(base64str, 'base64').toString('binary');
const plugin = {
_f2l: undefined,
};
plugin.init = async (params) => {
const { router } = params;
const hostMiddleware = params.middleware;
const accountMiddlewares = [
hostMiddleware.exposeUid,
hostMiddleware.ensureLoggedIn,
hostMiddleware.canViewUsers,
hostMiddleware.checkAccountPermissions,
hostMiddleware.buildAccountData,
];
const hostHelpers = require.main.require('./src/routes/helpers');
const controllers = require('./lib/controllers');
const middlewares = require('./lib/middlewares');
// Public-facing pages
hostHelpers.setupPageRoute(router, '/2factor/access-notification', controllers.renderAccessNotificationHelp);
// ACP
hostHelpers.setupAdminPageRoute(router, '/admin/plugins/2factor', [hostMiddleware.pluginHooks], controllers.renderAdminPage);
// UCP
hostHelpers.setupPageRoute(router, '/user/:userslug/2factor', accountMiddlewares, controllers.renderSettings);
// 2fa Login
hostHelpers.setupPageRoute(router, '/login/2fa', [hostMiddleware.ensureLoggedIn], controllers.renderChoices);
hostHelpers.setupPageRoute(router, '/login/2fa/totp', [hostMiddleware.ensureLoggedIn], controllers.renderTotpChallenge);
router.post('/login/2fa/totp', hostMiddleware.ensureLoggedIn, controllers.processTotpLogin, (req, res) => {
req.session.tfa = true;
delete req.session.tfaForce;
req.session.meta.datetime = Date.now();
user.auth.addSession(req.uid, req.sessionID, req.session.meta.uuid);
res.redirect(nconf.get('relative_path') + (req.query.next || '/'));
});
hostHelpers.setupPageRoute(router, '/login/2fa/authn', [hostMiddleware.ensureLoggedIn], controllers.renderAuthnChallenge);
// 2fa backups codes
hostHelpers.setupPageRoute(router, '/login/2fa/backup', [hostMiddleware.ensureLoggedIn], controllers.renderBackup);
router.post('/login/2fa/backup', hostMiddleware.ensureLoggedIn, controllers.processBackup, (req, res) => {
req.session.tfa = true;
res.redirect(nconf.get('relative_path') + (req.query.next || '/'));
});
router.put('/login/2fa/backup', hostMiddleware.requireUser, middlewares.requireSecondFactor, hostMiddleware.applyCSRF, controllers.generateBackupCodes);
// Websockets
SocketPlugins['2factor'] = require('./websockets');
// Login Strategy
passport.use(new passportTotp(
async (user, done) => {
try {
const key = await plugin.get(user.uid);
return done(null, key, 30);
} catch (e) {
return done(e);
}
}
));
// Fido2Lib instantiation
plugin._f2l = new Fido2Lib({
timeout: 60 * 1000, // 60 seconds
rpId: nconf.get('url_parsed').hostname,
rpName: meta.config.title || 'NodeBB',
});
// Configure 2FA path exemptions
let prefixes = ['/reset', '/confirm'];
let pages = ['/login/2fa', '/login/2fa/authn', '/login/2fa/totp', '/login/2fa/backup', '/2factor/authn/verify', '/register/complete'];
let paths = ['/api/v3/plugins/2factor/authn/verify'];
({ prefixes, pages, paths } = await plugins.hooks.fire('filter:2factor.exemptions', { prefixes, pages, paths }));
pages = pages.reduce((memo, cur) => {
memo.push(nconf.get('relative_path') + cur);
memo.push(`${nconf.get('relative_path')}/api${cur}`);
return memo;
}, []);
plugin.exemptions = {
prefixes,
paths: new Set(pages.concat(paths)),
};
};
plugin.addRoutes = async ({ router, middleware, helpers }) => {
const middlewares = [
middleware.ensureLoggedIn,
];
routeHelpers.setupApiRoute(router, 'get', '/2factor/authn/register', middlewares, async (req, res) => {
const registrationRequest = await plugin._f2l.attestationOptions();
const userData = await user.getUserFields(req.uid, ['username', 'displayname']);
registrationRequest.user = {
id: base64url(String(req.uid)),
name: userData.username,
displayName: userData.displayname,
};
registrationRequest.challenge = base64url(registrationRequest.challenge);
req.session.registrationRequest = registrationRequest;
helpers.formatApiResponse(200, res, registrationRequest);
});
routeHelpers.setupApiRoute(router, 'post', '/2factor/authn/register', middlewares, async (req, res) => {
const attestationExpectations = {
challenge: req.session.registrationRequest.challenge,
origin: `${nconf.get('url_parsed').protocol}//${nconf.get('url_parsed').host}`,
factor: 'second',
};
req.body.rawId = Uint8Array.from(atob(base64url.toBase64(req.body.rawId)), c => c.charCodeAt(0)).buffer;
const regResult = await plugin._f2l.attestationResult(req.body, attestationExpectations);
plugin.saveAuthn(req.uid, regResult.authnrData);
delete req.session.registrationRequest;
req.session.tfa = true; // eliminate re-challenge on registration
helpers.formatApiResponse(200, res);
});
// Note: auth request generated in Controllers.renderLogin
routeHelpers.setupApiRoute(router, 'post', '/2factor/authn/verify', middlewares, async (req, res) => {
const prevCounter = await plugin.getAuthnCount(req.body.authResponse.id);
const publicKey = await plugin.getAuthnPublicKey(req.uid, req.body.authResponse.id);
const expectations = {
challenge: req.session.authRequest,
origin: `${nconf.get('url_parsed').protocol}//${nconf.get('url_parsed').host}`,
factor: 'second',
publicKey,
prevCounter,
userHandle: null,
};
req.body.authResponse.rawId =
Uint8Array.from(atob(base64url.toBase64(req.body.authResponse.rawId)), c => c.charCodeAt(0)).buffer;
req.body.authResponse.response.userHandle = undefined;
const authnResult = await plugin._f2l.assertionResult(req.body.authResponse, expectations);
const count = authnResult.authnrData.get('counter');
await plugin.updateAuthnCount(req.body.authResponse.id, count);
req.session.tfa = true;
delete req.session.authRequest;
delete req.session.tfaForce;
req.session.meta.datetime = Date.now();
helpers.formatApiResponse(200, res, {
next: req.query.next || '/',
});
});
routeHelpers.setupApiRoute(router, 'delete', '/2factor/authn', middlewares, async (req, res) => {
const { uid } = req;
const keyIds = await db.getObjectKeys(`2factor:webauthn:${uid}`);
await db.sortedSetRemove('2factor:webauthn:counters', keyIds);
await db.delete(`2factor:webauthn:${uid}`);
helpers.formatApiResponse(200, res);
});
routeHelpers.setupApiRoute(router, 'delete', '/2factor/totp', middlewares, async (req, res) => {
await db.deleteObjectField('2factor:uid:key', req.uid);
helpers.formatApiResponse(200, res);
});
};
plugin.appendConfig = async (config) => {
const hasKey = await plugin.hasKey(config.uid);
config['2factor'] = { hasKey };
return config;
};
plugin.addAdminNavigation = function (header, callback) {
header.plugins.push({
route: '/plugins/2factor',
icon: 'fa-lock',
name: '[[2factor:title]]',
});
callback(null, header);
};
plugin.addProfileItem = function (data, callback) {
data.links.push({
id: '2factor',
route: '2factor',
icon: 'fa-lock',
name: '[[2factor:title]]',
visibility: {
self: true,
other: false,
moderator: false,
globalMod: false,
admin: false,
canViewInfo: false,
},
});
callback(null, data);
};
plugin.get = async uid => db.getObjectField('2factor:uid:key', uid);
plugin.getAuthnKeyIds = async (uid) => {
const keys = await db.getObject(`2factor:webauthn:${uid}`);
return Object.keys(keys);
};
plugin.getAuthnPublicKey = async (uid, id) => db.getObjectField(`2factor:webauthn:${uid}`, id);
plugin.getAuthnCount = async id => db.sortedSetScore(`2factor:webauthn:counters`, id);
plugin.updateAuthnCount = async (id, count) => db.sortedSetAdd(`2factor:webauthn:counters`, count, id);
plugin.save = function (uid, key, callback) {
db.setObjectField('2factor:uid:key', uid, key, callback);
};
plugin.saveAuthn = (uid, authnrData) => {
const counter = authnrData.get('counter');
const publicKey = authnrData.get('credentialPublicKeyPem');
const id = base64url(authnrData.get('credId'));
db.setObjectField(`2factor:webauthn:${uid}`, id, publicKey);
db.sortedSetAdd(`2factor:webauthn:counters`, counter, id);
};
plugin.hasAuthn = async (uid) => {
if (!(parseInt(uid, 10) > 0)) {
return false;
}
return await db.exists(`2factor:webauthn:${uid}`);
};
plugin.hasTotp = async (uid) => {
if (!(parseInt(uid, 10) > 0)) {
return false;
}
return await db.isObjectField('2factor:uid:key', uid);
};
// hmm... remove?
plugin.hasKey = async (uid) => {
const [hasTotp, hasAuthn] = await Promise.all([
plugin.hasTotp(uid),
plugin.hasAuthn(uid),
]);
return hasTotp || hasAuthn;
};
plugin.hasBackupCodes = async uid => db.exists(`2factor:uid:${uid}:backupCodes`);
plugin.countBackupCodes = async uid => db.setCount(`2factor:uid:${uid}:backupCodes`);
plugin.generateBackupCodes = function (uid, callback) {
const set = `2factor:uid:${uid}:backupCodes`;
const codes = [];
let code;
for (let x = 0; x < 5; x++) {
code = utils.generateUUID().replace('-', '').slice(0, 10);
codes.push(code);
}
async.series([
async.apply(db.delete, set), // Invalidate all old codes
async.apply(db.setAdd, set, codes), // Save new codes
function (next) {
notifications.create({
bodyShort: '[[2factor:notification.backupCode.generated]]',
bodyLong: '',
nid: `2factor.backupCode.generated-${uid}-${Date.now()}`,
from: uid,
path: '/',
}, (err, notification) => {
if (!err && notification) {
notifications.push(notification, [uid], next);
}
});
},
], (err) => {
callback(err, codes);
});
};
plugin.useBackupCode = function (code, uid, callback) {
const set = `2factor:uid:${uid}:backupCodes`;
async.waterfall([
async.apply(db.isSetMember, set, code),
function (valid, next) {
if (valid) {
// Invalidate this backup code
db.setRemove(set, code, (err) => {
next(err, valid);
});
notifications.create({
bodyShort: '[[2factor:notification.backupCode.used]]',
bodyLong: '',
nid: `2factor.backupCode.used-${uid}-${Date.now()}`,
from: uid,
path: '/',
}, (err, notification) => {
if (!err && notification) {
notifications.push(notification, [uid]);
}
});
} else {
next(null, valid);
}
},
], callback);
};
plugin.disassociate = async (uid) => {
await Promise.all([
db.deleteObjectField('2factor:uid:key', uid),
db.delete(`2factor:uid:${uid}:backupCodes`),
]);
// Clear U2F keys
const keyIds = await db.getObjectKeys(`2factor:webauthn:${uid}`);
await db.sortedSetRemove('2factor:webauthn:counters', keyIds);
await db.delete(`2factor:webauthn:${uid}`);
};
plugin.overrideUid = async ({ req, locals }) => {
if (req.uid && await plugin.hasKey(req.uid) && req.session.tfa !== true) {
locals['2factor'] = req.uid;
req.uid = 0;
delete req.user;
delete req.loggedIn;
}
return { req, locals };
};
plugin.check = async ({ req, res }) => {
if (!req.user || req.session.tfa === true) {
return;
}
const requestPath = req.baseUrl + req.path;
if (plugin.exemptions.paths.has(requestPath) || plugin.exemptions.prefixes.some(prefix => requestPath.startsWith(nconf.get('relative_path') + prefix))) {
return;
}
let { tfaEnforcedGroups } = await meta.settings.get('2factor');
tfaEnforcedGroups = JSON.parse(tfaEnforcedGroups || '[]');
const redirect = requestPath
.replace('/api', '')
.replace(nconf.get('relative_path'), '');
if (await plugin.hasKey(req.user.uid)) {
if (!res.locals.isAPI) {
// Account has TFA, redirect to login
controllerHelpers.redirect(res, `/login/2fa?next=${redirect}`);
} else {
await controllerHelpers.formatApiResponse(401, res, new Error('[[2factor:second-factor-required]]'));
}
} else if (tfaEnforcedGroups.length && (await groups.isMemberOfGroups(req.uid, tfaEnforcedGroups)).includes(true)) {
if (req.url.startsWith('/admin') || (!req.url.startsWith('/admin') && !req.url.match('2factor'))) {
controllerHelpers.redirect(res, `/me/2factor?next=${redirect}`);
}
}
// No TFA setup
};
plugin.checkSocket = async (data) => {
if (!data.socket.uid || data.req.session.tfa === true) {
return;
}
if (await plugin.hasKey(data.socket.uid)) {
winston.info(`[plugin/2factor] Denying socket access for uid ${data.socket.uid} pending second factor.`);
throw new Error('[[2factor:second-factor-required]]');
}
};
plugin.clearSession = function (data, callback) {
if (data.req.session) {
delete data.req.session.tfa;
}
setImmediate(callback);
};
plugin.getUsers = function (callback) {
async.waterfall([
async.apply(db.getObjectKeys, '2factor:uid:key'),
function (uids, next) {
user.getUsersFields(uids, ['username', 'userslug', 'picture'], next);
},
], callback);
};
plugin.adjustRelogin = async ({ req, res }) => {
if (await plugin.hasKey(req.uid)) {
req.session.forceLogin = 0;
req.session.tfaForce = 1;
if (!res.locals.isAPI) {
controllerHelpers.redirect(res, `/login/2fa?next=${req.session.returnTo}`);
}
}
};
plugin.handle2faFailure = async (uid) => {
const notification = await notifications.create({
bodyShort: '[[2factor:notification.failure]]',
bodyLong: '',
nid: `2factor.failure.${uid}-${Date.now()}`,
from: uid,
path: `/2factor/access-notification?when=${Date.now()}`,
});
await notifications.push(notification, [uid]);
};
plugin.integrations = {};
plugin.integrations.writeApi = async (data) => {
const routeTest = /^\/api\/v\d\/users\/\d+\/tokens\/?/;
const uidMatch = data.route.match(/(\d+)\/tokens$/);
const uid = uidMatch ? parseInt(uidMatch[1], 10) : 0;
// Enforce 2FA on token generation route
if (data.method === 'POST' && routeTest.test(data.route) && await plugin.hasTotp(uid)) {
if (!data.req.headers.hasOwnProperty('x-two-factor-authentication')) {
// No 2FA received
return data.res.status(400).json(data.errorHandler.generate(
400, '2fa-enabled', 'Two Factor Authentication is enabled for this route, please send in the appropriate additional header for authorization', ['x-two-factor-authentication']
));
}
const skew = notp.totp.verify(data.req.headers['x-two-factor-authentication'], await plugin.get(uid));
if (!skew || Math.abs(skew.delta) > 2) {
return data.res.status(400).json(data.errorHandler.generate(
401, '2fa-failed', 'The Two-Factor Authentication code provided is not correct or has expired'
));
}
}
};
module.exports = plugin;
;