Skip to content
Merged
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
10 changes: 10 additions & 0 deletions lib/api/2fa/totp.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ module.exports = (db, server, userHandler) => {
validationObjs: {
requestBody: {
token: Joi.string().length(6).required().description('6-digit number'),
totpNonce: Joi.string().hex().length(40).required().description('Short-lived nonce returned by /authenticate'),
sess: sessSchema,
ip: sessIPSchema
},
Expand Down Expand Up @@ -263,7 +264,16 @@ module.exports = (db, server, userHandler) => {
req.validate(roles.can(req.role).readAny('users'));
}

if (req.user === 'root') {
res.status(403);
return res.json({
error: 'TOTP validation is not allowed with master token',
code: 'InvalidToken'
});
}

let user = new ObjectId(result.value.user);
result.value.accessTokenHash = req.accessToken?.hash;
let totp = await userHandler.checkTotp(user, result.value);

if (!totp) {
Expand Down
12 changes: 6 additions & 6 deletions lib/api/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ module.exports = (db, server, userHandler) => {
token: Joi.string().description(
'If access token was requested then this is the value to use as access token when making API requests on behalf of logged in user.'
),
totpNonce: Joi.string().hex().length(40).description('Short-lived nonce for completing TOTP authentication'),
passwordPwned: booleanSchema.description(
'Indicates whether account password has been found in the list of Pwned passwords and should be replaced'
)
Expand Down Expand Up @@ -273,6 +274,9 @@ module.exports = (db, server, userHandler) => {
if (result.value.token) {
try {
authResponse.token = await userHandler.generateAuthToken(authData.user);
if (Array.isArray(authData.require2fa) && authData.require2fa.includes('totp')) {
authResponse.totpNonce = await userHandler.generateTotpNonce(authData.user, authResponse.token);
}
} catch (err) {
let response = {
error: err.message,
Expand Down Expand Up @@ -380,9 +384,7 @@ module.exports = (db, server, userHandler) => {
filter: Joi.string().description('Filter ID associated with the event'),
credential: Joi.string().description('WebAuthn credential ID'),
appId: Joi.string().description('Optional appId which is the URL of the app'),
require2fa: Joi.alternatives()
.try(Joi.string(), booleanSchema.allow(false))
.description('2FA requirement detail'),
require2fa: Joi.alternatives().try(Joi.string(), booleanSchema.allow(false)).description('2FA requirement detail'),
last: Joi.date().required().description('Date of the last update of data'),
events: Joi.number().required().description('Number of times same auth log has occurred'),
source: Joi.string().description('Source of auth. Example: `master` if password auth was used'),
Expand Down Expand Up @@ -564,9 +566,7 @@ module.exports = (db, server, userHandler) => {
filter: Joi.string().description('Filter ID associated with the event'),
credential: Joi.string().description('WebAuthn credential ID'),
appId: Joi.string().description('Optional appId which is the URL of the app'),
require2fa: Joi.alternatives()
.try(Joi.string(), booleanSchema.allow(false))
.description('2FA requirement detail'),
require2fa: Joi.alternatives().try(Joi.string(), booleanSchema.allow(false)).description('2FA requirement detail'),
last: Joi.date().required().description('Date of the last update of Event'),
events: Joi.number().required().description('Number of times same auth Event has occurred'),
source: Joi.string().description('Source of auth. Example: `master` if password auth was used'),
Expand Down
1 change: 1 addition & 0 deletions lib/consts.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ module.exports = {
TOTP_FAILURES: 6,
// TOTP authentication window in seconds, starts counting from first invalid authentication
TOTP_WINDOW: 180,
TOTP_NONCE_TTL: 10 * 60,

SCOPES: ['imap', 'pop3', 'smtp'],

Expand Down
103 changes: 103 additions & 0 deletions lib/user-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -2291,6 +2291,95 @@ class UserHandler {
return true;
}

async generateTotpNonce(user, accessToken) {
const tokenHash = crypto.createHash('sha256').update(accessToken).digest('hex');
const totpNonce = crypto.randomBytes(20).toString('hex');

try {
await this.redis.set(getTotpNonceKey(user, totpNonce), tokenHash, 'EX', consts.TOTP_NONCE_TTL);
return totpNonce;
} catch (err) {
log.error('Redis', 'REDISFAIL id=%s error=%s', user, err.message);
return false;
}
}

async validateTotpNonce(user, totpNonce, accessTokenHash) {
if (!accessTokenHash) {
const err = new Error('Authentication token is required');
err.response = 'NO';
err.responseCode = 403;
err.code = 'AuthenticationRequired';
throw err;
}

const totpNonceKey = getTotpNonceKey(user, totpNonce);
let res;

// GETDEL with TTL response
// Also checks if provided accessTokenHash is equal to the one stored
try {
res = await this.redis.eval(
`
local value = redis.call("GET", KEYS[1])
if not value then
return {0}
end

if value ~= ARGV[1] then
return {-1}
end

local ttl = redis.call("PTTL", KEYS[1])
redis.call("DEL", KEYS[1])
return {1, ttl}
`,
1,
totpNonceKey,
accessTokenHash
);
} catch (err) {
log.error('Redis', 'REDISFAIL id=%s error=%s', user, err.message);
}

if (!res || Number(res[0]) !== 1) {
const err = new Error('Invalid or expired TOTP nonce');
err.response = 'NO';
err.responseCode = 403;
err.code = 'InvalidTotpNonce';
throw err;
}

const ttl = Number(res[1]) || 0;

return {
user,
key: totpNonceKey,
accessTokenHash,
expires: Date.now() + ttl
};
}

// Restore TOTP nonce with correct remaining ttl if TOTP code failed
async restoreTotpNonce(consumedTotpNonce) {
if (!consumedTotpNonce || !consumedTotpNonce.key || !consumedTotpNonce.accessTokenHash) {
return false;
}

const ttl = Math.max(0, consumedTotpNonce.expires - Date.now());
if (!ttl) {
return false;
}

try {
await this.redis.set(consumedTotpNonce.key, consumedTotpNonce.accessTokenHash, 'PX', ttl);
return true;
} catch (err) {
log.error('Redis', 'REDISFAIL id=%s error=%s', consumedTotpNonce.user, err.message);
return false;
}
}

async checkTotp(user, data) {
let userRlKey = `totp:${user}`;
let totpSuccessKey = `totp:${user}:${data.token}`;
Expand All @@ -2306,6 +2395,8 @@ class UserHandler {
throw err;
}

let consumedTotpNonce = await this.validateTotpNonce(user, data.totpNonce, data.accessTokenHash);

try {
let totpAlreadyUsed = await this.redis.exists(totpSuccessKey);
if (totpAlreadyUsed) {
Expand Down Expand Up @@ -2401,6 +2492,14 @@ class UserHandler {
// ignore
}

if (!verified) {
try {
await this.restoreTotpNonce(consumedTotpNonce);
} catch {
// ignore
}
}

if (verified) {
try {
await this.redis
Expand Down Expand Up @@ -4030,6 +4129,10 @@ function rateLimitResponse(res) {
return err;
}

function getTotpNonceKey(user, totpNonce) {
return `totpnonce:${user}:${crypto.createHash('sha256').update(totpNonce).digest('hex')}`;
}

// high collision hash function
function getStringSelector(str) {
let hash = crypto.createHash('sha1').update(str).digest();
Expand Down
133 changes: 133 additions & 0 deletions test/api/totp-nonce-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */

'use strict';

const chai = require('chai');
const crypto = require('crypto');
const ObjectId = require('mongodb').ObjectId;

const expect = chai.expect;
chai.config.includeStack = true;

const UserHandler = require('../../lib/user-handler');

describe('TOTP Nonce Handling', function () {
this.timeout(10000); // eslint-disable-line no-invalid-this

it('should stop checkTotp if validateTotpNonce fails', async () => {
const calls = [];
const handler = {
rateLimit: async () => {
calls.push('rateLimit');
return { success: true };
},
validateTotpNonce: async () => {
calls.push('validate');
const err = new Error('Invalid or expired TOTP nonce');
err.response = 'NO';
err.responseCode = 403;
err.code = 'InvalidTotpNonce';
throw err;
}
};

let err;
try {
await UserHandler.prototype.checkTotp.call(handler, new ObjectId(), {
token: '000000',
totpNonce: crypto.randomBytes(20).toString('hex'),
accessTokenHash: crypto.randomBytes(32).toString('hex')
});
} catch (E) {
err = E;
}

expect(err).to.exist;
expect(err.code).to.equal('InvalidTotpNonce');
expect(calls).to.deep.equal(['rateLimit', 'validate']);
});

it('should restore the nonce after a failed TOTP verification', async () => {
const calls = [];
const accessTokenHash = crypto.randomBytes(32).toString('hex');
const seed = 'JBSWY3DPEHPK3PXP';
const handler = {
redis: {
exists: async () => 0,
multi() {
return {
set() {
return this;
},
expire() {
return this;
},
exec: async () => []
};
}
},
users: {
collection() {
return {
findOne: async () => ({
enabled2fa: ['totp'],
seed
})
};
}
},
rateLimit: async () => ({ success: true }),
rateLimitReleaseUser: async () => false,
logAuthEvent: async () => false,
validateTotpNonce: async () => {
calls.push('validate');
return {
user: new ObjectId(),
key: 'totpnonce:test',
accessTokenHash,
expires: Date.now() + 5 * 60 * 1000
};
},
restoreTotpNonce: async consumedTotpNonce => {
calls.push(`restore:${consumedTotpNonce.key}`);
return true;
}
};

const result = await UserHandler.prototype.checkTotp.call(handler, new ObjectId(), {
token: '000000',
totpNonce: crypto.randomBytes(20).toString('hex'),
accessTokenHash
});

expect(result).to.be.false;
expect(calls[0]).to.equal('validate');
expect(calls).to.include('restore:totpnonce:test');
});

it('should validate and consume a nonce, then restore it with the remaining TTL', async () => {
const user = new ObjectId();
const totpNonce = crypto.randomBytes(20).toString('hex');
const accessTokenHash = crypto.randomBytes(32).toString('hex');
let restoredArgs;

const handler = {
redis: {
eval: async () => [1, 5 * 60 * 1000],
set: async (...args) => {
restoredArgs = args;
}
}
};

const consumedTotpNonce = await UserHandler.prototype.validateTotpNonce.call(handler, user, totpNonce, accessTokenHash);
const restored = await UserHandler.prototype.restoreTotpNonce.call(handler, consumedTotpNonce);

expect(restored).to.be.true;
expect(restoredArgs[0]).to.equal(consumedTotpNonce.key);
expect(restoredArgs[1]).to.equal(accessTokenHash);
expect(restoredArgs[2]).to.equal('PX');
expect(restoredArgs[3]).to.be.above(0);
expect(restoredArgs[3]).to.be.at.most(5 * 60 * 1000);
});
});
Loading