mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-09-12 08:04:37 +08:00
Allow enabling custom 2FA that effectively disables account password for IMAP/SMTP/POP3
This commit is contained in:
parent
e5376d8a4d
commit
74100be5f0
9 changed files with 631 additions and 299 deletions
2
api.js
2
api.js
|
@ -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
|
@ -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"
}
});
|
||||
|
|
|
@ -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
185
lib/api/2fa/custom.js
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
28
package.json
28
package.json
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue