Allow enabling custom 2FA that effectively disables account password for IMAP/SMTP/POP3

This commit is contained in:
Andris Reinman 2018-04-13 14:32:58 +03:00
parent e5376d8a4d
commit 74100be5f0
9 changed files with 631 additions and 299 deletions

2
api.js
View file

@ -18,6 +18,7 @@ const messagesRoutes = require('./lib/api/messages');
const filtersRoutes = require('./lib/api/filters');
const aspsRoutes = require('./lib/api/asps');
const totpRoutes = require('./lib/api/2fa/totp');
const custom2faRoutes = require('./lib/api/2fa/custom');
const u2fRoutes = require('./lib/api/2fa/u2f');
const updatesRoutes = require('./lib/api/updates');
const authRoutes = require('./lib/api/auth');
@ -153,6 +154,7 @@ module.exports = done => {
filtersRoutes(db, server);
aspsRoutes(db, server, userHandler);
totpRoutes(db, server, userHandler);
custom2faRoutes(db, server, userHandler);
u2fRoutes(db, server, userHandler);
updatesRoutes(db, server, notifier);
authRoutes(db, server, userHandler);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-02-19T14:11:09.580Z", "url": "http://apidocjs.com", "version": "0.17.6" } });
define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-04-13T11:32:14.959Z", "url": "http://apidocjs.com", "version": "0.17.6" } });

View file

@ -1 +1 @@
{ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-02-19T14:11:09.580Z", "url": "http://apidocjs.com", "version": "0.17.6" } }
{ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-04-13T11:32:14.959Z", "url": "http://apidocjs.com", "version": "0.17.6" } }

185
lib/api/2fa/custom.js Normal file
View file

@ -0,0 +1,185 @@
'use strict';
const Joi = require('joi');
const ObjectID = require('mongodb').ObjectID;
// Custom 2FA needs to be enabled if your website handles its own 2FA and you want to disable
// master password usage for IMAP/POP/SMTP clients
module.exports = (db, server, userHandler) => {
/**
* @api {put} /users/:user/2fa/custom Enable custom 2FA for an user
* @apiName EnableCustom2FA
* @apiGroup TwoFactorAuth
* @apiDescription This method disables account password for IMAP/POP3/SMTP
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} user ID of the User
* @apiParam {String} [sess] Session identifier for the logs
* @apiParam {String} [ip] IP address for the logs
*
* @apiSuccess {Boolean} success Indicates successful response
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i -XPUT http://localhost:8080/users/59fc66a03e54454869460e45/2fa/custom \
* -H 'Content-type: application/json' \
* -d '{
* "ip": "127.0.0.1"
* }'
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "This username does not exist"
* "code": "UserNotFound"
* }
*/
server.put('/users/:user/2fa/custom', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.enableCustom2fa(user, result.value, (err, success) => {
if (err) {
res.json({
error: err.message,
code: err.code
});
return next();
}
res.json({
success
});
return next();
});
});
/**
* @api {delete} /users/:user/2fa/custom Disable custom 2FA for an user
* @apiName DisableCustom2FA
* @apiGroup TwoFactorAuth
* @apiDescription This method disables custom 2FA. If it was the only 2FA set up, then account password for IMAP/POP3/SMTP gets enabled again
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} user ID of the User
* @apiParam {String} [sess] Session identifier for the logs
* @apiParam {String} [ip] IP address for the logs
*
* @apiSuccess {Boolean} success Indicates successful response
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i -XDELETE http://localhost:8080/users/59fc66a03e54454869460e45/2fa/custom \
* -H 'Content-type: application/json' \
* -d '{
* "ip": "127.0.0.1"
* }'
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "This username does not exist"
* "code": "UserNotFound"
* }
*/
server.del('/users/:user/2fa/custom', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
req.query.user = req.params.user;
const result = Joi.validate(req.query, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
return next();
}
let user = new ObjectID(result.value.user);
userHandler.disableCustom2fa(user, result.value, (err, success) => {
if (err) {
res.json({
error: err.message,
code: err.code
});
return next();
}
res.json({
success
});
return next();
});
});
};

View file

@ -159,7 +159,7 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user);
userHandler.disableTotp(user, result.value, (err, result) => {
userHandler.disableTotp(user, result.value, (err, success) => {
if (err) {
res.json({
error: err.message,
@ -168,16 +168,8 @@ module.exports = (db, server, userHandler) => {
return next();
}
if (!result) {
res.json({
error: 'Invalid authentication token',
code: 'InvalidToken'
});
return next();
}
res.json({
success: true
success
});
return next();
@ -278,7 +270,7 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user);
userHandler.disable2fa(user, result.value, (err, result) => {
userHandler.disable2fa(user, result.value, (err, success) => {
if (err) {
res.json({
error: err.message,
@ -287,16 +279,8 @@ module.exports = (db, server, userHandler) => {
return next();
}
if (!result) {
res.json({
error: 'Failed to disable U2F',
code: 'TotpDisableFailed'
});
return next();
}
res.json({
success: true
success
});
return next();

View file

@ -252,7 +252,7 @@ class UserHandler {
*/
rateLimitIP(meta, count, callback) {
if (meta.ip) {
this.counters.ttlcounter('auth_ip:' + meta.ip, count, consts.IP_AUTH_FAILURES, consts.IP_AUTH_WINDOW, callback);
return this.counters.ttlcounter('auth_ip:' + meta.ip, count, consts.IP_AUTH_FAILURES, consts.IP_AUTH_WINDOW, callback);
}
return callback(null, { success: true });
}
@ -303,7 +303,6 @@ class UserHandler {
});
}
/**
* Authenticate user
*
@ -475,7 +474,9 @@ class UserHandler {
// master password can not be used for other stuff if 2FA is enabled
// temporary password is only valid for master
meta.result = 'fail';
return this.logAuthEvent(userData._id, meta, () => authFail(new Error('Authentication failed. Invalid scope')));
let err = new Error('Authentication failed. Invalid scope');
err.response = 'NO'; // imap response code
return this.logAuthEvent(userData._id, meta, () => authFail(err));
}
return this.logAuthEvent(userData._id, meta, () => {
@ -573,7 +574,9 @@ class UserHandler {
meta.asp = asp._id;
meta.aname = asp.description;
return this.logAuthEvent(userData._id, meta, () => authFail(new Error('Authentication failed. Invalid scope')));
return this.logAuthEvent(userData._id, meta, () =>
authFail(new Error('Authentication failed. Invalid scope'))
);
}
meta.result = 'success';
@ -618,7 +621,6 @@ class UserHandler {
);
});
});
}
/**
@ -1594,7 +1596,9 @@ class UserHandler {
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (!enabled2fa.includes('totp')) {
return callback(new Error('Could not update user, check if 2FA TOTP is not already disabled'));
let err = new Error('Could not update user, check if 2FA TOTP is not already disabled');
err.code = 'TotpDisabled';
return callback(err);
}
let update =
@ -1629,7 +1633,9 @@ class UserHandler {
}
if (!result || !result.value) {
return callback(new Error('Could not update user, check if 2FA is not already disabled'));
let err = new Error('This username does not exist');
err.code = 'UserNotFound';
return callback(err);
}
return this.logAuthEvent(
@ -1649,7 +1655,7 @@ class UserHandler {
checkTotp(user, data, callback) {
let userRlKey = 'totp:' + user;
this.rateLimit(userRlKey, data, 0, (err, res) => { // NOT Sure why this used "consts.USER_AUTH_WINDOW * 3"
this.rateLimit(userRlKey, data, 0, (err, res) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
@ -1746,6 +1752,161 @@ class UserHandler {
});
}
enableCustom2fa(user, data, callback) {
this.users.collection('users').findOne(
{
_id: user
},
{
fields: {
enabled2fa: true,
username: true
}
},
(err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to fetch user';
err.code = 'InternalDatabaseError';
return callback(err);
}
if (!userData) {
let err = new Error('This username does not exist');
err.code = 'UserNotFound';
return callback(err);
}
// previous versions used {enabled2fa: true} for TOTP based 2FA
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
if (enabled2fa.includes('custom')) {
// 2fa not set up
let err = new Error('Custom 2FA is already enabled for this user');
err.code = 'CustomEnabled';
return callback(err);
}
let update =
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
? {
$set: {
enabled2fa: ['custom'].concat(userData.enabled2fa ? 'totp' : [])
}
}
: {
$addToSet: {
enabled2fa: 'custom'
}
};
// update user settings
return this.users.collection('users').findOneAndUpdate(
{
_id: user
},
update,
{},
(err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
err.code = 'InternalDatabaseError';
return callback(err);
}
if (!result || !result.value) {
let err = new Error('This username does not exist');
err.code = 'UserNotFound';
return callback(err);
}
return this.logAuthEvent(
user,
{
action: 'enable 2fa custom',
result: 'success',
sess: data.sess,
ip: data.ip
},
() => callback(null, true)
);
}
);
}
);
}
disableCustom2fa(user, data, callback) {
this.users.collection('users').findOne(
{
_id: user
},
{
fields: {
enabled2fa: true,
username: true
}
},
(err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
err.code = 'InternalDatabaseError';
return callback(err);
}
if (!userData) {
let err = new Error('This username does not exist');
err.code = 'UserNotFound';
return callback(err);
}
if (!Array.isArray(userData.enabled2fa) || !userData.enabled2fa.includes('custom')) {
let err = new Error('Could not update user, check if custom 2FA is not already disabled');
err.code = 'CustomDisabled';
return callback(err);
}
let update = {
$pull: {
enabled2fa: 'custom'
}
};
return this.users.collection('users').findOneAndUpdate(
{
_id: user
},
update,
{},
(err, result) => {
if (err) {
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
err.message = 'Database Error, failed to update user';
err.code = 'InternalDatabaseError';
return callback(err);
}
if (!result || !result.value) {
let err = new Error('This username does not exist');
err.code = 'UserNotFound';
return callback(err);
}
return this.logAuthEvent(
user,
{
action: 'disable 2fa custom',
sess: data.sess,
ip: data.ip
},
() => callback(null, true)
);
}
);
}
);
}
setupU2f(user, data, callback) {
let registrationRequest;
try {

View file

@ -23,25 +23,25 @@
"grunt-shell-spawn": "^0.3.10",
"grunt-wait": "^0.1.0",
"icedfrisby": "^1.5.0",
"mailparser": "^2.2.0",
"mailparser": "2.2.0",
"markdown-toc": "^1.2.0",
"mocha": "^5.0.1",
"request": "^2.83.0"
"mocha": "^5.1.0",
"request": "^2.85.0"
},
"dependencies": {
"addressparser": "1.0.1",
"bcryptjs": "2.4.3",
"bugsnag": "2.1.3",
"bugsnag": "2.3.1",
"generate-password": "1.4.0",
"he": "1.1.1",
"html-to-text": "3.3.0",
"html-to-text": "4.0.0",
"humanname": "0.2.2",
"humanparser": "1.5.0",
"iconv-lite": "0.4.19",
"iconv-lite": "0.4.21",
"ioredfour": "1.0.2-ioredis",
"ioredis": "3.2.2",
"joi": "13.1.2",
"js-yaml": "3.10.0",
"js-yaml": "3.11.0",
"key-fingerprint": "1.1.0",
"libbase64": "1.0.2",
"libmime": "3.1.0",
@ -50,19 +50,19 @@
"mailsplit": "4.1.2",
"mobileconfig": "2.1.0",
"mongo-cursor-pagination-node6": "5.0.0",
"mongodb": "3.0.2",
"mongodb": "3.0.6",
"mongodb-extended-json": "1.10.0",
"node-forge": "0.7.1",
"nodemailer": "4.4.2",
"node-forge": "0.7.5",
"nodemailer": "4.6.4",
"npmlog": "4.1.2",
"openpgp": "2.6.2",
"openpgp": "3.0.4",
"qrcode": "1.2.0",
"restify": "6.3.4",
"restify": "7.1.1",
"restify-logger": "2.0.1",
"seq-index": "1.1.0",
"smtp-server": "3.4.1",
"smtp-server": "3.4.2",
"speakeasy": "2.0.0",
"tlds": "1.199.0",
"tlds": "1.203.1",
"u2f": "0.1.3",
"utf7": "1.0.2",
"uuid": "3.2.1",