mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-09-21 07:46:03 +08:00
3394 lines
137 KiB
JavaScript
3394 lines
137 KiB
JavaScript
'use strict';
|
|
|
|
const config = require('wild-config');
|
|
const log = require('npmlog');
|
|
const hashes = require('./hashes');
|
|
const speakeasy = require('speakeasy');
|
|
const QRCode = require('qrcode');
|
|
const tools = require('./tools');
|
|
const consts = require('./consts');
|
|
const counters = require('./counters');
|
|
const ObjectID = require('mongodb').ObjectID;
|
|
const generatePassword = require('generate-password');
|
|
const os = require('os');
|
|
const crypto = require('crypto');
|
|
const mailboxTranslations = require('./translations');
|
|
const MailComposer = require('nodemailer/lib/mail-composer');
|
|
const humanname = require('humanname');
|
|
const u2f = require('u2f');
|
|
const UserCache = require('./user-cache');
|
|
const isemail = require('isemail');
|
|
|
|
const TOTP_SETUP_TTL = 6 * 3600 * 1000;
|
|
|
|
class UserHandler {
|
|
constructor(options) {
|
|
this.database = options.database;
|
|
this.users = options.users || options.database;
|
|
this.redis = options.redis;
|
|
|
|
this.loggelf = options.loggelf || (() => false);
|
|
|
|
this.messageHandler = options.messageHandler;
|
|
this.counters = this.messageHandler ? this.messageHandler.counters : counters(this.redis);
|
|
|
|
this.userCache = new UserCache({
|
|
users: this.users,
|
|
redis: this.redis
|
|
});
|
|
|
|
if (!('authlogExpireDays' in options)) {
|
|
this.authlogExpireDays = 30;
|
|
} else {
|
|
this.authlogExpireDays = options.authlogExpireDays;
|
|
}
|
|
}
|
|
|
|
resolveAddress(address, options, callback) {
|
|
options = options || {};
|
|
let wildcard = !!options.wildcard;
|
|
|
|
address = tools.normalizeAddress(address, false, {
|
|
removeLabel: true,
|
|
removeDots: true
|
|
});
|
|
|
|
let username = address.substr(0, address.indexOf('@'));
|
|
let domain = address.substr(address.indexOf('@') + 1);
|
|
|
|
let projection = {
|
|
user: true,
|
|
targets: true
|
|
};
|
|
|
|
Object.keys(options.projection || {}).forEach(key => {
|
|
projection[key] = true;
|
|
});
|
|
|
|
// try exact match
|
|
this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@' + domain
|
|
},
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, addressData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (addressData) {
|
|
return callback(null, addressData);
|
|
}
|
|
|
|
let aliasDomain;
|
|
// try an alias
|
|
let checkAliases = done => {
|
|
this.users.collection('domainaliases').findOne(
|
|
{ alias: domain },
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, aliasData) => {
|
|
if (err) {
|
|
return done(err);
|
|
}
|
|
if (!aliasData) {
|
|
return done();
|
|
}
|
|
|
|
aliasDomain = aliasData.domain;
|
|
|
|
this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@' + aliasDomain
|
|
},
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
done
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
checkAliases((err, addressData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (addressData) {
|
|
return callback(null, addressData);
|
|
}
|
|
|
|
if (!wildcard) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
let query = {
|
|
addrview: '*@' + domain
|
|
};
|
|
|
|
if (aliasDomain) {
|
|
// search for alias domain as well
|
|
query.addrview = { $in: [query.addrview, '*@' + aliasDomain] };
|
|
}
|
|
|
|
// try to find a catch-all address
|
|
this.users.collection('addresses').findOne(
|
|
query,
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, addressData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (addressData) {
|
|
return callback(null, addressData);
|
|
}
|
|
|
|
// try to find a catch-all user (eg. "postmaster@*")
|
|
this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@*'
|
|
},
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, addressData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!addressData) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
return callback(null, addressData);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reolve user by username/address
|
|
*
|
|
* @param {String} username Either username or email address
|
|
* @param {Object} [extraFields] Optional projection fields object
|
|
*/
|
|
get(username, extraFields, callback) {
|
|
if (!callback && typeof extraFields === 'function') {
|
|
callback = extraFields;
|
|
extraFields = false;
|
|
}
|
|
|
|
let fields = {
|
|
_id: true,
|
|
quota: true,
|
|
storageUsed: true,
|
|
disabled: true
|
|
};
|
|
|
|
Object.keys(extraFields || {}).forEach(field => {
|
|
fields[field] = true;
|
|
});
|
|
|
|
let checkAddress = next => {
|
|
if (tools.isId(username)) {
|
|
return next(null, {
|
|
query: { _id: new ObjectID(username) }
|
|
});
|
|
}
|
|
|
|
if (username.indexOf('@') < 0) {
|
|
// assume regular username
|
|
return next(null, {
|
|
query: { unameview: tools.uview(username) }
|
|
});
|
|
}
|
|
|
|
this.resolveAddress(username, { projection: { name: true } }, (err, addressData) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
if (addressData.user) {
|
|
return next(null, {
|
|
query: { _id: addressData.user },
|
|
addressData
|
|
});
|
|
}
|
|
return callback(null, false);
|
|
});
|
|
};
|
|
|
|
checkAddress((err, data) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
this.users.collection('users').findOne(
|
|
data.query,
|
|
{
|
|
projection: fields,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (userData && fields.name && data.addressData) {
|
|
// override name
|
|
userData.name = data.addressData.name || userData.name;
|
|
}
|
|
|
|
return callback(null, userData);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* rateLimitIP
|
|
* if ip is not available will always return success object
|
|
* @param {Object} meta
|
|
* @param {String} meta.ip request remote ip address
|
|
* @param {Integer} count
|
|
* @param {Function} callback
|
|
*/
|
|
rateLimitIP(meta, count, callback) {
|
|
if (!meta || !meta.ip || !consts.IP_AUTH_FAILURES) {
|
|
return callback(null, { success: true });
|
|
}
|
|
let wlKey = 'rl-wl';
|
|
// $ redis-cli
|
|
// > SADD "rl-wl" "1.2.3.4"
|
|
this.redis.sismember(wlKey, meta.ip, (err, isMember) => {
|
|
if (err) {
|
|
log.error('Redis', 'SMFAIL key=%s value=%s error=%s', wlKey, meta.ip, err.message);
|
|
// ignore errors
|
|
return callback(null, { success: true });
|
|
}
|
|
|
|
if (isMember) {
|
|
// whitelisted IP
|
|
return callback(null, { success: true });
|
|
}
|
|
|
|
return this.counters.ttlcounter('auth_ip:' + meta.ip, count, consts.IP_AUTH_FAILURES, consts.IP_AUTH_WINDOW, callback);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* rateLimitUser
|
|
* @param {String} tokenID user identifier
|
|
* @param {Integer} count
|
|
* @param {Function} callback
|
|
*/
|
|
rateLimitUser(tokenID, meta, count, callback) {
|
|
let checkUserRateLimit = () => this.counters.ttlcounter('auth_user:' + tokenID, count, consts.USER_AUTH_FAILURES, consts.USER_AUTH_WINDOW, callback);
|
|
|
|
if (!meta || !meta.ip) {
|
|
// not IP address to check for
|
|
return checkUserRateLimit();
|
|
}
|
|
|
|
let wlKey = 'rl-wl';
|
|
// $ redis-cli
|
|
// > SADD "rl-wl" "1.2.3.4"
|
|
this.redis.sismember(wlKey, meta.ip, (err, isMember) => {
|
|
if (err) {
|
|
log.error('Redis', 'SMFAIL key=%s value=%s error=%s', wlKey, meta.ip, err.message);
|
|
// ignore errors
|
|
}
|
|
|
|
if (isMember) {
|
|
// whitelisted IP, allow authentication attempt without rate limits
|
|
return callback(null, { success: true });
|
|
}
|
|
|
|
return checkUserRateLimit();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* rateLimitReleaseUser
|
|
* @param {String} tokenID user identifier
|
|
* @param {Integer} count
|
|
* @param {Function} callback
|
|
*/
|
|
rateLimitReleaseUser(tokenID, callback) {
|
|
this.redis.del('auth_user:' + tokenID, callback);
|
|
}
|
|
|
|
/**
|
|
* rateLimit
|
|
* @param {String} tokenID user identifier
|
|
* @param {Object} meta
|
|
* @param {String} meta.ip request remote ip address
|
|
* @param {Integer} count
|
|
* @param {Function} callback
|
|
*/
|
|
rateLimit(tokenID, meta, count, callback) {
|
|
this.rateLimitIP(meta, count, (err, ipRes) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
this.rateLimitUser(tokenID, meta, count, (err, userRes) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
if (!ipRes.success) {
|
|
return callback(null, ipRes);
|
|
}
|
|
return callback(null, userRes);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Authenticate user
|
|
*
|
|
* @param {String} username Either username or email address
|
|
*/
|
|
authenticate(username, password, requiredScope, meta, callback) {
|
|
if (!callback && typeof meta === 'function') {
|
|
callback = meta;
|
|
meta = {};
|
|
}
|
|
|
|
username = (username || '').toString();
|
|
let userDomain = username.indexOf('@') >= 0 ? username.split('@').pop() : '';
|
|
|
|
let passwordType = 'master';
|
|
let passwordId;
|
|
|
|
meta = meta || {};
|
|
meta.requiredScope = requiredScope;
|
|
|
|
if (!password) {
|
|
// do not allow signing in without a password
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Empty password',
|
|
_auth_result: 'fail',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return callback(null, false, false);
|
|
}
|
|
|
|
// first check if client IP is not used too much
|
|
this.rateLimitIP(meta, 0, (err, res) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
full_message: err.stack,
|
|
_error: err.message,
|
|
_code: err.code,
|
|
_auth_result: 'error',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return callback(err, false, false);
|
|
}
|
|
|
|
if (!res.success) {
|
|
// too many failed attempts from this IP
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Rate limited',
|
|
_auth_result: 'ratelimited',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return rateLimitResponse(res, err => callback(err, false, false));
|
|
}
|
|
|
|
this.checkAddress(username, (err, query) => {
|
|
if (err) {
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Unknown user',
|
|
_auth_result: 'unknown',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return callback(err, false, false);
|
|
}
|
|
|
|
if (!query) {
|
|
// nothing to do here
|
|
return callback(null, false, false);
|
|
}
|
|
|
|
this.users.collection('users').findOne(
|
|
query,
|
|
{
|
|
projection: {
|
|
_id: true,
|
|
username: true,
|
|
address: true,
|
|
tempPassword: true,
|
|
password: true,
|
|
enabled2fa: true,
|
|
u2f: true,
|
|
disabled: true,
|
|
disabledScopes: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
full_message: err.stack,
|
|
_error: err.message,
|
|
_code: err.code,
|
|
_auth_result: 'error',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return callback(err, false, false);
|
|
}
|
|
|
|
if (!userData) {
|
|
// rate limit failed authentication attempts against non-existent users as well
|
|
let ustring = (query.unameview || query._id || '').toString();
|
|
return this.rateLimit(ustring, meta, 1, (err, res) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
full_message: err.stack,
|
|
_error: err.message,
|
|
_code: err.code,
|
|
_auth_result: 'error',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return callback(err, false, false);
|
|
}
|
|
if (!res.success) {
|
|
// does not really matter but respond with a rate limit error, not auth fail error
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Rate limited',
|
|
_auth_result: 'ratelimited',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return rateLimitResponse(res, err => callback(err, false, false));
|
|
}
|
|
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Unknown user',
|
|
_auth_result: 'unknown',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
callback(null, false, false);
|
|
});
|
|
}
|
|
|
|
// make sure we use the primary domain if available
|
|
userDomain = (userData.address || '').split('@').pop() || userDomain;
|
|
|
|
// check if there are not too many auth attempts for that user
|
|
this.rateLimitUser(userData._id, meta, 0, (err, res) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
full_message: err.stack,
|
|
_error: err.message,
|
|
_code: err.code,
|
|
_auth_result: 'error',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_user: userData._id,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return callback(err, false, userData._id);
|
|
}
|
|
if (!res.success) {
|
|
// too many failed attempts for this user
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Rate limited',
|
|
_auth_result: 'ratelimited',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_user: userData._id,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return rateLimitResponse(res, err => callback(err, false, userData._id));
|
|
}
|
|
|
|
if (userData.disabled) {
|
|
// disabled users can not log in
|
|
meta.result = 'disabled';
|
|
// TODO: should we send some specific error message?
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'User is disabled',
|
|
_auth_result: 'disabled',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_user: userData._id,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
return this.logAuthEvent(userData._id, meta, () => callback(null, false, userData._id));
|
|
}
|
|
|
|
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, meta.appId, done);
|
|
};
|
|
|
|
let authSuccess = (...args) => {
|
|
// clear rate limit counter on success
|
|
this.rateLimitReleaseUser(userData._id, () => false);
|
|
|
|
this.loggelf({
|
|
short_message: '[AUTHOK] ' + username,
|
|
_auth_result: 'success',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_user: userData._id,
|
|
_password_type: passwordType,
|
|
_password_id: passwordId,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
callback(...args);
|
|
};
|
|
|
|
let authFail = (...args) => {
|
|
let err = args[0] || {};
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
full_message: err.stack,
|
|
_error: err.message || 'Authentication failed',
|
|
_code: err.code,
|
|
_auth_result: 'fail',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_user: userData._id,
|
|
_password_type: passwordType,
|
|
_password_id: passwordId,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip
|
|
});
|
|
|
|
// 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 = () => {
|
|
hashes.compare(password, userData.password, (err, success) => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
|
|
if (!success) {
|
|
return next(null, success);
|
|
}
|
|
|
|
if (hashes.shouldRehash(userData.password)) {
|
|
// needs rehashing
|
|
|
|
return hashes.hash(password, (err, hash) => {
|
|
if (err) {
|
|
log.error('DB', 'HASHFAIL rehash user=%s error=%s', userData._id, err.message);
|
|
// ignore DB error, rehash some other time
|
|
return next(null, success);
|
|
}
|
|
if (!hash) {
|
|
// should this happen???
|
|
return next(null, success);
|
|
}
|
|
|
|
return this.users.collection('users').updateOne(
|
|
{
|
|
_id: userData._id
|
|
},
|
|
{
|
|
$set: {
|
|
password: hash
|
|
}
|
|
},
|
|
{ w: 'majority' },
|
|
err => {
|
|
if (err) {
|
|
log.error('DB', 'DBFAIL rehash user=%s error=%s', userData._id, err.message);
|
|
} else {
|
|
log.info('DB', 'REHASHED user=%s algo=%s', userData._id, consts.DEFAULT_HASH_ALGO);
|
|
}
|
|
|
|
// ignore DB error, rehash some other time
|
|
return next(null, success);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
next(null, success);
|
|
});
|
|
};
|
|
|
|
if (userData.tempPassword && userData.tempPassword.created > new Date(Date.now() - consts.TEMP_PASS_WINDOW)) {
|
|
// try temporary password
|
|
return hashes.compare(password, userData.tempPassword.password, (err, success) => {
|
|
if (err) {
|
|
err.code = 'HashError';
|
|
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) {
|
|
return authFail(err, false, userData._id);
|
|
}
|
|
|
|
if (success) {
|
|
meta.result = 'success';
|
|
meta.source = !usingTemporaryPassword ? 'master' : 'temporary';
|
|
|
|
if (enabled2fa.length) {
|
|
meta.require2fa = enabled2fa.length ? enabled2fa.join(',') : false;
|
|
}
|
|
|
|
let disabledScopes = userData.disabledScopes || [];
|
|
|
|
if (requiredScope !== 'master' && (enabled2fa.length || usingTemporaryPassword || disabledScopes.includes(requiredScope))) {
|
|
// 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.code = 'InvalidAuthScope';
|
|
err.response = 'NO'; // imap response code
|
|
return this.logAuthEvent(userData._id, meta, () => authFail(err, false, userData._id));
|
|
}
|
|
|
|
return this.logAuthEvent(userData._id, meta, (err, authEvent) => {
|
|
if (err) {
|
|
// should not happen
|
|
}
|
|
|
|
if (authEvent) {
|
|
this.users.collection('users').updateOne(
|
|
{
|
|
_id: userData._id
|
|
},
|
|
{
|
|
$set: {
|
|
lastLogin: {
|
|
time: new Date(),
|
|
authEvent,
|
|
ip: meta.ip
|
|
}
|
|
}
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
() => false
|
|
);
|
|
}
|
|
|
|
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, userData._id);
|
|
});
|
|
}
|
|
|
|
authSuccess(null, authResponse, userData._id);
|
|
});
|
|
}
|
|
|
|
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, userData._id));
|
|
}
|
|
|
|
// 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, userData._id));
|
|
}
|
|
|
|
let selector = getStringSelector(password);
|
|
|
|
this.users
|
|
.collection('asps')
|
|
.find({
|
|
user: userData._id
|
|
})
|
|
.maxTimeMS(consts.DB_MAX_TIME_USERS)
|
|
.toArray((err, asps) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return authFail(err, false, userData._id);
|
|
}
|
|
|
|
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, userData._id));
|
|
}
|
|
|
|
let pos = 0;
|
|
let checkNext = () => {
|
|
if (pos >= asps.length) {
|
|
meta.result = 'fail';
|
|
meta.source = 'master';
|
|
return this.logAuthEvent(userData._id, meta, () => authFail(null, false, userData._id));
|
|
}
|
|
|
|
let asp = asps[pos++];
|
|
if (asp.selector && asp.selector !== selector) {
|
|
// no need to check, definitely a wrong one
|
|
return setImmediate(checkNext);
|
|
}
|
|
|
|
hashes.compare(password, asp.password, (err, success) => {
|
|
if (err) {
|
|
err.code = 'HashError';
|
|
return authFail(err, false, userData._id);
|
|
}
|
|
|
|
if (!success) {
|
|
return setImmediate(checkNext);
|
|
}
|
|
|
|
meta.source = 'asp';
|
|
meta.asp = asp._id;
|
|
// store ASP name in case the ASP gets deleted and for faster listing
|
|
meta.aname = asp.description;
|
|
|
|
passwordType = 'asp';
|
|
passwordId = asp._id.toString();
|
|
|
|
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
|
|
meta.result = 'fail';
|
|
return this.logAuthEvent(userData._id, meta, () => {
|
|
let err = new Error('Authentication failed. Invalid scope');
|
|
err.code = 'InvalidAuthScope';
|
|
err.response = 'NO'; // imap response code
|
|
authFail(err, false, userData._id);
|
|
});
|
|
}
|
|
|
|
meta.result = 'success';
|
|
|
|
this.logAuthEvent(userData._id, meta, (err, authEvent) => {
|
|
if (err) {
|
|
// don't really care
|
|
}
|
|
let aspUpdates = {
|
|
used: new Date(),
|
|
authEvent,
|
|
authIp: meta.ip
|
|
};
|
|
|
|
if (asp.ttl) {
|
|
// extend temporary password ttl every time it is used
|
|
aspUpdates.expires = new Date(Date.now() + asp.ttl * 1000);
|
|
}
|
|
|
|
this.users.collection('asps').findOneAndUpdate(
|
|
{
|
|
_id: asp._id
|
|
},
|
|
{
|
|
$set: aspUpdates
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
() => {
|
|
authSuccess(
|
|
null,
|
|
{
|
|
user: userData._id,
|
|
username: userData.username,
|
|
scope: requiredScope,
|
|
asp: asp._id.toString(),
|
|
require2fa: false // application scope never requires 2FA
|
|
},
|
|
userData._id
|
|
);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
};
|
|
|
|
checkNext();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Authenticate user using an older password. Needed for account recovery.
|
|
* TODO: check if this is even used anywhere?
|
|
*
|
|
* @param {String} username Either username or email address
|
|
*/
|
|
authenticateUsingOldPassword(username, password, callback) {
|
|
if (!password) {
|
|
// do not allow signing in without a password
|
|
return callback(null, false);
|
|
}
|
|
|
|
this.checkAddress(username, (err, query) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (!query) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
this.users.collection('users').findOne(
|
|
query,
|
|
{
|
|
projection: {
|
|
_id: true,
|
|
username: true,
|
|
oldPasswords: true,
|
|
disabled: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!userData) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
if (userData.disabled) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
// FIXME: use IP in rlkey
|
|
let rlkey = 'outh:' + userData._id.toString();
|
|
this.counters.ttlcounter(rlkey, 0, consts.USER_AUTH_FAILURES, consts.USER_AUTH_WINDOW, (err, res) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!res.success) {
|
|
return rateLimitResponse(res, callback);
|
|
}
|
|
|
|
let authSuccess = (...args) => {
|
|
// clear rate limit counter on success
|
|
this.redis.del(rlkey, () => false);
|
|
callback(...args);
|
|
};
|
|
|
|
let authFail = (...args) => {
|
|
// increment rate limit counter on failure
|
|
this.counters.ttlcounter(rlkey, 1, consts.USER_AUTH_FAILURES, consts.USER_AUTH_WINDOW, () => {
|
|
callback(...args);
|
|
});
|
|
};
|
|
|
|
if (!userData.oldPasswords || !userData.oldPasswords.length) {
|
|
return authFail(null, false);
|
|
}
|
|
|
|
// do not check too many passwords
|
|
if (userData.oldPasswords.length > 30) {
|
|
userData.oldPasswords = userData.oldPasswords.slice(-30);
|
|
}
|
|
|
|
let curPos = 0;
|
|
let checkNext = () => {
|
|
if (curPos >= userData.oldPasswords.length) {
|
|
return authFail(null, false);
|
|
}
|
|
let oldPassword = userData.oldPasswords[curPos++];
|
|
|
|
hashes.compare(password, oldPassword.hash, (err, success) => {
|
|
if (err) {
|
|
err.code = 'HashError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!success) {
|
|
return setImmediate(checkNext);
|
|
}
|
|
|
|
return authSuccess(null, userData._id);
|
|
});
|
|
};
|
|
|
|
return setImmediate(checkNext);
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
generateU2fAuthRequest(user, keyHandle, appId, callback) {
|
|
let authRequest;
|
|
try {
|
|
authRequest = u2f.request(appId || config.u2f.appId, keyHandle);
|
|
} catch (E) {
|
|
log.error('U2F', 'U2FFAIL request id=%s error=%s', user, E.message);
|
|
}
|
|
|
|
if (!authRequest) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
this.redis
|
|
.multi()
|
|
.set('u2f:auth:' + user, JSON.stringify(authRequest))
|
|
.expire('u2f:auth:' + user, 1 * 3600)
|
|
.exec((err, results) => {
|
|
if ((!err && !results) || !results[0]) {
|
|
err = new Error('Invalid DB response');
|
|
} else if (!err && results && results[0] && results[0][0]) {
|
|
err = results[0][0];
|
|
}
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
callback(null, authRequest);
|
|
});
|
|
}
|
|
|
|
generateASP(user, data, callback) {
|
|
let password = generatePassword.generate({
|
|
length: 16,
|
|
uppercase: false,
|
|
numbers: false,
|
|
symbols: false
|
|
});
|
|
// We need a quick hash key that can be used to identify the password.
|
|
// Otherwise, when authenticating, we'd need to check the password against all stored bcrypt
|
|
// hashes which would make forever if the user has a longer list of application specific passwords
|
|
let selector = getStringSelector(password);
|
|
|
|
let allowedScopes = [...consts.SCOPES];
|
|
let hasAllScopes = false;
|
|
let scopeSet = new Set();
|
|
let scopes = [].concat(data.scopes || []);
|
|
|
|
scopes.forEach(scope => {
|
|
scope = scope.toLowerCase().trim();
|
|
if (scope === '*') {
|
|
hasAllScopes = true;
|
|
} else if (allowedScopes.includes(scope)) {
|
|
scopeSet.add(scope);
|
|
}
|
|
});
|
|
|
|
if (hasAllScopes || scopeSet.size === allowedScopes.length) {
|
|
scopes = ['*'];
|
|
} else {
|
|
scopes = Array.from(scopeSet).sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
hashes.hash(password, (err, hash) => {
|
|
if (err) {
|
|
log.error('DB', 'HASHFAIL generateASP id=%s error=%s', user, err.message);
|
|
err.code = 'HashError';
|
|
return callback(err);
|
|
}
|
|
|
|
let passwordData = {
|
|
_id: new ObjectID(),
|
|
user,
|
|
description: data.description,
|
|
scopes,
|
|
password: hash,
|
|
selector,
|
|
used: false,
|
|
authEvent: false,
|
|
authIp: false,
|
|
created: new Date()
|
|
};
|
|
|
|
if (data.ttl) {
|
|
passwordData.ttl = data.ttl;
|
|
passwordData.expires = new Date(Date.now() + data.ttl * 1000);
|
|
}
|
|
|
|
// register this address as the default address for that user
|
|
return this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
_id: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'DBFAIL generateASP id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!userData) {
|
|
return callback(new Error('User not found'));
|
|
}
|
|
|
|
this.users.collection('asps').insertOne(passwordData, err => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'create asp',
|
|
asp: passwordData._id,
|
|
aname: passwordData.description,
|
|
temporary: passwordData.ttl ? true : false,
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() =>
|
|
callback(null, {
|
|
id: passwordData._id,
|
|
password
|
|
})
|
|
);
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
deleteASP(user, asp, data, callback) {
|
|
return this.users.collection('asps').findOne(
|
|
{
|
|
_id: asp,
|
|
user
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, asp) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!asp) {
|
|
let err = new Error('Application Specific Password was not found');
|
|
err.code = 'AspNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
this.users.collection('asps').deleteOne({ _id: asp._id }, (err, r) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (r.deletedCount) {
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'delete asp',
|
|
asp: asp._id,
|
|
aname: asp.description,
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
} else {
|
|
return callback(null, true);
|
|
}
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
create(data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
username: data.username.replace(/\./g, '')
|
|
},
|
|
{
|
|
projection: {
|
|
unameview: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (userData) {
|
|
let err = new Error('This username already exists');
|
|
err.code = 'UserExistsError';
|
|
return callback(err);
|
|
}
|
|
|
|
let address = data.address ? data.address : false;
|
|
|
|
if (!address) {
|
|
try {
|
|
if (isemail.validate(data.username)) {
|
|
address = data.username;
|
|
}
|
|
} catch (E) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!address) {
|
|
address = data.username.split('@').shift() + '@' + (config.emailDomain || os.hostname()).toLowerCase();
|
|
}
|
|
|
|
address = tools.normalizeAddress(address, false, { removeLabel: true });
|
|
let addrview = tools.uview(address);
|
|
|
|
let allowedScopes = [...consts.SCOPES];
|
|
let scopeSet = new Set();
|
|
let disabledScopes = [].concat(data.disabledScopes || []);
|
|
disabledScopes.forEach(scope => {
|
|
scope = scope.toLowerCase().trim();
|
|
if (allowedScopes.includes(scope)) {
|
|
scopeSet.add(scope);
|
|
}
|
|
});
|
|
disabledScopes = Array.from(scopeSet).sort((a, b) => a.localeCompare(b));
|
|
|
|
let checkAddress = done => {
|
|
if (data.emptyAddress) {
|
|
return done();
|
|
}
|
|
|
|
this.users.collection('addresses').findOne(
|
|
{
|
|
addrview
|
|
},
|
|
{
|
|
projection: {
|
|
_id: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, addressData) => {
|
|
if (err) {
|
|
log.error('DB', 'CREATEFAIL username=%s address=%s error=%s', data.username, address, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (addressData) {
|
|
let err = new Error('This address already exists');
|
|
err.code = 'AddressExistsError';
|
|
return callback(err);
|
|
}
|
|
|
|
done();
|
|
}
|
|
);
|
|
};
|
|
|
|
checkAddress(() => {
|
|
let junkRetention = consts.JUNK_RETENTION;
|
|
|
|
// Insert user data
|
|
|
|
let hashPassword = done => {
|
|
if (!data.password) {
|
|
// Users with an empty password can not log in
|
|
return done(null, '');
|
|
}
|
|
|
|
if (data.hashedPassword) {
|
|
// try if the bcrypt library can handle it?
|
|
return hashes.compare('whatever', data.password, err => {
|
|
if (err) {
|
|
return done(err);
|
|
}
|
|
// did not throw, so probably OK
|
|
return done(null, data.password);
|
|
});
|
|
}
|
|
|
|
hashes.hash(data.password, done);
|
|
};
|
|
|
|
hashPassword((err, hash) => {
|
|
if (err) {
|
|
log.error('DB', 'HASHFAIL user.create id=%s error=%s', data.username, err.message);
|
|
err.code = 'HashError';
|
|
return callback(err);
|
|
}
|
|
|
|
let id = new ObjectID();
|
|
|
|
// spamLevel is from 0 (everything is spam) to 100 (accept everything)
|
|
let spamLevel = 'spamLevel' in data && !isNaN(data.spamLevel) ? Number(data.spamLevel) : 50;
|
|
if (spamLevel < 0) {
|
|
spamLevel = 0;
|
|
}
|
|
if (spamLevel > 100) {
|
|
spamLevel = 100;
|
|
}
|
|
|
|
userData = {
|
|
_id: id,
|
|
|
|
username: data.username,
|
|
// dotless version
|
|
unameview: tools.uview(data.username),
|
|
|
|
name: data.name,
|
|
|
|
// security
|
|
password: '', // set this later. having no password prevents login
|
|
|
|
enabled2fa: [],
|
|
seed: '', // 2fa seed value
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false,
|
|
|
|
// default email address
|
|
address: '', // set this later
|
|
|
|
language: data.language,
|
|
|
|
// quota
|
|
storageUsed: 0,
|
|
quota: data.quota || 0,
|
|
|
|
recipients: data.recipients || 0,
|
|
forwards: data.forwards || 0,
|
|
|
|
imapMaxUpload: data.imapMaxUpload || 0,
|
|
imapMaxDownload: data.imapMaxDownload || 0,
|
|
pop3MaxDownload: data.pop3MaxDownload || 0,
|
|
receivedMax: data.receivedMax || 0,
|
|
|
|
targets: [].concat(data.targets || []),
|
|
|
|
// autoreply status
|
|
// off by default, can be changed later by user through the API
|
|
autoreply: false,
|
|
|
|
uploadSentMessages: !!data.uploadSentMessages,
|
|
pubKey: data.pubKey || '',
|
|
encryptMessages: !!data.encryptMessages,
|
|
encryptForwarded: !!data.encryptForwarded,
|
|
|
|
spamLevel,
|
|
|
|
// default retention for user mailboxes
|
|
retention: data.retention || 0,
|
|
|
|
disabledScopes,
|
|
|
|
lastLogin: {
|
|
time: false,
|
|
authEvent: false,
|
|
ip: false
|
|
},
|
|
|
|
metaData: data.metaData || '',
|
|
|
|
// until setup value is not true, this account is not usable
|
|
activated: false,
|
|
disabled: true,
|
|
|
|
created: new Date()
|
|
};
|
|
|
|
if (data.tags && data.tags.length) {
|
|
userData.tags = data.tags;
|
|
userData.tagsview = data.tagsview;
|
|
}
|
|
|
|
this.users.collection('users').insertOne(userData, { w: 'majority' }, err => {
|
|
if (err) {
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
|
|
let response;
|
|
switch (err.code) {
|
|
case 11000:
|
|
response = 'Selected user already exists';
|
|
err.code = 'UserExistsError';
|
|
break;
|
|
default:
|
|
response = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
}
|
|
|
|
err.message = response;
|
|
return callback(err);
|
|
}
|
|
|
|
let mailboxes = this.getMailboxes(data.language, data.mailboxes).map(mailbox => {
|
|
mailbox.user = id;
|
|
|
|
if (['\\Trash', '\\Junk'].includes(mailbox.specialUse)) {
|
|
mailbox.retention = data.retention ? Math.min(data.retention, junkRetention) : junkRetention;
|
|
} else {
|
|
mailbox.retention = data.retention;
|
|
}
|
|
|
|
return mailbox;
|
|
});
|
|
|
|
this.database.collection('mailboxes').insertMany(
|
|
mailboxes,
|
|
{
|
|
w: 'majority',
|
|
ordered: false
|
|
},
|
|
err => {
|
|
if (err) {
|
|
// try to rollback
|
|
this.users.collection('users').deleteOne({ _id: id }, () => false);
|
|
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
let ensureAddress = done => {
|
|
if (data.emptyAddress) {
|
|
return done(null, '');
|
|
}
|
|
|
|
let addressData = {
|
|
user: id,
|
|
address,
|
|
// dotless version
|
|
addrview,
|
|
created: new Date()
|
|
};
|
|
|
|
if (data.tags && data.tags.length && data.addTagsToAddress) {
|
|
addressData.tags = data.tags;
|
|
addressData.tagsview = data.tagsview;
|
|
}
|
|
|
|
// insert alias address to email address registry
|
|
this.users.collection('addresses').insertOne(addressData, { w: 'majority' }, err => {
|
|
if (err) {
|
|
// try to rollback
|
|
this.users.collection('users').deleteOne({ _id: id }, () => false);
|
|
this.database.collection('mailboxes').deleteMany({ user: id }, () => false);
|
|
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
|
|
let response;
|
|
switch (err.code) {
|
|
case 11000:
|
|
response = 'Selected email address already exists';
|
|
err.code = 'AddressExistsError';
|
|
break;
|
|
default:
|
|
response = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
}
|
|
|
|
err.message = response;
|
|
return done(err);
|
|
}
|
|
|
|
done(null, address);
|
|
});
|
|
};
|
|
|
|
ensureAddress((err, address) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
let updates = {
|
|
address,
|
|
activated: true,
|
|
disabled: false
|
|
};
|
|
|
|
if (data.requirePasswordChange) {
|
|
updates.tempPassword = {
|
|
validAfter: new Date(),
|
|
password: hash,
|
|
created: new Date()
|
|
};
|
|
} else {
|
|
updates.password = hash;
|
|
}
|
|
|
|
// register this address as the default address for that user
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: id,
|
|
activated: false
|
|
},
|
|
{
|
|
$set: updates
|
|
},
|
|
{
|
|
returnOriginal: false,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, result) => {
|
|
if (err) {
|
|
// try to rollback
|
|
this.users.collection('users').deleteOne({ _id: id }, () => false);
|
|
this.database.collection('mailboxes').deleteMany({ user: id }, () => false);
|
|
this.users.collection('addresses').deleteOne({ user: id }, () => false);
|
|
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
let userData = result.value;
|
|
|
|
if (!userData) {
|
|
// should never happen
|
|
return callback(null, id);
|
|
}
|
|
|
|
let createSuccess = () =>
|
|
this.logAuthEvent(
|
|
id,
|
|
{
|
|
action: 'account created',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, id)
|
|
);
|
|
|
|
if (!this.messageHandler || data.emptyAddress) {
|
|
return createSuccess();
|
|
}
|
|
|
|
let parsedName = humanname.parse(userData.name || '');
|
|
this.pushDefaultMessages(
|
|
userData,
|
|
{
|
|
NAME: userData.name || userData.username || address,
|
|
FNAME: parsedName.firstName,
|
|
LNAME: parsedName.lastName,
|
|
DOMAIN: address.substr(address.indexOf('@') + 1),
|
|
EMAIL: address
|
|
},
|
|
() => createSuccess()
|
|
);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
pushDefaultMessages(userData, tags, callback) {
|
|
tools.getEmailTemplates(tags, (err, messages) => {
|
|
if (err || !messages || !messages.length) {
|
|
return callback();
|
|
}
|
|
|
|
let pos = 0;
|
|
let insertMessages = () => {
|
|
if (pos >= messages.length) {
|
|
return callback();
|
|
}
|
|
let data = messages[pos++];
|
|
let compiler = new MailComposer(data);
|
|
|
|
compiler.compile().build((err, message) => {
|
|
if (err) {
|
|
return insertMessages();
|
|
}
|
|
|
|
let mailboxQueryKey = 'path';
|
|
let mailboxQueryValue = 'INBOX';
|
|
|
|
if (['sent', 'trash', 'junk', 'drafts', 'archive'].includes((data.mailbox || '').toString().toLowerCase())) {
|
|
mailboxQueryKey = 'specialUse';
|
|
mailboxQueryValue = '\\' + data.mailbox.toLowerCase().replace(/^./g, c => c.toUpperCase());
|
|
}
|
|
|
|
let flags = [];
|
|
if (data.seen) {
|
|
flags.push('\\Seen');
|
|
}
|
|
if (data.flag) {
|
|
flags.push('\\Flagged');
|
|
}
|
|
|
|
this.messageHandler.encryptMessage(userData.encryptMessages ? userData.pubKey : false, message, (err, encrypted) => {
|
|
if (!err && encrypted) {
|
|
message = encrypted;
|
|
}
|
|
|
|
this.messageHandler.add(
|
|
{
|
|
user: userData._id,
|
|
[mailboxQueryKey]: mailboxQueryValue,
|
|
meta: {
|
|
source: 'AUTO',
|
|
time: new Date()
|
|
},
|
|
flags,
|
|
raw: message
|
|
},
|
|
insertMessages
|
|
);
|
|
});
|
|
});
|
|
};
|
|
insertMessages();
|
|
});
|
|
}
|
|
|
|
reset(user, data, callback) {
|
|
let password = generatePassword.generate({
|
|
length: 12,
|
|
uppercase: true,
|
|
numbers: true,
|
|
symbols: false
|
|
});
|
|
|
|
hashes.hash(password, (err, hash) => {
|
|
if (err) {
|
|
log.error('DB', 'HASHFAIL user.reset id=%s error=%s', user, err.message);
|
|
err.code = 'HashError';
|
|
return callback(err);
|
|
}
|
|
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
$set: {
|
|
tempPassword: {
|
|
validAfter: data.validAfter || new Date(),
|
|
password: hash,
|
|
created: new Date()
|
|
}
|
|
}
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, result) => {
|
|
if (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to reset user credentials';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!result || !result.value) {
|
|
return callback(new Error('Could not update user ' + user));
|
|
}
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'reset',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, password)
|
|
);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
setupTotp(user, data, callback) {
|
|
return this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
username: true,
|
|
enabled2fa: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to check user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('Could not find user data');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (enabled2fa.includes('totp')) {
|
|
let err = new Error('TOTP 2FA is already enabled for this user');
|
|
err.code = 'TotpEnabled';
|
|
return callback(err);
|
|
}
|
|
|
|
let secret = speakeasy.generateSecret({
|
|
length: 20,
|
|
name: userData.username
|
|
});
|
|
|
|
let seed = secret.base32;
|
|
if (config.totp && config.totp.secret) {
|
|
try {
|
|
let cipher = crypto.createCipher(config.totp.cipher || 'aes192', config.totp.secret);
|
|
seed = '$' + cipher.update(seed, 'utf8', 'hex');
|
|
seed += cipher.final('hex');
|
|
} catch (E) {
|
|
log.error('DB', 'TOTPFAIL cipher failed id=%s error=%s', user, E.message);
|
|
let err = new Error('Database Error, failed to update user');
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
}
|
|
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user,
|
|
enabled2fa: { $not: { $eq: 'totp' } }
|
|
},
|
|
{
|
|
$set: {
|
|
pendingSeed: seed,
|
|
pendingSeedChanged: new Date()
|
|
}
|
|
},
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS },
|
|
(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) {
|
|
return callback(new Error('Could not update user, check if 2FA is not already enabled'));
|
|
}
|
|
|
|
let otpauth_url = speakeasy.otpauthURL({
|
|
secret: secret.ascii,
|
|
label: data.label || userData.username,
|
|
issuer: data.issuer || 'WildDuck'
|
|
});
|
|
|
|
QRCode.toDataURL(otpauth_url, (err, dataUrl) => {
|
|
if (err) {
|
|
log.error('DB', 'QRFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Failed to generate QR code';
|
|
err.code = 'QRError';
|
|
return callback(err);
|
|
}
|
|
|
|
callback(null, {
|
|
secret: secret.base32,
|
|
dataUrl
|
|
});
|
|
});
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
enableTotp(user, data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
pendingSeed: true,
|
|
pendingSeedChanged: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (enabled2fa.includes('totp')) {
|
|
// 2fa not set up
|
|
let err = new Error('TOTP 2FA is already enabled for this user');
|
|
err.code = 'TotpEnabled';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!userData.pendingSeed || (userData.pendingSeedChanged && userData.pendingSeedChanged < new Date(Date.now() - TOTP_SETUP_TTL))) {
|
|
// 2fa not set up
|
|
let err = new Error('TOTP 2FA is not initialized for this user');
|
|
err.code = 'TotpDisabled';
|
|
return callback(err);
|
|
}
|
|
|
|
let secret = userData.pendingSeed;
|
|
if (secret.charAt(0) === '$' && config.totp && config.totp.secret) {
|
|
try {
|
|
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
|
secret = decipher.update(secret.substr(1), 'hex', 'utf-8');
|
|
secret += decipher.final('utf8');
|
|
} catch (E) {
|
|
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
|
let err = new Error('Can not use decrypted secret');
|
|
err.code = 'InternalConfigError';
|
|
return callback(err);
|
|
}
|
|
}
|
|
|
|
let verified = speakeasy.totp.verify({
|
|
secret,
|
|
encoding: 'base32',
|
|
token: data.token,
|
|
window: 6
|
|
});
|
|
|
|
if (!verified) {
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'enable 2fa totp',
|
|
result: 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, false)
|
|
);
|
|
}
|
|
|
|
let update =
|
|
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
|
|
? {
|
|
$set: {
|
|
seed: userData.pendingSeed,
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false,
|
|
enabled2fa: ['totp']
|
|
}
|
|
}
|
|
: {
|
|
$set: {
|
|
seed: userData.pendingSeed,
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false
|
|
},
|
|
$addToSet: {
|
|
enabled2fa: 'totp'
|
|
}
|
|
};
|
|
|
|
// token was valid, update user settings
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user,
|
|
pendingSeed: userData.pendingSeed
|
|
},
|
|
update,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS },
|
|
(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) {
|
|
err = new Error('Failed to set up 2FA. Check if it is not already enabled');
|
|
err.code = 'TotpEnabled';
|
|
return callback(err);
|
|
}
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'enable 2fa totp',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
disableTotp(user, data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (!enabled2fa.includes('totp')) {
|
|
let err = new Error('Could not update user, check if 2FA TOTP is not already disabled');
|
|
err.code = 'TotpDisabled';
|
|
return callback(err);
|
|
}
|
|
|
|
let update =
|
|
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
|
|
? {
|
|
$set: {
|
|
enabled2fa: [],
|
|
seed: '',
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false
|
|
}
|
|
}
|
|
: {
|
|
$pull: {
|
|
enabled2fa: 'totp'
|
|
},
|
|
$set: {
|
|
seed: '',
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false
|
|
}
|
|
};
|
|
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
update,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS },
|
|
(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 totp',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
checkTotp(user, data, callback) {
|
|
let userRlKey = 'totp:' + user;
|
|
this.rateLimit(userRlKey, data, 0, (err, res) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!res.success) {
|
|
return rateLimitResponse(res, callback);
|
|
}
|
|
|
|
let authSuccess = (...args) => {
|
|
// clear rate limit counter on success
|
|
this.rateLimitReleaseUser(userRlKey, () => false);
|
|
callback(...args);
|
|
};
|
|
|
|
let authFail = (...args) => {
|
|
// increment rate limit counter on failure
|
|
this.rateLimit(userRlKey, data, 1, () => {
|
|
callback(...args);
|
|
});
|
|
};
|
|
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
username: true,
|
|
enabled2fa: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!userData) {
|
|
let err = new Error('This user does not exist');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (!userData.seed || !enabled2fa.includes('totp')) {
|
|
// 2fa not set up
|
|
let err = new Error('2FA TOTP is not enabled for this user');
|
|
err.code = 'TotpDisabled';
|
|
return callback(err);
|
|
}
|
|
|
|
let secret = userData.seed;
|
|
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
|
try {
|
|
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
|
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
|
secret += decipher.final('utf8');
|
|
} catch (E) {
|
|
log.error('DB', 'TOTPFAIL decipher failed id=%s error=%s', user, E.message);
|
|
let err = new Error('Can not use decrypted secret');
|
|
err.code = 'InternalConfigError';
|
|
return callback(err);
|
|
}
|
|
}
|
|
|
|
let verified = speakeasy.totp.verify({
|
|
secret,
|
|
encoding: 'base32',
|
|
token: data.token,
|
|
window: 6
|
|
});
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'check 2fa totp',
|
|
result: verified ? 'success' : 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => {
|
|
if (verified) {
|
|
authSuccess(null, verified);
|
|
} else {
|
|
authFail(null, verified);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
enableCustom2fa(user, data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS },
|
|
(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
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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,
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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 {
|
|
registrationRequest = u2f.request(data.appId || config.u2f.appId);
|
|
} catch (E) {
|
|
log.error('U2F', 'U2FFAIL request id=%s error=%s', user, E.message);
|
|
}
|
|
|
|
if (!registrationRequest) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
username: true,
|
|
enabled2fa: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to check user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!userData) {
|
|
err = new Error('Could not find user data');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (enabled2fa.includes('u2f')) {
|
|
return callback(new Error('U2F 2FA is already enabled for this user'));
|
|
}
|
|
|
|
// store registration request to Redis
|
|
this.redis
|
|
.multi()
|
|
.set('u2f:req:' + user, JSON.stringify(registrationRequest))
|
|
.expire('u2f:req:' + user, 1 * 3600)
|
|
.exec((err, results) => {
|
|
if ((!err && !results) || !results[0]) {
|
|
err = new Error('Invalid DB response');
|
|
} else if (!err && results && results[0] && results[0][0]) {
|
|
err = results[0][0];
|
|
}
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
callback(null, registrationRequest);
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
enableU2f(user, data, callback) {
|
|
this.redis
|
|
.multi()
|
|
.get('u2f:req:' + user)
|
|
.del('u2f:req:' + user)
|
|
.exec((err, results) => {
|
|
if ((!err && !results) || !results[0]) {
|
|
err = new Error('Invalid DB response');
|
|
} else if (!err && results && results[0] && results[0][0]) {
|
|
err = results[0][0];
|
|
}
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
let registrationRequest = results[0][1];
|
|
|
|
if (!registrationRequest) {
|
|
let err = new Error('U2F 2FA is not initialized for this user');
|
|
err.code = 'U2fDisabled';
|
|
return callback(err);
|
|
}
|
|
try {
|
|
registrationRequest = JSON.parse(registrationRequest);
|
|
} catch (E) {
|
|
return callback(new Error('Invalid 2FA data stored'));
|
|
}
|
|
|
|
let registrationResponse = {};
|
|
Object.keys(data || {}).forEach(key => {
|
|
if (['clientData', 'registrationData', 'version', 'challenge'].includes(key)) {
|
|
registrationResponse[key] = data[key];
|
|
}
|
|
});
|
|
|
|
let result;
|
|
try {
|
|
result = u2f.checkRegistration(registrationRequest, registrationResponse);
|
|
} catch (E) {
|
|
log.error('U2F', 'U2FFAIL checkRegistration id=%s error=%s', user, E.message);
|
|
}
|
|
|
|
if (!result || !result.successful) {
|
|
return callback(new Error((result && result.errorMessage) || 'Failed to validate U2F response'));
|
|
}
|
|
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (enabled2fa.includes('u2f')) {
|
|
// 2fa not set up
|
|
let err = new Error('U2F 2FA is already enabled for this user');
|
|
err.code = 'U2fEnabled';
|
|
return callback(err);
|
|
}
|
|
|
|
let curDate = new Date();
|
|
let update =
|
|
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
|
|
? {
|
|
$set: {
|
|
enabled2fa: ['u2f'],
|
|
u2f: {
|
|
keyHandle: result.keyHandle,
|
|
pubKey: result.publicKey,
|
|
cert: result.certificate,
|
|
date: curDate
|
|
}
|
|
}
|
|
}
|
|
: {
|
|
$addToSet: {
|
|
enabled2fa: 'u2f'
|
|
},
|
|
$set: {
|
|
u2f: {
|
|
keyHandle: result.keyHandle,
|
|
pubKey: result.publicKey,
|
|
cert: result.certificate,
|
|
date: curDate
|
|
}
|
|
}
|
|
};
|
|
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
update,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS },
|
|
(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) {
|
|
err = new Error('Failed to set up 2FA. User not found');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'enable 2fa u2f',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
disableU2f(user, data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (!enabled2fa.includes('u2f')) {
|
|
return callback(new Error('Could not update user, check if U2F 2FA is not already disabled'));
|
|
}
|
|
|
|
let curDate = new Date();
|
|
let update =
|
|
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
|
|
? {
|
|
$set: {
|
|
enabled2fa: [],
|
|
u2f: {
|
|
keyHandle: '',
|
|
pubKey: '',
|
|
cert: '',
|
|
date: curDate
|
|
}
|
|
}
|
|
}
|
|
: {
|
|
$pull: {
|
|
enabled2fa: 'u2f'
|
|
},
|
|
$set: {
|
|
u2f: {
|
|
keyHandle: '',
|
|
pubKey: '',
|
|
cert: '',
|
|
date: curDate
|
|
}
|
|
}
|
|
};
|
|
|
|
return this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
update,
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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) {
|
|
err = new Error('Could not update user, check if 2FA is not already disabled');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'disable 2fa u2f',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
startU2f(user, data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!userData) {
|
|
let err = new Error('This user does not exist');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (!enabled2fa.includes('u2f') || !userData.u2f || !userData.u2f.keyHandle) {
|
|
// 2fa not set up
|
|
let err = new Error('2FA U2F is not enabled for this user');
|
|
err.code = 'U2fDisabled';
|
|
return callback(err);
|
|
}
|
|
|
|
this.generateU2fAuthRequest(user, userData.u2f.keyHandle, data.appId, (err, authRequest) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
if (!authRequest) {
|
|
return callback(null, false);
|
|
}
|
|
callback(null, authRequest);
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
checkU2f(user, data, callback) {
|
|
this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!userData) {
|
|
let err = new Error('This user does not exist');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (!enabled2fa.includes('u2f') || !userData.u2f || !userData.u2f.keyHandle) {
|
|
// 2fa not set up
|
|
let err = new Error('2FA U2F is not enabled for this user');
|
|
err.code = 'U2fDisabled';
|
|
return callback(err);
|
|
}
|
|
|
|
this.redis
|
|
.multi()
|
|
.get('u2f:auth:' + user)
|
|
.del('u2f:auth:' + user)
|
|
.exec((err, results) => {
|
|
if ((!err && !results) || !results[0]) {
|
|
err = new Error('Invalid DB response');
|
|
} else if (!err && results && results[0] && results[0][0]) {
|
|
err = results[0][0];
|
|
}
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
let authRequest = results[0][1];
|
|
|
|
if (!authRequest) {
|
|
return callback(null, false);
|
|
}
|
|
try {
|
|
authRequest = JSON.parse(authRequest);
|
|
} catch (E) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
let authResponse = {};
|
|
Object.keys(data || {}).forEach(key => {
|
|
if (['clientData', 'signatureData'].includes(key)) {
|
|
authResponse[key] = data[key];
|
|
}
|
|
});
|
|
|
|
let result;
|
|
try {
|
|
result = u2f.checkSignature(authRequest, authResponse, userData.u2f.pubKey);
|
|
} catch (E) {
|
|
// ignore
|
|
log.error('U2F', 'U2FFAIL checkSignature id=%s error=%s', user, E.message);
|
|
}
|
|
|
|
let verified = result && result.successful;
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'check 2fa u2f',
|
|
result: verified ? 'success' : 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => {
|
|
callback(null, verified);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
disable2fa(user, data, callback) {
|
|
this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
$set: {
|
|
enabled2fa: [],
|
|
seed: '',
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false,
|
|
u2f: {
|
|
keyHandle: '',
|
|
pubKey: '',
|
|
cert: '',
|
|
date: new Date()
|
|
}
|
|
}
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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('Could not update user, check if 2FA is not already disabled');
|
|
err.code = 'U2fDisabled';
|
|
return callback(err);
|
|
}
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'disable 2fa',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
update(user, data, callback) {
|
|
let $set = {};
|
|
let updates = false;
|
|
let passwordChanged = false;
|
|
|
|
// if some of the counter keys are modified, then reset the according value in Redis
|
|
let resetKeys = new Map([
|
|
['recipients', 'wdr'],
|
|
['forwards', 'wdf'],
|
|
['imapMaxUpload', 'iup'],
|
|
['imapMaxDownload', 'idw'],
|
|
['pop3MaxDownload', 'pdw'],
|
|
['receivedMax', 'rl:rcpt']
|
|
]);
|
|
let flushKeys = [];
|
|
|
|
Object.keys(data).forEach(key => {
|
|
if (['user', 'existingPassword', 'hashedPassword', 'allowUnsafe', 'ip', 'sess'].includes(key)) {
|
|
return;
|
|
}
|
|
|
|
if (resetKeys.has(key)) {
|
|
flushKeys.push(resetKeys.get(key) + ':' + user);
|
|
}
|
|
|
|
if (key === 'password') {
|
|
if (!data[key]) {
|
|
// removes current password (if set)
|
|
$set.password = '';
|
|
} else {
|
|
$set.password = data[key]; // hashed below
|
|
}
|
|
|
|
$set.tempPassword = false;
|
|
$set.passwordChange = new Date();
|
|
passwordChanged = true;
|
|
return;
|
|
}
|
|
|
|
if (key === 'disable2fa') {
|
|
if (data.disable2fa) {
|
|
$set.enabled2fa = [];
|
|
$set.seed = '';
|
|
$set.pendingSeed = '';
|
|
$set.pendingSeedChanged = false;
|
|
$set.u2f = {
|
|
keyHandle: '',
|
|
pubKey: '',
|
|
cert: '',
|
|
date: new Date()
|
|
};
|
|
}
|
|
updates = true;
|
|
return;
|
|
}
|
|
|
|
if (key === 'spamLevel') {
|
|
// spamLevel is from 0 (everything is spam) to 100 (accept everything)
|
|
let spamLevel = !isNaN(data.spamLevel) ? Number(data.spamLevel) : 50;
|
|
if (spamLevel < 0) {
|
|
spamLevel = 0;
|
|
}
|
|
if (spamLevel > 100) {
|
|
spamLevel = 100;
|
|
}
|
|
$set.spamLevel = data.spamLevel;
|
|
updates = true;
|
|
return;
|
|
}
|
|
|
|
if (key === 'disabledScopes') {
|
|
let allowedScopes = [...consts.SCOPES];
|
|
let scopeSet = new Set();
|
|
let disabledScopes = [].concat(data.disabledScopes || []);
|
|
disabledScopes.forEach(scope => {
|
|
scope = scope.toLowerCase().trim();
|
|
if (allowedScopes.includes(scope)) {
|
|
scopeSet.add(scope);
|
|
}
|
|
});
|
|
$set.disabledScopes = Array.from(scopeSet).sort((a, b) => a.localeCompare(b));
|
|
updates = true;
|
|
return;
|
|
}
|
|
|
|
$set[key] = data[key];
|
|
updates = true;
|
|
});
|
|
|
|
if ($set.username) {
|
|
$set.unameview = tools.uview($set.username);
|
|
}
|
|
|
|
if (!updates && !passwordChanged) {
|
|
return callback(new Error('Nothing was updated'));
|
|
}
|
|
|
|
let hashPassword = done => {
|
|
if (!$set.password) {
|
|
return done();
|
|
}
|
|
|
|
if (data.hashedPassword) {
|
|
// try if the bcrypt library can handle it?
|
|
return hashes.compare('whatever', $set.password, err => {
|
|
if (err) {
|
|
return done(err);
|
|
}
|
|
// did not throw, so probably OK, no need to update `$set.password`
|
|
return done();
|
|
});
|
|
}
|
|
|
|
hashes.hash($set.password, (err, hash) => {
|
|
if (err) {
|
|
return done(err);
|
|
}
|
|
$set.password = hash;
|
|
done();
|
|
});
|
|
};
|
|
|
|
hashPassword(err => {
|
|
if (err) {
|
|
log.error('DB', 'HASHFAIL user.update id=%s error=%s', data.username, err.message);
|
|
err.code = 'HashError';
|
|
return callback(err);
|
|
}
|
|
|
|
let verifyExistingPassword = next => {
|
|
this.users.collection('users').findOne(
|
|
{ _id: user },
|
|
{
|
|
projection: {
|
|
password: true,
|
|
oldPasswords: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!userData) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, 'User was not found');
|
|
err = new Error('User was not found');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
// push current password to old passwords list on password change (and not temporary password)
|
|
if ($set.password && userData && userData.password) {
|
|
let oldPasswords = [].concat(userData.oldPasswords || []);
|
|
oldPasswords.push({
|
|
date: new Date(),
|
|
hash: userData.password
|
|
});
|
|
$set.oldPasswords = oldPasswords;
|
|
}
|
|
|
|
if (!data.existingPassword) {
|
|
return next();
|
|
}
|
|
|
|
if (!userData.password) {
|
|
return next();
|
|
}
|
|
|
|
hashes.compare(data.existingPassword, userData.password, (err, success) => {
|
|
if (err) {
|
|
log.error('DB', 'HASHFAIL user.update id=%s error=%s', data.username, err.message);
|
|
err.code = err.code || 'HashError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (success) {
|
|
return next();
|
|
}
|
|
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'password change',
|
|
result: 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(new Error('Password verification failed'))
|
|
);
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
verifyExistingPassword(() => {
|
|
this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
$set
|
|
},
|
|
{
|
|
returnOriginal: false,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(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) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, 'User was not found');
|
|
err = new Error('user was not found');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
// check if we need to reset any ttl counters
|
|
if (flushKeys.length) {
|
|
let flushreq = this.redis.multi();
|
|
flushKeys.forEach(key => {
|
|
flushreq = flushreq.del(key);
|
|
});
|
|
// just call the operations and hope for the best, no problems if fails
|
|
flushreq.exec(() => false);
|
|
}
|
|
|
|
this.userCache.flush(user, () => false);
|
|
|
|
if (passwordChanged) {
|
|
return this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'password change',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
},
|
|
() => callback(null, true)
|
|
);
|
|
} else {
|
|
return callback(null, true);
|
|
}
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
getMailboxes(language, defaults) {
|
|
defaults = defaults || {};
|
|
|
|
let lcode = (language || '')
|
|
.toLowerCase()
|
|
.split('_')
|
|
.shift();
|
|
|
|
let translation = lcode && mailboxTranslations.hasOwnProperty(lcode) ? mailboxTranslations[lcode] : mailboxTranslations.en;
|
|
|
|
let defaultMailboxes = [
|
|
{
|
|
path: 'INBOX'
|
|
},
|
|
{
|
|
specialUse: '\\Sent'
|
|
},
|
|
{
|
|
specialUse: '\\Trash'
|
|
},
|
|
{
|
|
specialUse: '\\Drafts'
|
|
},
|
|
{
|
|
specialUse: '\\Junk'
|
|
}
|
|
];
|
|
|
|
let uidValidity = Math.floor(Date.now() / 1000);
|
|
|
|
return defaultMailboxes.map(mailbox => ({
|
|
path: mailbox.path === 'INBOX' ? 'INBOX' : defaults[mailbox.specialUse] || translation[mailbox.specialUse || mailbox.path] || mailbox.path,
|
|
specialUse: mailbox.specialUse,
|
|
uidValidity,
|
|
uidNext: 1,
|
|
modifyIndex: 0,
|
|
subscribed: true,
|
|
flags: []
|
|
}));
|
|
}
|
|
|
|
logAuthEvent(user, entry, callback) {
|
|
// only log auth events if we have a valid user id and logging is not disabled
|
|
if (!user || !tools.isId(user) || this.authlogExpireDays === false) {
|
|
return callback();
|
|
}
|
|
|
|
let now = new Date();
|
|
|
|
entry.user = typeof user === 'string' ? new ObjectID(user) : user;
|
|
entry.action = entry.action || 'authentication';
|
|
entry.created = now;
|
|
|
|
if (typeof this.authlogExpireDays === 'number' && this.authlogExpireDays !== 0) {
|
|
// this entry expires in set days
|
|
entry.expires = new Date(Date.now() + Math.abs(this.authlogExpireDays) * 24 * 3600 * 1000);
|
|
}
|
|
|
|
// key is for merging similar events
|
|
entry.key = crypto
|
|
.createHash('md5')
|
|
.update([entry.protocol, entry.ip, entry.action, entry.result].map(v => (v || '').toString()).join('^'))
|
|
.digest();
|
|
|
|
return this.users.collection('authlog').findOneAndUpdate(
|
|
{
|
|
user: entry.user,
|
|
created: {
|
|
// merge similar events into buckets of time
|
|
$gte: new Date(Date.now() - consts.AUTHLOG_BUCKET)
|
|
},
|
|
// events are merged based on this key
|
|
key: entry.key
|
|
},
|
|
{
|
|
$setOnInsert: entry,
|
|
$inc: {
|
|
events: 1
|
|
},
|
|
$set: {
|
|
last: now
|
|
}
|
|
},
|
|
{
|
|
upsert: true,
|
|
projection: { _id: true },
|
|
returnOriginal: false,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, r) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
return callback(null, r && r.value && r.value._id);
|
|
}
|
|
);
|
|
}
|
|
|
|
logout(user, reason, callback) {
|
|
// register this address as the default address for that user
|
|
return this.users.collection('users').findOne(
|
|
{
|
|
_id: new ObjectID(user)
|
|
},
|
|
{
|
|
projection: {
|
|
_id: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, userData) => {
|
|
if (err) {
|
|
log.error('DB', 'DBFAIL logout id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
if (!userData) {
|
|
err = new Error('User not found');
|
|
err.code = 'UserNotFound';
|
|
return callback(err);
|
|
}
|
|
|
|
if (!this.messageHandler || !this.messageHandler.notifier) {
|
|
return callback(null, false);
|
|
}
|
|
|
|
this.messageHandler.notifier.fire(userData._id, {
|
|
command: 'LOGOUT',
|
|
reason
|
|
});
|
|
return callback(null, true);
|
|
}
|
|
);
|
|
}
|
|
|
|
// This method deletes non expireing records from database
|
|
delete(user, meta, callback) {
|
|
meta = meta || {};
|
|
|
|
// clear limits in Redis
|
|
this.redis.del('limits:' + user, () => false);
|
|
|
|
let tryCount = 0;
|
|
let tryDelete = err => {
|
|
if (tryCount++ > 10) {
|
|
return callback(err);
|
|
}
|
|
|
|
this.users.collection('addresses').deleteMany({ user }, err => {
|
|
if (err) {
|
|
log.error('USERDEL', 'Failed to delete addresses for id=%s error=%s', user, err.message);
|
|
err.code = 'InternalDatabaseError';
|
|
if (tryCount > 4) {
|
|
return setTimeout(() => tryDelete(err), 100);
|
|
}
|
|
}
|
|
|
|
this.users.collection('users').deleteOne({ _id: user }, err => {
|
|
if (err) {
|
|
log.error('USERDEL', 'Failed to delete user id=%s error=%s', user, err.message);
|
|
err.code = 'InternalDatabaseError';
|
|
return setTimeout(() => tryDelete(err), 100);
|
|
}
|
|
|
|
// set up a task to delete user messages
|
|
let now = new Date();
|
|
this.database.collection('tasks').insertOne(
|
|
{
|
|
task: 'user-delete',
|
|
locked: false,
|
|
lockedUntil: now,
|
|
created: now,
|
|
status: 'queued',
|
|
user
|
|
},
|
|
() =>
|
|
this.logAuthEvent(
|
|
user,
|
|
{
|
|
action: 'delete user',
|
|
result: 'success',
|
|
sess: meta.session,
|
|
ip: meta.ip
|
|
},
|
|
() => callback(null, true)
|
|
)
|
|
|
|
);
|
|
});
|
|
});
|
|
};
|
|
setImmediate(tryDelete);
|
|
}
|
|
|
|
checkAddress(username, callback) {
|
|
if (username.indexOf('@') < 0) {
|
|
// not formatted as an address, assume regular username
|
|
return callback(null, {
|
|
unameview: tools.uview(username)
|
|
});
|
|
}
|
|
|
|
let address = tools.normalizeAddress(username, false, {
|
|
removeLabel: true,
|
|
removeDots: true
|
|
});
|
|
let domain = address.substr(address.indexOf('@') + 1);
|
|
username = address.substr(0, address.indexOf('@'));
|
|
|
|
let aliasDomain;
|
|
let findAddressData = done => {
|
|
this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: address
|
|
},
|
|
{
|
|
projection: {
|
|
user: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, addressData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return callback(err);
|
|
}
|
|
|
|
if (addressData) {
|
|
return done(null, addressData);
|
|
}
|
|
|
|
// check if the address uses an alias domain
|
|
this.users.collection('domainaliases').findOne(
|
|
{ alias: domain },
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, aliasData) => {
|
|
if (err) {
|
|
return done(err);
|
|
}
|
|
|
|
if (!aliasData) {
|
|
// not an alias domain, nothing to check for
|
|
return done();
|
|
}
|
|
|
|
aliasDomain = aliasData.domain;
|
|
this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@' + aliasDomain
|
|
},
|
|
{
|
|
projection: {
|
|
user: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
},
|
|
(err, addressData) => {
|
|
if (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
return done(err);
|
|
}
|
|
return done(null, addressData);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
findAddressData((err, addressData) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
if (addressData && !addressData.user) {
|
|
// found a non-user address
|
|
return callback(null, false);
|
|
}
|
|
|
|
if (!addressData) {
|
|
// fall back to username formatted as an address
|
|
return callback(null, {
|
|
unameview: address
|
|
});
|
|
}
|
|
|
|
callback(null, {
|
|
_id: addressData.user
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function rateLimitResponse(res, callback) {
|
|
let err = new Error('Authentication was rate limited. Check again in ' + res.ttl + ' seconds');
|
|
err.response = 'NO';
|
|
err.code = 'RateLimitedError';
|
|
return callback(err);
|
|
}
|
|
|
|
// high collision hash function
|
|
function getStringSelector(str) {
|
|
let hash = crypto
|
|
.createHash('sha1')
|
|
.update(str)
|
|
.digest();
|
|
let sum = 0;
|
|
for (let i = 0, len = hash.length; i < len; i++) {
|
|
sum += hash[i];
|
|
}
|
|
return (sum % 32).toString(16);
|
|
}
|
|
|
|
module.exports = UserHandler;
|