Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,26 @@ Controllers.generateBackupCodes = async (req, res) => {
};

Controllers.processBackup = async (req, res, next) => {
const count = await db.incrObjectField('locks', `backup:${req.uid}`);
if (count > 1) {
req.flash('error', '[[error:api.429]]');
await wait(10000); // 10s for spamming
return res.redirect(`${nconf.get('relative_path')}/login/2fa/backup`);
}

try {
const success = await parent.useBackupCode(req.body.code, req.user.uid);
if (!success) {
req.flash('error', '[[2factor:backup.failure]]');
await wait(2000);
await db.deleteObjectField('locks', `backup:${req.uid}`);
return res.redirect(`${nconf.get('relative_path')}/login/2fa/backup`);
}
// Success!
await db.deleteObjectField('locks', `backup:${req.uid}`);
next();
} catch (err) {
await db.deleteObjectField('locks', `backup:${req.uid}`);
req.flash('error', err.message);
res.redirect(`${nconf.get('relative_path')}/login/2fa/backup`);
}
Expand Down
34 changes: 25 additions & 9 deletions library.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const passportTotp = require('passport-totp').Strategy;
const notp = require('notp');
const { Fido2Lib } = require('fido2-lib');
const base64url = require('base64url');
const crypto = require('crypto');

const db = nodebb.require('./src/database');
const nconf = nodebb.require('nconf');
Expand All @@ -14,7 +15,6 @@ const meta = nodebb.require('./src/meta');
const groups = nodebb.require('./src/groups');
const plugins = nodebb.require('./src/plugins');
const notifications = nodebb.require('./src/notifications');
const utils = nodebb.require('./src/utils');
const routeHelpers = nodebb.require('./src/routes/helpers');
const controllerHelpers = nodebb.require('./src/controllers/helpers');
const SocketPlugins = nodebb.require('./src/socket.io/plugins');
Expand Down Expand Up @@ -57,7 +57,7 @@ plugin.init = async (params) => {
// 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) => {
router.post('/login/2fa/totp', hostMiddleware.ensureLoggedIn, hostMiddleware.applyCSRF, controllers.processTotpLogin, (req, res) => {
req.session.tfa = true;
const now = Date.now();
req.session.meta.datetime = now;
Expand All @@ -72,7 +72,7 @@ plugin.init = async (params) => {

// 2fa backups codes
hostHelpers.setupPageRoute(router, '/login/2fa/backup', [hostMiddleware.ensureLoggedIn], controllers.renderBackup);
router.post('/login/2fa/backup', hostMiddleware.ensureLoggedIn, controllers.processBackup, (req, res) => {
router.post('/login/2fa/backup', hostMiddleware.ensureLoggedIn, hostMiddleware.applyCSRF, controllers.processBackup, (req, res) => {
req.session.tfa = true;
res.redirect(guard(nconf.get('relative_path') + (req.query.next || '/')));
});
Expand Down Expand Up @@ -329,22 +329,24 @@ plugin.hasKey = async (uid) => {
return hasTotp || hasAuthn;
};

function hashBackupCode(code) {
return crypto.createHash('sha256').update(code).digest('hex');
}

plugin.hasBackupCodes = async uid => db.exists(`2factor:uid:${uid}:backupCodes`);

plugin.countBackupCodes = async uid => db.setCount(`2factor:uid:${uid}:backupCodes`);

plugin.generateBackupCodes = async (uid) => {
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);
codes.push(crypto.randomBytes(6).toString('hex'));
}

await db.delete(set); // Invalidate all old codes
await db.setAdd(set, codes); // Save new codes
await db.setAdd(set, codes.map(hashBackupCode)); // Save hashes only

const notification = await notifications.create({
bodyShort: '[[2factor:notification.backupCode.generated]]',
Expand All @@ -364,10 +366,24 @@ plugin.generateBackupCodes = async (uid) => {
plugin.useBackupCode = async (code, uid) => {
const set = `2factor:uid:${uid}:backupCodes`;

const valid = await db.isSetMember(set, code);
if (typeof code !== 'string') {
return false;
}
code = code.trim().toLowerCase().replace(/[\s-]/g, '');
if (!code) {
return false;
}

const hashed = hashBackupCode(code);
// Codes generated before hashing was introduced are stored in plain text
const [validHashed, validLegacy] = await Promise.all([
db.isSetMember(set, hashed),
db.isSetMember(set, code),
]);
const valid = validHashed || validLegacy;
if (valid) {
// Invalidate this backup code
await db.setRemove(set, code);
await db.setRemove(set, validHashed ? hashed : code);

const notification = await notifications.create({
bodyShort: '[[2factor:notification.backupCode.used]]',
Expand Down
2 changes: 1 addition & 1 deletion static/templates/login-backup.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<div class="form-group">
<input type="text" class="form-control form-control-large text-center" id="code" name="code" autocomplete="off" />
</div>
<input type="hidden" id="csrf" name="csrf" value="{config.csrf_token}" />
<input type="hidden" name="csrf_token" value="{config.csrf_token}" />
<button class="btn btn-block btn-primary text-center" type="submit">{{tx("2factor:login.verify")}}</button>

<hr />
Expand Down
2 changes: 1 addition & 1 deletion static/templates/login-totp.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<input type="text" inputmode="numeric" class="form-control form-control-large text-center" id="code" name="code" autocomplete="off" />
<button class="btn btn-block btn-primary text-center" type="submit">{{tx("2factor:login.verify")}}</button>
</div>
<input type="hidden" id="csrf" name="csrf" value="{config.csrf_token}" />
<input type="hidden" name="csrf_token" value="{config.csrf_token}" />
</form>

<hr />
Expand Down