mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-12-29 03:36:06 +08:00
3408 lines
109 KiB
JavaScript
3408 lines
109 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 util = require('util');
|
|
|
|
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
|
|
});
|
|
|
|
this.flushUserCache = util.promisify(this.userCache.flush.bind(this.userCache));
|
|
|
|
if (!('authlogExpireDays' in options)) {
|
|
this.authlogExpireDays = 30;
|
|
} else {
|
|
this.authlogExpireDays = options.authlogExpireDays;
|
|
}
|
|
}
|
|
|
|
resolveAddress(address, options, callback) {
|
|
if (!callback) {
|
|
return this.asyncResolveAddress(address, options);
|
|
}
|
|
this.asyncResolveAddress(address, options)
|
|
.catch(err => callback(err))
|
|
.then(result => callback(null, result));
|
|
}
|
|
|
|
async asyncResolveAddress(address, options) {
|
|
options = options || {};
|
|
let wildcard = !!options.wildcard;
|
|
|
|
address = tools.normalizeAddress(address, false, {
|
|
removeLabel: true,
|
|
removeDots: true
|
|
});
|
|
|
|
let atPos = address.indexOf('@');
|
|
let username = address.substr(0, atPos);
|
|
let domain = address.substr(atPos + 1);
|
|
|
|
let projection = {
|
|
user: true,
|
|
targets: true
|
|
};
|
|
|
|
Object.keys(options.projection || {}).forEach(key => {
|
|
projection[key] = true;
|
|
});
|
|
|
|
if (options.projection === false) {
|
|
// do not use projection
|
|
projection = false;
|
|
}
|
|
|
|
try {
|
|
let addressData;
|
|
// try exact match
|
|
addressData = await this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@' + domain
|
|
},
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
|
|
if (addressData) {
|
|
return addressData;
|
|
}
|
|
|
|
// try an alias
|
|
let aliasDomain;
|
|
let aliasData = await this.users.collection('domainaliases').findOne(
|
|
{ alias: domain },
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
|
|
if (aliasData) {
|
|
aliasDomain = aliasData.domain;
|
|
|
|
addressData = await this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@' + aliasDomain
|
|
},
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
|
|
if (addressData) {
|
|
return addressData;
|
|
}
|
|
}
|
|
|
|
if (!wildcard) {
|
|
// wildcard not allowed, so there is nothing else to check for
|
|
return false;
|
|
}
|
|
|
|
// Add addrview to projection as we will need it down further for
|
|
// matching the right wildcard partial address.
|
|
projection.addrview = true;
|
|
let partialWildcards = tools.getWildcardAddresses(username, domain);
|
|
|
|
let query = {
|
|
addrview: { $in: partialWildcards }
|
|
};
|
|
|
|
let sortedDomainPartials = partialWildcards.map(addr => addr.replace(/^\*/, '')).sort((a, b) => b.lenght - a.length);
|
|
let sortedAliasPartials = [];
|
|
|
|
if (aliasDomain) {
|
|
// search for alias domain as well
|
|
let aliasWildcards = tools.getWildcardAddresses(username, aliasDomain);
|
|
query.addrview.$in = query.addrview.$in.concat(aliasWildcards);
|
|
sortedAliasPartials = aliasWildcards.map(addr => addr.replace(/^\*/, '')).sort((a, b) => a.lenght - b.length);
|
|
}
|
|
|
|
let sortedPartials = sortedDomainPartials.concat(sortedAliasPartials);
|
|
|
|
// try to find a catch-all address while preferring the longest match
|
|
let addressMatches = await this.users
|
|
.collection('addresses')
|
|
.find(query, {
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
})
|
|
.toArray();
|
|
|
|
if (addressMatches && addressMatches.length) {
|
|
let matchingPartials = new WeakMap();
|
|
|
|
addressMatches.forEach(addressData => {
|
|
let partialMatch = sortedPartials.find(partial => addressData.addrview.indexOf(partial) >= 0);
|
|
if (partialMatch) {
|
|
matchingPartials.set(addressData, sortedPartials.indexOf(partialMatch));
|
|
}
|
|
});
|
|
|
|
addressData = addressMatches.sort((a, b) => {
|
|
let aPos = matchingPartials.has(a) ? matchingPartials.get(a) : Infinity;
|
|
let bPos = matchingPartials.has(b) ? matchingPartials.get(b) : Infinity;
|
|
return aPos - bPos;
|
|
})[0];
|
|
}
|
|
|
|
if (addressData) {
|
|
return addressData;
|
|
}
|
|
|
|
// try to find a catch-all user (eg. "postmaster@*")
|
|
addressData = await this.users.collection('addresses').findOne(
|
|
{
|
|
addrview: username + '@*'
|
|
},
|
|
{
|
|
projection,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
|
|
if (addressData) {
|
|
return addressData;
|
|
}
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
// no match was found
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolve 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;
|
|
}
|
|
if (!callback) {
|
|
return this.asyncGet(username, extraFields);
|
|
}
|
|
this.asyncGet(username, extraFields)
|
|
.catch(err => callback(err))
|
|
.then(result => callback(null, result));
|
|
}
|
|
|
|
async asyncGet(username, extraFields) {
|
|
let fields = {
|
|
_id: true,
|
|
quota: true,
|
|
storageUsed: true,
|
|
disabled: true,
|
|
suspended: true
|
|
};
|
|
|
|
Object.keys(extraFields || {}).forEach(field => {
|
|
fields[field] = true;
|
|
});
|
|
|
|
let addressData;
|
|
let query;
|
|
if (tools.isId(username)) {
|
|
query = { _id: new ObjectID(username) };
|
|
} else if (username.indexOf('@') < 0) {
|
|
// assume regular username
|
|
query = { unameview: tools.uview(username) };
|
|
} else {
|
|
addressData = await this.asyncResolveAddress(username, { projection: { name: true } });
|
|
|
|
if (addressData.user) {
|
|
query = { _id: addressData.user };
|
|
}
|
|
}
|
|
|
|
if (!query) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
let userData = await this.users.collection('users').findOne(query, {
|
|
projection: fields,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
});
|
|
|
|
if (userData && fields.name && addressData && addressData.name) {
|
|
// override name
|
|
userData.name = addressData.name;
|
|
}
|
|
|
|
return userData;
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async rateLimitIP(meta, count) {
|
|
if (!meta || !meta.ip || !consts.IP_AUTH_FAILURES) {
|
|
return { success: true };
|
|
}
|
|
|
|
let wlKey = 'rl-wl';
|
|
// $ redis-cli
|
|
// > SADD "rl-wl" "1.2.3.4"
|
|
try {
|
|
let isMember = await this.redis.sismember(wlKey, meta.ip);
|
|
if (isMember) {
|
|
// whitelisted IP
|
|
return { success: true };
|
|
}
|
|
} catch (err) {
|
|
log.error('Redis', 'SMFAIL key=%s value=%s error=%s', wlKey, meta.ip, err.message);
|
|
// ignore errors
|
|
return { success: true };
|
|
}
|
|
|
|
return await this.counters.asyncTTLCounter('auth_ip:' + meta.ip, count, consts.IP_AUTH_FAILURES, consts.IP_AUTH_WINDOW);
|
|
}
|
|
|
|
/**
|
|
* rateLimitUser
|
|
* @param {String} tokenID user identifier
|
|
* @param {Object} meta
|
|
* @param {Integer} count
|
|
*/
|
|
async rateLimitUser(tokenID, meta, count) {
|
|
if (meta && meta.ip) {
|
|
// check if whitelisted IP
|
|
let wlKey = 'rl-wl';
|
|
// $ redis-cli
|
|
// > SADD "rl-wl" "1.2.3.4"
|
|
try {
|
|
let isMember = await this.redis.sismember(wlKey, meta.ip);
|
|
if (isMember) {
|
|
// whitelisted IP, allow authentication attempt without rate limits
|
|
return { success: true };
|
|
}
|
|
} catch (err) {
|
|
log.error('Redis', 'SMFAIL key=%s value=%s error=%s', wlKey, meta.ip, err.message);
|
|
// ignore errors
|
|
}
|
|
}
|
|
return await this.counters.asyncTTLCounter('auth_user:' + tokenID, count, consts.USER_AUTH_FAILURES, consts.USER_AUTH_WINDOW);
|
|
}
|
|
|
|
/**
|
|
* rateLimitReleaseUser
|
|
* @param {String} tokenID user identifier
|
|
* @param {Integer} count
|
|
*/
|
|
async rateLimitReleaseUser(tokenID) {
|
|
await this.redis.del('auth_user:' + tokenID);
|
|
}
|
|
|
|
/**
|
|
* rateLimit
|
|
* @param {String} tokenID user identifier
|
|
* @param {Object} meta
|
|
* @param {String} meta.ip request remote ip address
|
|
* @param {Integer} count
|
|
*/
|
|
async rateLimit(tokenID, meta, count) {
|
|
let ipRes = await this.rateLimitIP(meta, count);
|
|
|
|
let userRes = await this.rateLimitUser(tokenID, meta, count);
|
|
if (!ipRes.success) {
|
|
return ipRes;
|
|
}
|
|
return userRes;
|
|
}
|
|
|
|
/**
|
|
* Authenticate user
|
|
*
|
|
* @param {String} username Either username or email address
|
|
* @param {String} password Password for authentication
|
|
* @param {String} [requiredScope="master"] Which scope to use
|
|
* @param {Object} [meta] Additional meta info
|
|
* @param {String} [meta.ip] IP address of the client
|
|
* @param {String} [meta.session] Session ID
|
|
*/
|
|
authenticate(username, password, requiredScope, meta, callback) {
|
|
if (!callback) {
|
|
return this.asyncAuthenticate(username, password, requiredScope, meta);
|
|
}
|
|
this.asyncAuthenticate(username, password, requiredScope, meta)
|
|
.catch(err => callback(err))
|
|
.then(result => {
|
|
if (!Array.isArray(result)) {
|
|
result = [].concat(result || [false, false]);
|
|
}
|
|
callback(null, ...result);
|
|
});
|
|
}
|
|
|
|
async asyncAuthenticate(username, password, requiredScope, meta) {
|
|
meta = meta || {};
|
|
requiredScope = requiredScope || 'master';
|
|
|
|
username = (username || '').toString();
|
|
let userDomain = username.indexOf('@') >= 0 ? username.split('@').pop() : '';
|
|
|
|
let now = new Date();
|
|
|
|
let passwordType = 'master'; // try 'master' first and 'asp' later
|
|
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,
|
|
_sess: meta.sess
|
|
});
|
|
return [false, false];
|
|
}
|
|
|
|
// first check if client IP is not used too much
|
|
let rateLimitRes;
|
|
try {
|
|
rateLimitRes = await this.rateLimitIP(meta, 0);
|
|
} catch (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,
|
|
_sess: meta.sess
|
|
});
|
|
// return as failed auth
|
|
return [false, false];
|
|
}
|
|
if (!rateLimitRes.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,
|
|
_sess: meta.sess
|
|
});
|
|
throw rateLimitResponse(rateLimitRes);
|
|
}
|
|
|
|
let userQuery;
|
|
try {
|
|
userQuery = await this.checkAddress(username);
|
|
} catch (err) {
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Unknown user',
|
|
_auth_result: 'unknown',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip,
|
|
_sess: meta.sess
|
|
});
|
|
return [false, false];
|
|
}
|
|
|
|
if (!userQuery) {
|
|
// nothing to do here
|
|
return [false, false];
|
|
}
|
|
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(userQuery, {
|
|
projection: {
|
|
_id: true,
|
|
username: true,
|
|
address: true,
|
|
tempPassword: true,
|
|
password: true,
|
|
enabled2fa: true,
|
|
u2f: true,
|
|
disabled: true,
|
|
suspended: true,
|
|
disabledScopes: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
});
|
|
} catch (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,
|
|
_sess: meta.sess
|
|
});
|
|
// return as failed auth
|
|
return [false, false];
|
|
}
|
|
|
|
if (!userData) {
|
|
// User was not found
|
|
|
|
// rate limit failed authentication attempts against non-existent users as well
|
|
try {
|
|
let ustring = (userQuery.unameview || userQuery._id || '').toString();
|
|
rateLimitRes = await this.rateLimit(ustring, meta, 1);
|
|
} catch (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,
|
|
_sess: meta.sess
|
|
});
|
|
}
|
|
|
|
if (!rateLimitRes.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,
|
|
_sess: meta.sess
|
|
});
|
|
throw rateLimitResponse(rateLimitRes);
|
|
}
|
|
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'Unknown user',
|
|
_auth_result: 'unknown',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip,
|
|
_sess: meta.sess
|
|
});
|
|
|
|
// return as failed auth
|
|
return [false, false];
|
|
}
|
|
|
|
// make sure we use the primary domain if available
|
|
userDomain = (userData.address || '').split('@').pop() || userDomain;
|
|
|
|
try {
|
|
// check if there are not too many auth attempts for that user
|
|
rateLimitRes = await this.rateLimitUser(userData._id, meta, 0);
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
err.user = userData._id;
|
|
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,
|
|
_sess: meta.sess
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
if (!rateLimitRes.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,
|
|
_sess: meta.sess
|
|
});
|
|
|
|
let err = rateLimitResponse(rateLimitRes);
|
|
err.user = userData._id;
|
|
throw err;
|
|
}
|
|
|
|
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,
|
|
_sess: meta.sess
|
|
});
|
|
await this.logAuthEvent(userData._id, meta);
|
|
return [false, userData._id];
|
|
}
|
|
|
|
if (userData.suspended) {
|
|
// disabled users can not log in
|
|
meta.result = 'suspended';
|
|
// TODO: should we send some specific error message?
|
|
this.loggelf({
|
|
short_message: '[AUTHFAIL] ' + username,
|
|
_error: 'User is suspended',
|
|
_auth_result: 'suspended',
|
|
_username: username,
|
|
_domain: userDomain,
|
|
_user: userData._id,
|
|
_scope: requiredScope,
|
|
_ip: meta.ip,
|
|
_sess: meta.sess
|
|
});
|
|
await this.logAuthEvent(userData._id, meta);
|
|
return [false, userData._id];
|
|
}
|
|
|
|
try {
|
|
let authSuccess = async authResponse => {
|
|
// clear rate limit counter on success
|
|
try {
|
|
await this.rateLimitReleaseUser(userData._id);
|
|
} catch (err) {
|
|
//ignore
|
|
}
|
|
|
|
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,
|
|
_sess: meta.sess
|
|
});
|
|
|
|
return [authResponse, userData._id];
|
|
};
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
let requirePasswordChange = false;
|
|
let usingTemporaryPassword = false;
|
|
|
|
let success;
|
|
if (userData.tempPassword && userData.tempPassword.created > new Date(now.getTime() - consts.TEMP_PASS_WINDOW)) {
|
|
// try temporary password first
|
|
|
|
try {
|
|
success = await hashes.compare(password, userData.tempPassword.password);
|
|
} catch (err) {
|
|
err.code = 'HashError';
|
|
throw err;
|
|
}
|
|
if (success) {
|
|
if (userData.validAfter > now) {
|
|
let err = new Error('Temporary password is not yet activated');
|
|
err.code = 'TempPasswordNotYetValid';
|
|
throw err;
|
|
}
|
|
|
|
requirePasswordChange = true;
|
|
usingTemporaryPassword = true;
|
|
}
|
|
}
|
|
|
|
if (!success) {
|
|
// temporary password did not match, try actual password
|
|
success = await hashes.compare(password, userData.password);
|
|
}
|
|
|
|
if (success) {
|
|
// master password matched
|
|
|
|
meta.result = 'success';
|
|
meta.source = !usingTemporaryPassword ? 'master' : 'temporary';
|
|
|
|
if (enabled2fa.length) {
|
|
meta.require2fa = enabled2fa.length ? enabled2fa.join(',') : false;
|
|
}
|
|
|
|
if (hashes.shouldRehash(userData.password)) {
|
|
// master password needs rehashing
|
|
let hash;
|
|
try {
|
|
hash = await hashes.hash(password);
|
|
if (!hash) {
|
|
// should this even happen???
|
|
throw new Error('Failed to rehash password');
|
|
}
|
|
|
|
try {
|
|
let r = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: userData._id
|
|
},
|
|
{
|
|
$set: {
|
|
password: hash
|
|
}
|
|
},
|
|
{ w: 'majority' }
|
|
);
|
|
|
|
if (r.modifiedCount) {
|
|
log.info('DB', 'REHASHED user=%s algo=%s', userData._id, consts.DEFAULT_HASH_ALGO);
|
|
}
|
|
} catch (err) {
|
|
log.error('DB', 'DBFAIL rehash user=%s error=%s', userData._id, err.message);
|
|
}
|
|
} catch (err) {
|
|
log.error('DB', 'HASHFAIL rehash user=%s error=%s', userData._id, err.message);
|
|
// ignore DB error, rehash some other time
|
|
}
|
|
}
|
|
|
|
let disabledScopes = userData.disabledScopes || [];
|
|
|
|
if (requiredScope !== 'master' && (enabled2fa.length || usingTemporaryPassword || disabledScopes.includes(requiredScope))) {
|
|
// master password can not be used for other scopes than 'master' if 2FA is enabled
|
|
// temporary password is also only valid for master
|
|
meta.result = 'fail';
|
|
await this.logAuthEvent(userData._id, meta);
|
|
|
|
let err = new Error('Authentication failed. Invalid scope');
|
|
err.code = 'InvalidAuthScope';
|
|
err.response = 'NO'; // imap response code
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
let authEvent = await this.logAuthEvent(userData._id, meta);
|
|
await this.users.collection('users').updateOne(
|
|
{
|
|
_id: userData._id
|
|
},
|
|
{
|
|
$set: {
|
|
lastLogin: {
|
|
time: now,
|
|
authEvent,
|
|
ip: meta.ip
|
|
}
|
|
}
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
let authResponse = {
|
|
user: userData._id,
|
|
username: userData.username,
|
|
scope: meta.requiredScope,
|
|
// 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;
|
|
|
|
if (enabled2fa.includes('u2f') && userData.u2f && userData.u2f.keyHandle) {
|
|
try {
|
|
let u2fAuthRequest = await this.generateU2fAuthRequest(userData._id, userData.u2f.keyHandle, meta.appId);
|
|
if (u2fAuthRequest) {
|
|
authResponse.u2fAuthRequest = u2fAuthRequest;
|
|
}
|
|
} catch (err) {
|
|
log.error('DB', 'U2FREFAIL u2fAuthRequest id=%s error=%s', userData._id, err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
return await authSuccess(authResponse);
|
|
}
|
|
|
|
if (requiredScope === 'master') {
|
|
// only master password can be used for management tasks
|
|
meta.result = 'fail';
|
|
meta.source = 'master';
|
|
await this.logAuthEvent(userData._id, meta);
|
|
|
|
let err = new Error('Invalid Auth');
|
|
err.code = 'AuthFail'; // will be returned as failed auth, not an error
|
|
throw err;
|
|
}
|
|
|
|
// 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';
|
|
await this.logAuthEvent(userData._id, meta);
|
|
|
|
let err = new Error('Invalid Auth');
|
|
err.code = 'AuthFail'; // will be returned as failed auth, not an error
|
|
throw err;
|
|
}
|
|
|
|
let selector = getStringSelector(password);
|
|
|
|
let asps;
|
|
try {
|
|
asps = await this.users
|
|
.collection('asps')
|
|
.find({
|
|
user: userData._id
|
|
})
|
|
.maxTimeMS(consts.DB_MAX_TIME_USERS)
|
|
.toArray();
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!asps || !asps.length) {
|
|
// user does not have app specific passwords set
|
|
meta.result = 'fail';
|
|
meta.source = 'master';
|
|
await this.logAuthEvent(userData._id, meta);
|
|
|
|
let err = new Error('Invalid Auth');
|
|
err.code = 'AuthFail'; // will be returned as failed auth, not an error
|
|
throw err;
|
|
}
|
|
|
|
for (let asp of asps) {
|
|
if (asp.selector && asp.selector !== selector) {
|
|
// no need to check, definitely a wrong one
|
|
continue;
|
|
}
|
|
|
|
let success;
|
|
try {
|
|
success = await hashes.compare(password, asp.password);
|
|
} catch (err) {
|
|
err.code = 'HashError';
|
|
throw err;
|
|
}
|
|
|
|
if (!success) {
|
|
continue;
|
|
}
|
|
|
|
// Found a matching Application Specific Password
|
|
|
|
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();
|
|
|
|
// Check if passwords scope matches required scope
|
|
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
|
|
meta.result = 'fail';
|
|
|
|
await this.logAuthEvent(userData._id, meta);
|
|
|
|
let err = new Error('Authentication failed. Invalid scope');
|
|
err.code = 'InvalidAuthScope';
|
|
err.response = 'NO'; // imap response code
|
|
throw err;
|
|
}
|
|
|
|
// Everything checked out
|
|
|
|
meta.result = 'success';
|
|
|
|
let authEvent;
|
|
try {
|
|
authEvent = await this.logAuthEvent(userData._id, meta);
|
|
} catch (err) {
|
|
// don't really care
|
|
}
|
|
|
|
let aspUpdates = {
|
|
used: now,
|
|
authEvent,
|
|
authIp: meta.ip
|
|
};
|
|
|
|
if (asp.ttl) {
|
|
// extend temporary password ttl every time it is used
|
|
aspUpdates.expires = new Date(now.getTime() + asp.ttl * 1000);
|
|
}
|
|
|
|
try {
|
|
await this.users.collection('asps').updateOne(
|
|
{
|
|
_id: asp._id
|
|
},
|
|
{
|
|
$set: aspUpdates
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return await authSuccess({
|
|
user: userData._id,
|
|
username: userData.username,
|
|
scope: requiredScope,
|
|
asp: asp._id.toString(),
|
|
require2fa: false // application scope never requires 2FA
|
|
});
|
|
}
|
|
|
|
// no suitable password found
|
|
meta.result = 'fail';
|
|
meta.source = 'master';
|
|
await this.logAuthEvent(userData._id, meta);
|
|
|
|
let err = new Error('Invalid Auth');
|
|
err.code = 'AuthFail'; // will be returned as failed auth, not an error
|
|
throw err;
|
|
} catch (err) {
|
|
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,
|
|
_sess: meta.sess
|
|
});
|
|
|
|
// increment rate limit counter on failure
|
|
await this.rateLimit(userData._id, meta, 1);
|
|
|
|
if (err.code !== 'AuthFail') {
|
|
err.user = userData._id;
|
|
throw err;
|
|
}
|
|
|
|
return [false, userData._id];
|
|
}
|
|
}
|
|
|
|
async generateU2fAuthRequest(user, keyHandle, appId) {
|
|
let authRequest;
|
|
try {
|
|
authRequest = u2f.request(appId || config.u2f.appId, keyHandle);
|
|
} catch (err) {
|
|
log.error('U2F', 'U2FFAIL request id=%s error=%s', user, err.message);
|
|
}
|
|
|
|
if (!authRequest) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
let results = await this.redis
|
|
.multi()
|
|
.set('u2f:auth:' + user, JSON.stringify(authRequest))
|
|
.expire('u2f:auth:' + user, 1 * 3600)
|
|
.exec();
|
|
if (!results || !results[0]) {
|
|
throw new Error('Invalid DB response');
|
|
} else if (results && results[0] && results[0][0]) {
|
|
throw typeof results[0][0] !== 'object' ? new Error(results[0][0]) : results[0][0];
|
|
}
|
|
return authRequest;
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async generateASP(user, data) {
|
|
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));
|
|
}
|
|
|
|
let hash;
|
|
try {
|
|
hash = await hashes.hash(password);
|
|
} catch (err) {
|
|
log.error('DB', 'HASHFAIL generateASP id=%s error=%s', user, err.message);
|
|
err.code = 'HashError';
|
|
throw 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);
|
|
}
|
|
|
|
try {
|
|
let userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
_id: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
if (!userData) {
|
|
let err = new Error('Could not find user data');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
} catch (err) {
|
|
log.error('DB', 'DBFAIL generateASP id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.users.collection('asps').insertOne(passwordData);
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
await 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
|
|
});
|
|
|
|
return {
|
|
id: passwordData._id,
|
|
password
|
|
};
|
|
}
|
|
|
|
async deleteASP(user, asp, data) {
|
|
asp = new ObjectID(asp);
|
|
|
|
let aspData;
|
|
try {
|
|
aspData = await this.users.collection('asps').findOne(
|
|
{
|
|
_id: asp,
|
|
user
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!aspData) {
|
|
let err = new Error('Application Specific Password was not found');
|
|
err.code = 'AspNotFound';
|
|
throw err;
|
|
}
|
|
|
|
let r;
|
|
try {
|
|
r = await this.users.collection('asps').deleteOne({ _id: asp });
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (r.deletedCount) {
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'delete asp',
|
|
asp: asp._id,
|
|
aname: asp.description,
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async create(data) {
|
|
// check if username is not already taken
|
|
let existingUserData;
|
|
try {
|
|
existingUserData = await this.users.collection('users').findOne(
|
|
{
|
|
username: data.username.replace(/\./g, '')
|
|
},
|
|
{
|
|
projection: {
|
|
unameview: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (existingUserData) {
|
|
let err = new Error('This username already exists');
|
|
err.code = 'UserExistsError';
|
|
throw err;
|
|
}
|
|
|
|
let address = data.address && !data.emptyAddress ? data.address : '';
|
|
let addrview;
|
|
|
|
if (!data.emptyAddress) {
|
|
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 });
|
|
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));
|
|
|
|
if (addrview) {
|
|
// check if address is not already taken
|
|
let existingAddressData;
|
|
try {
|
|
existingAddressData = await this.users.collection('addresses').findOne(
|
|
{
|
|
addrview
|
|
},
|
|
{
|
|
projection: {
|
|
_id: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (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';
|
|
throw err;
|
|
}
|
|
if (existingAddressData) {
|
|
let err = new Error('This address already exists');
|
|
err.code = 'AddressExistsError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let junkRetention = consts.JUNK_RETENTION;
|
|
|
|
// Insert user data
|
|
|
|
let hash;
|
|
if (!data.password) {
|
|
// Users with an empty password can not log in
|
|
hash = '';
|
|
} else {
|
|
try {
|
|
if (data.hashedPassword) {
|
|
// try if the hashing library can handle it?
|
|
let algo = hashes.checkHashSupport(data.password);
|
|
if (algo.result) {
|
|
hash = data.password;
|
|
} else {
|
|
throw new Error('Invalid algo: ' + JSON.stringify(algo.algo));
|
|
}
|
|
|
|
} else {
|
|
hash = await hashes.hash(data.password);
|
|
}
|
|
} catch (err) {
|
|
log.error('DB', 'HASHFAIL user.create id=%s error=%s', data.username, err.message);
|
|
err.code = 'HashError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
let userData = {
|
|
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,
|
|
|
|
// incremented every time password is changed
|
|
authVersion: 1,
|
|
|
|
// 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,
|
|
pop3MaxMessages: data.pop3MaxMessages || 0,
|
|
imapMaxConnections: data.imapMaxConnections || 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,
|
|
suspended: false,
|
|
|
|
created: new Date()
|
|
};
|
|
|
|
if (data.tags && data.tags.length) {
|
|
userData.tags = data.tags;
|
|
userData.tagsview = data.tagsview;
|
|
}
|
|
|
|
if (data.fromWhitelist && data.fromWhitelist.length) {
|
|
userData.fromWhitelist = data.fromWhitelist;
|
|
}
|
|
|
|
let user;
|
|
try {
|
|
let r = await this.users.collection('users').insertOne(userData, { w: 'majority' });
|
|
if (r.insertedId) {
|
|
user = userData._id = r.insertedId;
|
|
} else {
|
|
throw new Error('Failed to insert data');
|
|
}
|
|
} catch (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;
|
|
throw err;
|
|
}
|
|
|
|
let mailboxes = this.getMailboxes(data.language, data.mailboxes).map(mailbox => {
|
|
mailbox.user = user;
|
|
|
|
if (['\\Trash', '\\Junk'].includes(mailbox.specialUse)) {
|
|
mailbox.retention = data.retention ? Math.min(data.retention, junkRetention) : junkRetention;
|
|
} else {
|
|
mailbox.retention = data.retention;
|
|
}
|
|
|
|
return mailbox;
|
|
});
|
|
|
|
try {
|
|
await this.database.collection('mailboxes').insertMany(mailboxes, {
|
|
w: 'majority',
|
|
ordered: false
|
|
});
|
|
} catch (err) {
|
|
try {
|
|
// try to rollback
|
|
await this.users.collection('users').deleteOne({ _id: user });
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!data.emptyAddress) {
|
|
let addressData = {
|
|
user,
|
|
address,
|
|
// dotless version
|
|
addrview,
|
|
created: new Date()
|
|
};
|
|
|
|
if (data.tags && data.tags.length && data.addTagsToAddress) {
|
|
addressData.tags = data.tags;
|
|
addressData.tagsview = data.tagsview;
|
|
}
|
|
|
|
try {
|
|
// insert alias address to email address registry
|
|
await this.users.collection('addresses').insertOne(addressData, { w: 'majority' });
|
|
} catch (err) {
|
|
try {
|
|
// try to rollback
|
|
await this.users.collection('users').deleteOne({ _id: user });
|
|
await this.database.collection('mailboxes').deleteMany({ user });
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
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;
|
|
throw 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;
|
|
}
|
|
|
|
try {
|
|
// register this address as the default address for that user
|
|
let result = await this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user,
|
|
activated: false
|
|
},
|
|
{
|
|
$set: updates
|
|
},
|
|
{
|
|
returnOriginal: false,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
// use updated user data
|
|
userData = result.value;
|
|
} catch (err) {
|
|
// try to rollback
|
|
try {
|
|
await this.users.collection('users').deleteOne({ _id: user });
|
|
await this.database.collection('mailboxes').deleteMany({ user });
|
|
await this.users.collection('addresses').deleteOne({ user });
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
|
err.message = 'Database Error, failed to create user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (this.messageHandler && !data.emptyAddress) {
|
|
try {
|
|
let parsedName = humanname.parse(userData.name || '');
|
|
await this.pushDefaultMessages(userData, {
|
|
NAME: userData.name || userData.username || address,
|
|
FNAME: parsedName.firstName,
|
|
LNAME: parsedName.lastName,
|
|
DOMAIN: address.substr(address.indexOf('@') + 1),
|
|
EMAIL: address
|
|
});
|
|
} catch (err) {
|
|
log.error('DB', 'PARSEFAIL name=%s error=%s', userData.name, err.message);
|
|
// ignore?
|
|
}
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'account created',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return userData._id;
|
|
}
|
|
|
|
// TODO: reset all existing user sessions
|
|
async reset(user, data) {
|
|
let password = generatePassword.generate({
|
|
length: 12,
|
|
uppercase: true,
|
|
numbers: true,
|
|
symbols: false
|
|
});
|
|
|
|
let hash;
|
|
try {
|
|
hash = await hashes.hash(password);
|
|
} catch (err) {
|
|
log.error('DB', 'HASHFAIL user.reset id=%s error=%s', user, err.message);
|
|
err.code = 'HashError';
|
|
throw err;
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
$set: {
|
|
tempPassword: {
|
|
validAfter: data.validAfter || new Date(),
|
|
password: hash,
|
|
created: new Date()
|
|
}
|
|
}
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to reset user credentials';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('Could not update user ' + user);
|
|
err.code = 'UserUpdateFail';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'reset',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
return password;
|
|
}
|
|
|
|
async setupTotp(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
username: true,
|
|
enabled2fa: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to check user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('Could not find user data');
|
|
err.code = 'UserNotFound';
|
|
throw 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';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user,
|
|
enabled2fa: { $not: { $eq: 'totp' } }
|
|
},
|
|
{
|
|
$set: {
|
|
pendingSeed: seed,
|
|
pendingSeedChanged: new Date()
|
|
}
|
|
},
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS }
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('Could not update user, check if 2FA is not already enabled');
|
|
err.code = 'TotpEnabled';
|
|
throw err;
|
|
}
|
|
|
|
let otpauth_url = speakeasy.otpauthURL({
|
|
secret: secret.ascii,
|
|
// label is part of URL and speakeasy as of v2.0.0 does not encode special characters
|
|
label: encodeURIComponent(data.label || userData.username),
|
|
issuer: data.issuer || 'WildDuck'
|
|
});
|
|
|
|
try {
|
|
let dataUrl = await QRCode.toDataURL(otpauth_url);
|
|
return {
|
|
secret: secret.base32,
|
|
dataUrl
|
|
};
|
|
} catch (err) {
|
|
log.error('DB', 'QRFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Failed to generate QR code';
|
|
err.code = 'QRError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async enableTotp(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
pendingSeed: true,
|
|
pendingSeedChanged: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to fetch user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
if (!userData) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
let disabled2fa = !enabled2fa.length;
|
|
|
|
if (enabled2fa.includes('totp')) {
|
|
// 2fa not set up
|
|
let err = new Error('TOTP 2FA is already enabled for this user');
|
|
err.code = 'TotpEnabled';
|
|
throw 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';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let verified = speakeasy.totp.verify({
|
|
secret,
|
|
encoding: 'base32',
|
|
token: data.token,
|
|
window: consts.TOTP_WINDOW_SIZE
|
|
});
|
|
|
|
if (!verified) {
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'enable 2fa totp',
|
|
result: 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let updateQuery =
|
|
!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'
|
|
}
|
|
};
|
|
|
|
if (disabled2fa) {
|
|
if (!updateQuery.$inc) {
|
|
updateQuery.$inc = {};
|
|
}
|
|
updateQuery.$inc.authVersion = 1;
|
|
}
|
|
|
|
// token was valid, update user settings
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user,
|
|
pendingSeed: userData.pendingSeed
|
|
},
|
|
updateQuery,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS }
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('Failed to set up 2FA. Check if it is not already enabled');
|
|
err.code = 'TotpEnabled';
|
|
throw err;
|
|
}
|
|
|
|
if (disabled2fa) {
|
|
// 2fa was previously disabled, log out other sessions
|
|
await this.logout(user, 'Authentication required');
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'enable 2fa totp',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return { success: true, disabled2fa };
|
|
}
|
|
|
|
async disableTotp(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
|
|
let update =
|
|
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
|
|
? {
|
|
$set: {
|
|
enabled2fa: [],
|
|
seed: '',
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false
|
|
}
|
|
}
|
|
: {
|
|
$pull: {
|
|
enabled2fa: 'totp'
|
|
},
|
|
$set: {
|
|
seed: '',
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false
|
|
}
|
|
};
|
|
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
update,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS }
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'disable 2fa totp',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async checkTotp(user, data) {
|
|
let userRlKey = `totp:${user}`;
|
|
let totpSuccessKey = `totp:${user}:${data.token}`;
|
|
|
|
try {
|
|
let rateLimitRes = await this.rateLimit(userRlKey, data, 0);
|
|
if (!rateLimitRes.success) {
|
|
throw rateLimitResponse(rateLimitRes);
|
|
}
|
|
} catch (err) {
|
|
err.code = err.code || 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
let totpAlreadyUsed = await this.redis.exists(totpSuccessKey);
|
|
if (totpAlreadyUsed) {
|
|
let err = new Error('This code has already been used, please try again with a new code');
|
|
err.response = 'NO';
|
|
err.code = 'RateLimitedError';
|
|
throw err;
|
|
}
|
|
} catch (err) {
|
|
err.code = err.code || 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
username: true,
|
|
enabled2fa: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This user does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw 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';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let verified = speakeasy.totp.verify({
|
|
secret,
|
|
encoding: 'base32',
|
|
token: data.token,
|
|
window: consts.TOTP_WINDOW_SIZE
|
|
});
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'check 2fa totp',
|
|
result: verified ? 'success' : 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
try {
|
|
if (verified) {
|
|
await this.rateLimitReleaseUser(userRlKey);
|
|
} else {
|
|
await this.rateLimit(userRlKey, data, 1);
|
|
}
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
if (verified) {
|
|
try {
|
|
await this.redis
|
|
.multi()
|
|
.set(totpSuccessKey, Date.now())
|
|
.expire(totpSuccessKey, consts.TOTP_WINDOW_SIZE * 30)
|
|
.exec();
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return verified;
|
|
}
|
|
|
|
async enableCustom2fa(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to fetch user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
// previous versions used {enabled2fa: true} for TOTP based 2FA
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
let disabled2fa = !enabled2fa.length;
|
|
|
|
if (enabled2fa.includes('custom')) {
|
|
// 2fa not set up
|
|
let err = new Error('Custom 2FA is already enabled for this user');
|
|
err.code = 'CustomEnabled';
|
|
throw err;
|
|
}
|
|
|
|
let updateQuery =
|
|
!userData.enabled2fa || typeof userData.enabled2fa === 'boolean'
|
|
? {
|
|
$set: {
|
|
enabled2fa: ['custom'].concat(userData.enabled2fa ? 'totp' : [])
|
|
}
|
|
}
|
|
: {
|
|
$addToSet: {
|
|
enabled2fa: 'custom'
|
|
}
|
|
};
|
|
|
|
if (disabled2fa) {
|
|
if (!updateQuery.$inc) {
|
|
updateQuery.$inc = {};
|
|
}
|
|
updateQuery.$inc.authVersion = 1;
|
|
}
|
|
|
|
// update user settings
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
updateQuery,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS }
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
if (disabled2fa) {
|
|
// 2fa was previously disabled, log out other sessions
|
|
await this.logout(user, 'Authentication required');
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'enable 2fa custom',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return { success: true, disabled2fa };
|
|
}
|
|
|
|
async disableCustom2fa(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
|
|
let update = {
|
|
$pull: {
|
|
enabled2fa: 'custom'
|
|
}
|
|
};
|
|
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
update,
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'disable 2fa custom',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async setupU2f(user, data) {
|
|
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 false;
|
|
}
|
|
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
username: true,
|
|
enabled2fa: true,
|
|
seed: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to check user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('Could not find user data');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
|
|
if (enabled2fa.includes('u2f')) {
|
|
let err = new Error('U2F 2FA is already enabled for this user');
|
|
err.code = 'U2fEnabled';
|
|
throw err;
|
|
}
|
|
|
|
// store registration request to Redis
|
|
let results;
|
|
try {
|
|
results = await this.redis
|
|
.multi()
|
|
.set('u2f:req:' + user, JSON.stringify(registrationRequest))
|
|
.expire('u2f:req:' + user, 1 * 3600)
|
|
.exec();
|
|
|
|
if (!results || !results[0]) {
|
|
throw new Error('Invalid DB response');
|
|
} else if (results && results[0] && results[0][0]) {
|
|
throw typeof results[0][0] === 'object' ? results[0][0] : new Error(results[0][0]);
|
|
}
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
return registrationRequest;
|
|
}
|
|
|
|
async enableU2f(user, data) {
|
|
let results;
|
|
try {
|
|
results = await this.redis
|
|
.multi()
|
|
.get('u2f:req:' + user)
|
|
.del('u2f:req:' + user)
|
|
.exec();
|
|
|
|
if (!results || !results[0]) {
|
|
throw new Error('Invalid DB response');
|
|
} else if (results && results[0] && results[0][0]) {
|
|
throw typeof results[0][0] === 'object' ? results[0][0] : new Error(results[0][0]);
|
|
}
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
let registrationRequest = results[0][1];
|
|
|
|
if (!registrationRequest) {
|
|
let err = new Error('U2F 2FA is not initialized for this user');
|
|
err.code = 'U2fDisabled';
|
|
throw err;
|
|
}
|
|
try {
|
|
registrationRequest = JSON.parse(registrationRequest);
|
|
} catch (E) {
|
|
let err = new Error('Invalid 2FA data stored');
|
|
err.code = 'InvalidU2fRequest';
|
|
throw err;
|
|
}
|
|
|
|
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) {
|
|
let err = new Error((result && result.errorMessage) || 'Failed to validate U2F response');
|
|
err.code = 'InvalidU2fRequest';
|
|
throw err;
|
|
}
|
|
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to fetch user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
let enabled2fa = Array.isArray(userData.enabled2fa) ? userData.enabled2fa : [].concat(userData.enabled2fa ? 'totp' : []);
|
|
let disabled2fa = !enabled2fa.length;
|
|
|
|
if (enabled2fa.includes('u2f')) {
|
|
// 2fa not set up
|
|
let err = new Error('U2F 2FA is already enabled for this user');
|
|
err.code = 'U2fEnabled';
|
|
throw err;
|
|
}
|
|
|
|
let curDate = new Date();
|
|
let updateQuery =
|
|
!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
|
|
}
|
|
}
|
|
};
|
|
|
|
if (disabled2fa) {
|
|
if (!updateQuery.$inc) {
|
|
updateQuery.$inc = {};
|
|
}
|
|
updateQuery.$inc.authVersion = 1;
|
|
}
|
|
|
|
result = false;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
updateQuery,
|
|
{ maxTimeMS: consts.DB_MAX_TIME_USERS }
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('Failed to set up 2FA. User not found');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
if (disabled2fa) {
|
|
// 2fa was previously disabled, log out other sessions
|
|
await this.logout(user, 'Authentication required');
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'enable 2fa u2f',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return { success: true, disabled2fa };
|
|
}
|
|
|
|
async disableU2f(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This username does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw 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('Could not update user, check if U2F 2FA is not already disabled');
|
|
err.code = 'U2fDisabled';
|
|
throw err;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
};
|
|
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
update,
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('Could not update user, check if 2FA is not already disabled');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'disable 2fa u2f',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async startU2f(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
let err = new Error('This user does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
|
|
let authRequest = await this.generateU2fAuthRequest(user, userData.u2f.keyHandle, data.appId);
|
|
if (!authRequest) {
|
|
return false;
|
|
}
|
|
|
|
return authRequest;
|
|
}
|
|
|
|
async checkU2f(user, data) {
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
projection: {
|
|
enabled2fa: true,
|
|
username: true,
|
|
u2f: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
if (!userData) {
|
|
let err = new Error('This user does not exist');
|
|
err.code = 'UserNotFound';
|
|
throw 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';
|
|
throw err;
|
|
}
|
|
|
|
let results;
|
|
try {
|
|
results = await this.redis
|
|
.multi()
|
|
.get('u2f:auth:' + user)
|
|
.del('u2f:auth:' + user)
|
|
.exec();
|
|
if (!results || !results[0]) {
|
|
throw new Error('Invalid DB response');
|
|
} else if (results && results[0] && results[0][0]) {
|
|
throw typeof results[0][0] === 'object' ? results[0][0] : new Error(results[0][0]);
|
|
}
|
|
} catch (err) {
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
let authRequest = results[0][1];
|
|
|
|
if (!authRequest) {
|
|
return false;
|
|
}
|
|
try {
|
|
authRequest = JSON.parse(authRequest);
|
|
} catch (E) {
|
|
return 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;
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'check 2fa u2f',
|
|
result: verified ? 'success' : 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return verified;
|
|
}
|
|
|
|
async disable2fa(user, data) {
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').updateOne(
|
|
{
|
|
_id: user
|
|
},
|
|
{
|
|
$set: {
|
|
enabled2fa: [],
|
|
seed: '',
|
|
pendingSeed: '',
|
|
pendingSeedChanged: false,
|
|
u2f: {
|
|
keyHandle: '',
|
|
pubKey: '',
|
|
cert: '',
|
|
date: new Date()
|
|
}
|
|
}
|
|
},
|
|
{
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.matchedCount) {
|
|
let err = new Error('Could not update user, check if 2FA is not already disabled');
|
|
err.code = 'U2fDisabled';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'disable 2fa',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async update(user, data) {
|
|
let $set = {};
|
|
let $push = {};
|
|
|
|
let updates = false;
|
|
let passwordChanged = false;
|
|
let wasSuspended = 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'],
|
|
['pop3MaxMessages', 'pxm'],
|
|
['receivedMax', 'rl:rcpt']
|
|
]);
|
|
let flushKeys = [];
|
|
let flushHKeys = [];
|
|
|
|
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 === 'imapMaxConnections') {
|
|
flushHKeys.push({ key: 'lim:imap', value: user.toString() });
|
|
}
|
|
|
|
if (key === 'suspended' && data.suspended) {
|
|
// force logout after updates
|
|
wasSuspended = true;
|
|
}
|
|
|
|
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) {
|
|
let err = new Error('Nothing was updated');
|
|
err.code = 'NoUpdates';
|
|
throw err;
|
|
}
|
|
|
|
if ($set.password) {
|
|
try {
|
|
if (data.hashedPassword) {
|
|
// try if the hashing library can handle it?
|
|
await hashes.compare('whatever', $set.password);
|
|
// did not throw, so probably OK, no need to update `$set.password`
|
|
} else {
|
|
$set.password = await hashes.hash(data.password);
|
|
}
|
|
} catch (err) {
|
|
log.error('DB', 'HASHFAIL user.update id=%s error=%s', data.username, err.message);
|
|
err.code = 'HashError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{ _id: user },
|
|
{
|
|
projection: {
|
|
password: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!userData) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, 'User was not found');
|
|
let err = new Error('User was not found');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
// push current password to old passwords list on password change (and not temporary password)
|
|
if ($set.password && userData && userData.password) {
|
|
$push.oldPasswords = {
|
|
date: new Date(),
|
|
hash: userData.password
|
|
};
|
|
}
|
|
|
|
if (data.existingPassword && userData.password) {
|
|
let success;
|
|
try {
|
|
success = await hashes.compare(data.existingPassword, userData.password);
|
|
} catch (err) {
|
|
log.error('DB', 'HASHFAIL user.update id=%s error=%s', data.username, err.message);
|
|
err.code = err.code || 'HashError';
|
|
throw err;
|
|
}
|
|
|
|
if (!success) {
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'password change',
|
|
result: 'fail',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
let err = new Error('Password verification failed');
|
|
err.code = 'AuthFail';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let updateQuery = {
|
|
$set
|
|
};
|
|
|
|
if (Object.keys($push).length) {
|
|
updateQuery.$push = $push;
|
|
}
|
|
|
|
if (passwordChanged || wasSuspended) {
|
|
if (!updateQuery.$inc) {
|
|
updateQuery.$inc = {};
|
|
}
|
|
updateQuery.$inc.authVersion = 1;
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await this.users.collection('users').findOneAndUpdate(
|
|
{
|
|
_id: user
|
|
},
|
|
updateQuery,
|
|
{
|
|
returnOriginal: false,
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to update user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!result || !result.value) {
|
|
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, 'User was not found');
|
|
let err = new Error('user was not found');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
// check if we need to reset any ttl counters
|
|
if (flushKeys.length || flushHKeys.length) {
|
|
let flushreq = this.redis.multi();
|
|
|
|
flushKeys.forEach(key => {
|
|
flushreq = flushreq.del(key);
|
|
});
|
|
|
|
flushHKeys.forEach(entry => {
|
|
flushreq = flushreq.hdel(entry.key, entry.value);
|
|
});
|
|
|
|
// just call the operations and hope for the best, no problems if fails
|
|
try {
|
|
await flushreq.exec();
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
try {
|
|
await this.flushUserCache(user);
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
if (passwordChanged || wasSuspended) {
|
|
try {
|
|
if (passwordChanged) {
|
|
await this.logAuthEvent(user, {
|
|
action: 'password change',
|
|
result: 'success',
|
|
sess: data.sess,
|
|
ip: data.ip
|
|
});
|
|
}
|
|
|
|
await this.logout(user, 'Authentication required');
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return { success: true, passwordChanged };
|
|
}
|
|
|
|
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: []
|
|
}));
|
|
}
|
|
|
|
async logAuthEvent(user, entry) {
|
|
// 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 false;
|
|
}
|
|
|
|
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();
|
|
|
|
let r = await 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
|
|
}
|
|
);
|
|
|
|
return r && r.value && r.value._id;
|
|
}
|
|
|
|
async logout(user, reason) {
|
|
// register this address as the default address for that user
|
|
let userData;
|
|
try {
|
|
userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: new ObjectID(user)
|
|
},
|
|
{
|
|
projection: {
|
|
_id: true
|
|
},
|
|
maxTimeMS: consts.DB_MAX_TIME_USERS
|
|
}
|
|
);
|
|
} catch (err) {
|
|
log.error('DB', 'DBFAIL logout id=%s error=%s', user, err.message);
|
|
err.message = 'Database Error, failed to find user';
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
if (!userData) {
|
|
let err = new Error('User not found');
|
|
err.code = 'UserNotFound';
|
|
throw err;
|
|
}
|
|
|
|
if (!this.messageHandler || !this.messageHandler.notifier) {
|
|
return false;
|
|
}
|
|
|
|
this.messageHandler.notifier.fire(userData._id, {
|
|
command: 'LOGOUT',
|
|
reason
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
// This method deletes non-expiring records from database
|
|
async delete(user, meta) {
|
|
meta = meta || {};
|
|
|
|
// clear limits in Redis
|
|
try {
|
|
await this.redis.del('limits:' + user);
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
await this.users.collection('addresses').deleteMany({ user });
|
|
} catch (err) {
|
|
log.error('USERDEL', 'Failed to delete addresses for id=%s error=%s', user, err.message);
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await this.users.collection('users').deleteOne({ _id: user });
|
|
} catch (err) {
|
|
log.error('USERDEL', 'Failed to delete user id=%s error=%s', user, err.message);
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
// set up a task to delete user messages
|
|
let now = new Date();
|
|
await this.database.collection('tasks').insertOne({
|
|
task: 'user-delete',
|
|
locked: false,
|
|
lockedUntil: now,
|
|
created: now,
|
|
status: 'queued',
|
|
user
|
|
});
|
|
|
|
try {
|
|
await this.logAuthEvent(user, {
|
|
action: 'delete user',
|
|
result: 'success',
|
|
sess: meta.session,
|
|
ip: meta.ip
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async pushDefaultMessages(userData, tags) {
|
|
let messages = await tools.getEmailTemplates(tags);
|
|
if (!messages || !messages.length) {
|
|
return false;
|
|
}
|
|
|
|
let encryptMessage = util.promisify(this.messageHandler.encryptMessage.bind(this.messageHandler));
|
|
let addMessage = util.promisify(this.messageHandler.add.bind(this.messageHandler));
|
|
|
|
for (let data of messages) {
|
|
let compiler = new MailComposer(data);
|
|
let compiled = compiler.compile();
|
|
let build = util.promisify(compiled.build.bind(compiled));
|
|
|
|
try {
|
|
let message = await build();
|
|
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');
|
|
}
|
|
|
|
let encrypted = await encryptMessage(userData.encryptMessages ? userData.pubKey : false, message);
|
|
if (encrypted) {
|
|
message = encrypted;
|
|
}
|
|
|
|
await addMessage({
|
|
user: userData._id,
|
|
[mailboxQueryKey]: mailboxQueryValue,
|
|
meta: {
|
|
source: 'AUTO',
|
|
time: new Date()
|
|
},
|
|
flags,
|
|
raw: message
|
|
});
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
// returns a query to find a user based on address or username
|
|
async checkAddress(username) {
|
|
if (username.indexOf('@') < 0) {
|
|
// not formatted as an address, assume regular username
|
|
return {
|
|
unameview: tools.uview(username)
|
|
};
|
|
}
|
|
|
|
let addressData = await this.asyncResolveAddress(username, {
|
|
wildcard: false,
|
|
projection: {
|
|
user: true
|
|
}
|
|
});
|
|
|
|
if (addressData && !addressData.user) {
|
|
// found a non-user address
|
|
return false;
|
|
}
|
|
|
|
if (!addressData) {
|
|
// fall back to username formatted as an address
|
|
return {
|
|
unameview: tools.normalizeAddress(username, false, {
|
|
removeLabel: true,
|
|
removeDots: true
|
|
})
|
|
};
|
|
}
|
|
|
|
return {
|
|
_id: addressData.user
|
|
};
|
|
}
|
|
|
|
async setAuthToken(user, accessToken) {
|
|
let tokenHash = crypto.createHash('sha256').update(accessToken).digest('hex');
|
|
let key = 'tn:token:' + tokenHash;
|
|
let ttl = config.api.accessControl.tokenTTL || consts.ACCESS_TOKEN_DEFAULT_TTL;
|
|
|
|
let userData = await this.users.collection('users').findOne(
|
|
{
|
|
_id: new ObjectID(user)
|
|
},
|
|
{ projection: { authVersion: true } }
|
|
);
|
|
let authVersion = Number(userData && userData.authVersion) || 0;
|
|
|
|
let tokenData = {
|
|
user: user.toString(),
|
|
role: 'user',
|
|
created: Date.now(),
|
|
ttl,
|
|
authVersion,
|
|
// signature
|
|
s: crypto
|
|
.createHmac('sha256', config.api.accessControl.secret)
|
|
.update(
|
|
JSON.stringify({
|
|
token: accessToken,
|
|
user: user.toString(),
|
|
authVersion,
|
|
role: 'user'
|
|
})
|
|
)
|
|
.digest('hex')
|
|
};
|
|
|
|
await this.redis.multi().hmset(key, tokenData).expire(key, ttl).exec();
|
|
|
|
return accessToken;
|
|
}
|
|
|
|
async generateAuthToken(user) {
|
|
let accessToken = crypto.randomBytes(20).toString('hex');
|
|
return await this.setAuthToken(user, accessToken);
|
|
}
|
|
}
|
|
|
|
function rateLimitResponse(res) {
|
|
let err = new Error('Authentication was rate limited. Check again in ' + res.ttl + ' seconds');
|
|
err.response = 'NO';
|
|
err.code = 'RateLimitedError';
|
|
return 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;
|