mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-09-20 15:26:03 +08:00
Allow enabling custom 2FA that effectively disables account password for IMAP/SMTP/POP3
This commit is contained in:
parent
e5376d8a4d
commit
74100be5f0
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();
|
||||
|
|
|
@ -250,11 +250,11 @@ class UserHandler {
|
|||
* @param {Integer} count
|
||||
* @param {Function} callback
|
||||
*/
|
||||
rateLimitIP (meta, count, callback) {
|
||||
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});
|
||||
return callback(null, { success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -263,7 +263,7 @@ class UserHandler {
|
|||
* @param {Integer} count
|
||||
* @param {Function} callback
|
||||
*/
|
||||
rateLimitUser (tokenID, count, callback) {
|
||||
rateLimitUser(tokenID, count, callback) {
|
||||
this.counters.ttlcounter('auth_user:' + tokenID, count, consts.USER_AUTH_FAILURES, consts.USER_AUTH_WINDOW, callback);
|
||||
}
|
||||
|
||||
|
@ -273,11 +273,11 @@ class UserHandler {
|
|||
* @param {Integer} count
|
||||
* @param {Function} callback
|
||||
*/
|
||||
rateLimitReleaseUser (tokenID, callback) {
|
||||
rateLimitReleaseUser(tokenID, callback) {
|
||||
this.redis.del('auth_user:' + tokenID, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* rateLimit
|
||||
* @param {String} tokenID user identifier
|
||||
* @param {Object} meta
|
||||
|
@ -285,7 +285,7 @@ class UserHandler {
|
|||
* @param {Integer} count
|
||||
* @param {Function} callback
|
||||
*/
|
||||
rateLimit (tokenID, meta, count, callback) {
|
||||
rateLimit(tokenID, meta, count, callback) {
|
||||
this.rateLimitIP(meta, count, (err, ipRes) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -303,7 +303,6 @@ class UserHandler {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Authenticate user
|
||||
*
|
||||
|
@ -333,292 +332,295 @@ class UserHandler {
|
|||
return rateLimitResponse(res, callback);
|
||||
}
|
||||
|
||||
this.checkAddress(username, (err, query) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
this.checkAddress(username, (err, query) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
meta.username = username;
|
||||
meta.result = 'unknown';
|
||||
return this.logAuthEvent(null, meta, () => callback(null, false));
|
||||
}
|
||||
if (!query) {
|
||||
meta.username = username;
|
||||
meta.result = 'unknown';
|
||||
return this.logAuthEvent(null, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
this.users.collection('users').findOne(
|
||||
query,
|
||||
{
|
||||
fields: {
|
||||
_id: true,
|
||||
username: true,
|
||||
tempPassword: true,
|
||||
password: true,
|
||||
enabled2fa: true,
|
||||
u2f: true,
|
||||
disabled: true
|
||||
}
|
||||
},
|
||||
(err, userData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
if (query.unameview) {
|
||||
meta.username = query.unameview;
|
||||
} else {
|
||||
meta.user = query._id;
|
||||
this.users.collection('users').findOne(
|
||||
query,
|
||||
{
|
||||
fields: {
|
||||
_id: true,
|
||||
username: true,
|
||||
tempPassword: true,
|
||||
password: true,
|
||||
enabled2fa: true,
|
||||
u2f: true,
|
||||
disabled: true
|
||||
}
|
||||
meta.result = 'unknown';
|
||||
return this.logAuthEvent(null, meta, () => {
|
||||
// rate limit failed authentication attempts against non-existent users as well
|
||||
let ustring = (query.unameview || query._id || '').toString();
|
||||
|
||||
this.rateLimit(ustring, meta, 1, (err, res) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
if (!res.success) {
|
||||
return rateLimitResponse(res, callback);
|
||||
}
|
||||
callback(null, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.rateLimitUser(userData._id, 0, (err, res) => {
|
||||
},
|
||||
(err, userData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
if (!res.success) {
|
||||
return rateLimitResponse(res, callback);
|
||||
}
|
||||
|
||||
if (userData.disabled) {
|
||||
// disabled users can not log in
|
||||
meta.result = 'disabled';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
||||
|
||||
let getU2fAuthRequest = done => {
|
||||
if (!enabled2fa.includes('u2f') || !userData.u2f || !userData.u2f.keyHandle) {
|
||||
return done(null, false);
|
||||
if (!userData) {
|
||||
if (query.unameview) {
|
||||
meta.username = query.unameview;
|
||||
} else {
|
||||
meta.user = query._id;
|
||||
}
|
||||
this.generateU2fAuthRequest(userData._id, userData.u2f.keyHandle, done);
|
||||
};
|
||||
meta.result = 'unknown';
|
||||
return this.logAuthEvent(null, meta, () => {
|
||||
// rate limit failed authentication attempts against non-existent users as well
|
||||
let ustring = (query.unameview || query._id || '').toString();
|
||||
|
||||
let authSuccess = (...args) => {
|
||||
// clear rate limit counter on success
|
||||
this.rateLimitReleaseUser(userData._id, () => false);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
let authFail = (...args) => {
|
||||
// increment rate limit counter on failure
|
||||
this.rateLimit(userData._id, meta, 1, () => {
|
||||
callback(...args);
|
||||
});
|
||||
};
|
||||
|
||||
let requirePasswordChange = false;
|
||||
let usingTemporaryPassword = false;
|
||||
|
||||
let checkMasterPassword = next => {
|
||||
let checkAccountPassword = () => {
|
||||
bcrypt.compare(password, userData.password || '', next);
|
||||
};
|
||||
|
||||
if (userData.tempPassword && userData.tempPassword.created > new Date(Date.now() - 24 * 3600 * 1000)) {
|
||||
// try temporary password
|
||||
return bcrypt.compare(password, userData.tempPassword.password || '', (err, success) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (success) {
|
||||
if (userData.validAfter > new Date()) {
|
||||
let err = new Error('Temporary password is not yet activated');
|
||||
err.code = 'TempPasswordNotYetValid';
|
||||
return next(err);
|
||||
}
|
||||
|
||||
requirePasswordChange = true;
|
||||
usingTemporaryPassword = true;
|
||||
return next(null, true);
|
||||
}
|
||||
|
||||
checkAccountPassword();
|
||||
});
|
||||
}
|
||||
checkAccountPassword();
|
||||
};
|
||||
|
||||
// try master password
|
||||
checkMasterPassword((err, success) => {
|
||||
if (err) {
|
||||
err.code = err.code || 'BcryptError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
meta.result = 'success';
|
||||
meta.source = !usingTemporaryPassword ? 'master' : 'temporary';
|
||||
|
||||
if (enabled2fa.length) {
|
||||
meta.require2fa = enabled2fa.length ? enabled2fa.join(',') : false;
|
||||
}
|
||||
|
||||
if (requiredScope !== 'master' && (enabled2fa.length || usingTemporaryPassword)) {
|
||||
// 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')));
|
||||
}
|
||||
|
||||
return this.logAuthEvent(userData._id, meta, () => {
|
||||
let authResponse = {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: meta.scope,
|
||||
// if 2FA is enabled then require token validation
|
||||
require2fa: enabled2fa.length && !usingTemporaryPassword ? enabled2fa : false,
|
||||
requirePasswordChange // true, if password was reset and using temporary password
|
||||
};
|
||||
|
||||
if (enabled2fa.length && !usingTemporaryPassword) {
|
||||
authResponse.enabled2fa = enabled2fa;
|
||||
|
||||
return getU2fAuthRequest((err, u2fAuthRequest) => {
|
||||
if (err) {
|
||||
log.error('DB', 'U2FREFAIL u2fAuthRequest id=%s error=%s', userData._id, err.message);
|
||||
}
|
||||
if (u2fAuthRequest) {
|
||||
authResponse.u2fAuthRequest = u2fAuthRequest;
|
||||
}
|
||||
authSuccess(null, authResponse);
|
||||
});
|
||||
}
|
||||
|
||||
authSuccess(null, authResponse);
|
||||
});
|
||||
}
|
||||
|
||||
if (requiredScope === 'master') {
|
||||
// only master password can be used for management tasks
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
// try application specific passwords
|
||||
password = password.replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
if (!/^[a-z]{16}$/.test(password)) {
|
||||
// does not look like an application specific password
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
let selector = getStringSelector(password);
|
||||
|
||||
this.users
|
||||
.collection('asps')
|
||||
.find({
|
||||
user: userData._id
|
||||
})
|
||||
.toArray((err, asps) => {
|
||||
this.rateLimit(ustring, meta, 1, (err, res) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
if (!res.success) {
|
||||
return rateLimitResponse(res, callback);
|
||||
}
|
||||
callback(null, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!asps || !asps.length) {
|
||||
// user does not have app specific passwords set
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
this.rateLimitUser(userData._id, 0, (err, res) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
if (!res.success) {
|
||||
return rateLimitResponse(res, callback);
|
||||
}
|
||||
|
||||
if (userData.disabled) {
|
||||
// disabled users can not log in
|
||||
meta.result = 'disabled';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
||||
|
||||
let getU2fAuthRequest = done => {
|
||||
if (!enabled2fa.includes('u2f') || !userData.u2f || !userData.u2f.keyHandle) {
|
||||
return done(null, false);
|
||||
}
|
||||
this.generateU2fAuthRequest(userData._id, userData.u2f.keyHandle, done);
|
||||
};
|
||||
|
||||
let authSuccess = (...args) => {
|
||||
// clear rate limit counter on success
|
||||
this.rateLimitReleaseUser(userData._id, () => false);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
let authFail = (...args) => {
|
||||
// increment rate limit counter on failure
|
||||
this.rateLimit(userData._id, meta, 1, () => {
|
||||
callback(...args);
|
||||
});
|
||||
};
|
||||
|
||||
let requirePasswordChange = false;
|
||||
let usingTemporaryPassword = false;
|
||||
|
||||
let checkMasterPassword = next => {
|
||||
let checkAccountPassword = () => {
|
||||
bcrypt.compare(password, userData.password || '', next);
|
||||
};
|
||||
|
||||
if (userData.tempPassword && userData.tempPassword.created > new Date(Date.now() - 24 * 3600 * 1000)) {
|
||||
// try temporary password
|
||||
return bcrypt.compare(password, userData.tempPassword.password || '', (err, success) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (success) {
|
||||
if (userData.validAfter > new Date()) {
|
||||
let err = new Error('Temporary password is not yet activated');
|
||||
err.code = 'TempPasswordNotYetValid';
|
||||
return next(err);
|
||||
}
|
||||
|
||||
requirePasswordChange = true;
|
||||
usingTemporaryPassword = true;
|
||||
return next(null, true);
|
||||
}
|
||||
|
||||
checkAccountPassword();
|
||||
});
|
||||
}
|
||||
checkAccountPassword();
|
||||
};
|
||||
|
||||
// try master password
|
||||
checkMasterPassword((err, success) => {
|
||||
if (err) {
|
||||
err.code = err.code || 'BcryptError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
meta.result = 'success';
|
||||
meta.source = !usingTemporaryPassword ? 'master' : 'temporary';
|
||||
|
||||
if (enabled2fa.length) {
|
||||
meta.require2fa = enabled2fa.length ? enabled2fa.join(',') : false;
|
||||
}
|
||||
|
||||
let pos = 0;
|
||||
let checkNext = () => {
|
||||
if (pos >= asps.length) {
|
||||
if (requiredScope !== 'master' && (enabled2fa.length || usingTemporaryPassword)) {
|
||||
// master password can not be used for other stuff if 2FA is enabled
|
||||
// temporary password is only valid for master
|
||||
meta.result = 'fail';
|
||||
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, () => {
|
||||
let authResponse = {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: meta.scope,
|
||||
// if 2FA is enabled then require token validation
|
||||
require2fa: enabled2fa.length && !usingTemporaryPassword ? enabled2fa : false,
|
||||
requirePasswordChange // true, if password was reset and using temporary password
|
||||
};
|
||||
|
||||
if (enabled2fa.length && !usingTemporaryPassword) {
|
||||
authResponse.enabled2fa = enabled2fa;
|
||||
|
||||
return getU2fAuthRequest((err, u2fAuthRequest) => {
|
||||
if (err) {
|
||||
log.error('DB', 'U2FREFAIL u2fAuthRequest id=%s error=%s', userData._id, err.message);
|
||||
}
|
||||
if (u2fAuthRequest) {
|
||||
authResponse.u2fAuthRequest = u2fAuthRequest;
|
||||
}
|
||||
authSuccess(null, authResponse);
|
||||
});
|
||||
}
|
||||
|
||||
authSuccess(null, authResponse);
|
||||
});
|
||||
}
|
||||
|
||||
if (requiredScope === 'master') {
|
||||
// only master password can be used for management tasks
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
// try application specific passwords
|
||||
password = password.replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
if (!/^[a-z]{16}$/.test(password)) {
|
||||
// does not look like an application specific password
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
let selector = getStringSelector(password);
|
||||
|
||||
this.users
|
||||
.collection('asps')
|
||||
.find({
|
||||
user: userData._id
|
||||
})
|
||||
.toArray((err, asps) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!asps || !asps.length) {
|
||||
// user does not have app specific passwords set
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
let asp = asps[pos++];
|
||||
if (asp.selector && asp.selector !== selector) {
|
||||
// no need to check, definitely a wrong one
|
||||
return setImmediate(checkNext);
|
||||
}
|
||||
|
||||
bcrypt.compare(password, asp.password || '', (err, success) => {
|
||||
if (err) {
|
||||
err.code = 'BcryptError';
|
||||
return callback(err);
|
||||
let pos = 0;
|
||||
let checkNext = () => {
|
||||
if (pos >= asps.length) {
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
let asp = asps[pos++];
|
||||
if (asp.selector && asp.selector !== selector) {
|
||||
// no need to check, definitely a wrong one
|
||||
return setImmediate(checkNext);
|
||||
}
|
||||
|
||||
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
|
||||
meta.result = 'fail';
|
||||
bcrypt.compare(password, asp.password || '', (err, success) => {
|
||||
if (err) {
|
||||
err.code = 'BcryptError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
return setImmediate(checkNext);
|
||||
}
|
||||
|
||||
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
|
||||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id;
|
||||
meta.aname = asp.description;
|
||||
|
||||
return this.logAuthEvent(userData._id, meta, () =>
|
||||
authFail(new Error('Authentication failed. Invalid scope'))
|
||||
);
|
||||
}
|
||||
|
||||
meta.result = 'success';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id;
|
||||
meta.aname = asp.description;
|
||||
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(new Error('Authentication failed. Invalid scope')));
|
||||
}
|
||||
|
||||
meta.result = 'success';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id;
|
||||
meta.aname = asp.description;
|
||||
|
||||
this.logAuthEvent(userData._id, meta, (err, r) => {
|
||||
if (err) {
|
||||
// don't really care
|
||||
}
|
||||
this.rateLimitReleaseUser(userData._id, () => false);
|
||||
this.users.collection('asps').findOneAndUpdate(
|
||||
{
|
||||
_id: asp._id
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
used: new Date(),
|
||||
authEvent: r.insertedId
|
||||
}
|
||||
},
|
||||
() => {
|
||||
authSuccess(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: requiredScope,
|
||||
asp: asp._id.toString(),
|
||||
require2fa: false // application scope never requires 2FA
|
||||
});
|
||||
this.logAuthEvent(userData._id, meta, (err, r) => {
|
||||
if (err) {
|
||||
// don't really care
|
||||
}
|
||||
);
|
||||
this.rateLimitReleaseUser(userData._id, () => false);
|
||||
this.users.collection('asps').findOneAndUpdate(
|
||||
{
|
||||
_id: asp._id
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
used: new Date(),
|
||||
authEvent: r.insertedId
|
||||
}
|
||||
},
|
||||
() => {
|
||||
authSuccess(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: requiredScope,
|
||||
asp: asp._id.toString(),
|
||||
require2fa: false // application scope never requires 2FA
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
checkNext();
|
||||
});
|
||||
checkNext();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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…
Reference in a new issue