mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-09-19 03:24:55 +08:00
Added authentication API endpoints
This commit is contained in:
parent
cb0de3a15b
commit
de98489a2c
7 changed files with 1881 additions and 454 deletions
8
imap.js
8
imap.js
|
@ -363,9 +363,9 @@ module.exports = done => {
|
|||
err,
|
||||
tnx: 'mongo'
|
||||
},
|
||||
'Failed creating index %s %s. %s',
|
||||
'Failed creating index %s %s.%s. %s',
|
||||
indexpos,
|
||||
JSON.stringify(index.index.name),
|
||||
JSON.stringify(index.collection + '.' + index.index.name),
|
||||
err.message
|
||||
);
|
||||
} else if (r.numIndexesAfter !== r.numIndexesBefore) {
|
||||
|
@ -375,7 +375,7 @@ module.exports = done => {
|
|||
},
|
||||
'Created index %s %s',
|
||||
indexpos,
|
||||
JSON.stringify(index.index.name)
|
||||
JSON.stringify(index.collection + '.' + index.index.name)
|
||||
);
|
||||
} else {
|
||||
server.logger.debug(
|
||||
|
@ -384,7 +384,7 @@ module.exports = done => {
|
|||
},
|
||||
'Skipped index %s %s: %s',
|
||||
indexpos,
|
||||
JSON.stringify(index.index.name),
|
||||
JSON.stringify(index.collection + '.' + index.index.name),
|
||||
r.note || 'No index added'
|
||||
);
|
||||
}
|
||||
|
|
38
indexes.yaml
38
indexes.yaml
|
@ -33,6 +33,42 @@ indexes:
|
|||
key:
|
||||
user: 1
|
||||
|
||||
# Indexes for the application specific passwords collection
|
||||
|
||||
- collection: asps
|
||||
type: users # index applies to users database
|
||||
index:
|
||||
name: user
|
||||
key:
|
||||
user: 1
|
||||
|
||||
# Indexes for the authentication log collection
|
||||
|
||||
- collection: authlog
|
||||
type: users # index applies to users database
|
||||
index:
|
||||
name: user
|
||||
key:
|
||||
user: 1
|
||||
created: -1
|
||||
- collection: authlog
|
||||
type: users # index applies to users database
|
||||
index:
|
||||
name: entry_autoexpire
|
||||
# autoremove log entries after 30 days
|
||||
expireAfterSeconds: 2592000
|
||||
key:
|
||||
created: 1
|
||||
|
||||
# Indexes for the filters collection
|
||||
|
||||
- collection: filters
|
||||
type: users # index applies to users database
|
||||
index:
|
||||
name: user
|
||||
key:
|
||||
user: 1
|
||||
|
||||
# Indexes for the mailboxes collection
|
||||
|
||||
- collection: mailboxes
|
||||
|
@ -263,7 +299,7 @@ indexes:
|
|||
ids: 1
|
||||
- collection: threads
|
||||
index:
|
||||
name: threa_autoexpire
|
||||
name: thread_autoexpire
|
||||
# autoremove thread indexes after 180 days of inactivity
|
||||
expireAfterSeconds: 15552000
|
||||
key:
|
||||
|
|
|
@ -6,6 +6,7 @@ module.exports = (server, userHandler) => (login, session, callback) => {
|
|||
userHandler.authenticate(
|
||||
username,
|
||||
login.password,
|
||||
'imap',
|
||||
{
|
||||
protocol: 'IMAP',
|
||||
ip: session.remoteAddress
|
||||
|
@ -18,7 +19,7 @@ module.exports = (server, userHandler) => (login, session, callback) => {
|
|||
return callback();
|
||||
}
|
||||
|
||||
if (result.scope === 'master' && result.enabled2fa) {
|
||||
if (result.scope === 'master' && result.require2fa) {
|
||||
// master password not allowed if 2fa is enabled!
|
||||
return callback();
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ const tools = require('./tools');
|
|||
const consts = require('./consts');
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const generatePassword = require('generate-password');
|
||||
const base32 = require('base32.js');
|
||||
const os = require('os');
|
||||
|
||||
const mailboxTranslations = {
|
||||
|
@ -41,12 +40,15 @@ class UserHandler {
|
|||
*
|
||||
* @param {String} username Either username or email address
|
||||
*/
|
||||
authenticate(username, password, meta, callback) {
|
||||
authenticate(username, password, requiredScope, meta, callback) {
|
||||
if (!callback && typeof meta === 'function') {
|
||||
callback = meta;
|
||||
meta = {};
|
||||
}
|
||||
|
||||
meta = meta || {};
|
||||
meta.requiredScope = requiredScope;
|
||||
|
||||
if (!password) {
|
||||
// do not allow signing in without a password
|
||||
return callback(null, false);
|
||||
|
@ -74,7 +76,9 @@ class UserHandler {
|
|||
}
|
||||
|
||||
if (!addressData) {
|
||||
return callback(null, false);
|
||||
meta.address = address;
|
||||
meta.result = 'unknown';
|
||||
return this.logAuthEvent(null, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
return next(null, {
|
||||
|
@ -90,10 +94,10 @@ class UserHandler {
|
|||
|
||||
this.users.collection('users').findOne(query, {
|
||||
fields: {
|
||||
_id: true,
|
||||
username: true,
|
||||
password: true,
|
||||
enabled2fa: true,
|
||||
asp: true
|
||||
enabled2fa: true
|
||||
}
|
||||
}, (err, userData) => {
|
||||
if (err) {
|
||||
|
@ -101,60 +105,117 @@ class UserHandler {
|
|||
}
|
||||
|
||||
if (!userData) {
|
||||
return callback(null, false);
|
||||
if (query.username) {
|
||||
meta.username = query.username;
|
||||
} else {
|
||||
meta.user = query._id;
|
||||
}
|
||||
meta.result = 'unknown';
|
||||
return this.logAuthEvent(null, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
// try master password
|
||||
if (bcrypt.compareSync(password, userData.password || '')) {
|
||||
meta.scope = 'master';
|
||||
this.redis
|
||||
.multi()
|
||||
.zadd('wl:' + userData._id.toString(), Date.now(), JSON.stringify(meta))
|
||||
.zremrangebyscore('wl:' + userData._id.toString(), '-INF', Date.now() - 10 * 24 * 3600 * 1000)
|
||||
.expire('wl:' + userData._id.toString(), 10 * 24 * 3600)
|
||||
.exec(() => false);
|
||||
|
||||
return callback(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: 'master',
|
||||
enabled2fa: userData.enabled2fa
|
||||
});
|
||||
}
|
||||
|
||||
// try application specific passwords
|
||||
password = password.replace(/\s+/g, '').toLowerCase();
|
||||
if (!userData.asp || !userData.asp.length || !/^[a-z]{16}$/.test(password)) {
|
||||
// does not look like an application specific password
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
for (let i = 0; i < userData.asp.length; i++) {
|
||||
let asp = userData.asp[i];
|
||||
if (bcrypt.compareSync(password, asp.password || '')) {
|
||||
meta.scope = asp.id.toString();
|
||||
this.redis
|
||||
.multi()
|
||||
.zadd('wl:' + userData._id.toString(), Date.now(), JSON.stringify(meta))
|
||||
.zremrangebyscore('wl:' + userData._id.toString(), '-INF', Date.now() - 10 * 24 * 3600 * 1000)
|
||||
.expire('wl:' + userData._id.toString(), 10 * 24 * 3600)
|
||||
.exec(() => false);
|
||||
|
||||
return callback(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: 'application',
|
||||
enabled2fa: false // application scope never requires 2FA
|
||||
});
|
||||
bcrypt.compare(password, userData.password || '', (err, success) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (success) {
|
||||
meta.result = 'success';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () =>
|
||||
callback(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: 'master',
|
||||
// if 2FA is enabled then require token validation
|
||||
require2fa: !!userData.enabled2fa
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return callback(null, false);
|
||||
if (requiredScope === 'master') {
|
||||
// only master password can be used for management tasks
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
// try application specific passwords
|
||||
password = password.replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
if (!/^[a-z]{16}$/.test(password)) {
|
||||
// does not look like an application specific password
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
this.users
|
||||
.collection('asps')
|
||||
.find({
|
||||
user: userData._id
|
||||
})
|
||||
.toArray((err, asps) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!asps || !asps.length) {
|
||||
// user does not have app specific passwords set
|
||||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
let pos = 0;
|
||||
let checkNext = () => {
|
||||
if (pos >= asps.length) {
|
||||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
let asp = asps[pos++];
|
||||
|
||||
bcrypt.compare(password, asp.password || '', (err, success) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
return setImmediate(checkNext);
|
||||
}
|
||||
|
||||
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
|
||||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id.toString();
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(new Error('Authentication failed. Invalid scope')));
|
||||
}
|
||||
|
||||
meta.result = 'success';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id.toString();
|
||||
return this.logAuthEvent(userData._id, meta, () =>
|
||||
callback(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: requiredScope,
|
||||
asp: asp._id.toString(),
|
||||
require2fa: false // application scope never requires 2FA
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
checkNext();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
generateASP(data, callback) {
|
||||
generateASP(user, description, scopes, callback) {
|
||||
let password = generatePassword.generate({
|
||||
length: 16,
|
||||
uppercase: false,
|
||||
|
@ -162,31 +223,58 @@ class UserHandler {
|
|||
symbols: false
|
||||
});
|
||||
|
||||
let passwordEntry = {
|
||||
let allowedScopes = ['imap', 'pop3', 'smtp'];
|
||||
let hasAllScopes = false;
|
||||
let scopeSet = new Set();
|
||||
|
||||
(scopes || []).forEach(scope => {
|
||||
scope = scope.toLowerCase().trim();
|
||||
if (scope === '*') {
|
||||
hasAllScopes = true;
|
||||
} else {
|
||||
scopeSet.add(scope);
|
||||
}
|
||||
});
|
||||
if (hasAllScopes || scopeSet.size === allowedScopes.length) {
|
||||
scopes = ['*'];
|
||||
} else {
|
||||
scopes = Array.from(scopeSet).sort();
|
||||
}
|
||||
|
||||
let passwordData = {
|
||||
id: new ObjectID(),
|
||||
description: data.description,
|
||||
created: new Date(),
|
||||
password: bcrypt.hashSync(password, 11)
|
||||
user,
|
||||
description,
|
||||
scopes,
|
||||
password: bcrypt.hashSync(password, 11),
|
||||
created: new Date()
|
||||
};
|
||||
|
||||
// register this address as the default address for that user
|
||||
return this.users.collection('users').findOneAndUpdate({
|
||||
username: data.username
|
||||
return this.users.collection('users').findOne({
|
||||
_id: user
|
||||
}, {
|
||||
$push: {
|
||||
asp: passwordEntry
|
||||
fields: {
|
||||
_id: true
|
||||
}
|
||||
}, {}, (err, result) => {
|
||||
}, (err, userData) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', data.username, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
log.error('DB', 'DBFAIL generateASP id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to find user'));
|
||||
}
|
||||
if (!result || !result.value) {
|
||||
if (!userData) {
|
||||
return callback(new Error('User not found'));
|
||||
}
|
||||
|
||||
passwordEntry.password = password;
|
||||
return callback(null, passwordEntry);
|
||||
this.users.collection('asps').insertOne(passwordData, err => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
callback(null, {
|
||||
id: passwordData._id,
|
||||
password
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -205,9 +293,6 @@ class UserHandler {
|
|||
|
||||
if (userData) {
|
||||
let err = new Error('This username already exists');
|
||||
err.fields = {
|
||||
username: err.message
|
||||
};
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
|
@ -238,8 +323,6 @@ class UserHandler {
|
|||
recipients: data.recipients || 0,
|
||||
forwards: data.forwards || 0,
|
||||
|
||||
filters: [],
|
||||
|
||||
// default retention for user mailboxes
|
||||
retention: data.retention || 0,
|
||||
|
||||
|
@ -247,7 +330,9 @@ class UserHandler {
|
|||
|
||||
// until setup value is not true, this account is not usable
|
||||
activated: false,
|
||||
disabled: true
|
||||
disabled: true,
|
||||
|
||||
ip: data.ip
|
||||
}, err => {
|
||||
if (err) {
|
||||
log.error('DB', 'CREATEFAIL username=%s error=%s', data.username, err.message);
|
||||
|
@ -365,50 +450,35 @@ class UserHandler {
|
|||
});
|
||||
}
|
||||
|
||||
setup2fa(username, issuer, callback) {
|
||||
setup2fa(user, data, callback) {
|
||||
return this.users.collection('users').findOne({
|
||||
username
|
||||
_id: user
|
||||
}, {
|
||||
fields: {
|
||||
enabled2fa: true,
|
||||
seed: true
|
||||
username: true,
|
||||
enabled2fa: true
|
||||
}
|
||||
}, (err, entry) => {
|
||||
}, (err, userData) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message);
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to check user'));
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
if (!userData) {
|
||||
return callback(new Error('Could not find user data'));
|
||||
}
|
||||
|
||||
if (entry.enabled2fa) {
|
||||
if (userData.enabled2fa) {
|
||||
return callback(new Error('2FA is already enabled for this user'));
|
||||
}
|
||||
|
||||
if (entry.seed) {
|
||||
let otpauth_url = speakeasy.otpauthURL({
|
||||
secret: base32.decode(entry.seed),
|
||||
label: username,
|
||||
issuer
|
||||
});
|
||||
return QRCode.toDataURL(otpauth_url, (err, data_url) => {
|
||||
if (err) {
|
||||
log.error('DB', 'QRFAIL username=%s error=%s', username, err.message);
|
||||
return callback(new Error('Failed to generate QR code'));
|
||||
}
|
||||
return callback(null, data_url);
|
||||
});
|
||||
}
|
||||
|
||||
let secret = speakeasy.generateSecret({
|
||||
length: 20,
|
||||
name: username
|
||||
name: userData.username
|
||||
});
|
||||
|
||||
return this.users.collection('users').findOneAndUpdate({
|
||||
username,
|
||||
_id: user,
|
||||
enabled2fa: false
|
||||
}, {
|
||||
$set: {
|
||||
|
@ -416,7 +486,7 @@ class UserHandler {
|
|||
}
|
||||
}, {}, (err, result) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message);
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
}
|
||||
|
||||
|
@ -426,55 +496,112 @@ class UserHandler {
|
|||
|
||||
let otpauth_url = speakeasy.otpauthURL({
|
||||
secret: secret.ascii,
|
||||
label: username,
|
||||
issuer
|
||||
label: userData.username,
|
||||
issuer: data.issuer || 'Wild Duck'
|
||||
});
|
||||
|
||||
QRCode.toDataURL(otpauth_url, (err, data_url) => {
|
||||
if (err) {
|
||||
log.error('DB', 'QRFAIL username=%s error=%s', username, err.message);
|
||||
log.error('DB', 'QRFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Failed to generate QR code'));
|
||||
}
|
||||
return callback(null, data_url);
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: 'new 2fa seed',
|
||||
ip: data.ip
|
||||
},
|
||||
() => callback(null, data_url)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
enable2fa(username, userToken, callback) {
|
||||
this.check2fa(username, userToken, (err, verified) => {
|
||||
enable2fa(user, data, callback) {
|
||||
this.users.collection('users').findOne({
|
||||
_id: user
|
||||
}, {
|
||||
fields: {
|
||||
enabled2fa: true,
|
||||
username: true,
|
||||
seed: true
|
||||
}
|
||||
}, (err, userData) => {
|
||||
if (err) {
|
||||
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
}
|
||||
if (!userData) {
|
||||
let err = new Error('This username does not exist');
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!userData.seed) {
|
||||
// 2fa not set up
|
||||
let err = new Error('2FA is not initialized for this user');
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (userData.enabled2fa) {
|
||||
// 2fa not set up
|
||||
let err = new Error('2FA is already enabled for this user');
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let verified = speakeasy.totp.verify({
|
||||
secret: userData.seed,
|
||||
encoding: 'base32',
|
||||
token: data.token,
|
||||
window: 6
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return callback(null, false);
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: 'enable 2fa',
|
||||
result: 'fail',
|
||||
ip: data.ip
|
||||
},
|
||||
() => callback(null, false)
|
||||
);
|
||||
}
|
||||
|
||||
// token was valid, update user settings
|
||||
return this.users.collection('users').findOneAndUpdate({
|
||||
username
|
||||
_id: user,
|
||||
seed: userData.seed
|
||||
}, {
|
||||
$set: {
|
||||
enabled2fa: true
|
||||
}
|
||||
}, {}, (err, result) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message);
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
}
|
||||
|
||||
if (!result || !result.value) {
|
||||
return callback(new Error('Could not update user, check if 2FA is not already enabled'));
|
||||
return callback(new Error('Failed to set up 2FA. Check if it is not already enabled'));
|
||||
}
|
||||
|
||||
return callback(null, true);
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: 'enable 2fa',
|
||||
result: 'success',
|
||||
ip: data.ip
|
||||
},
|
||||
() => callback(null, true)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disable2fa(username, callback) {
|
||||
disable2fa(user, data, callback) {
|
||||
return this.users.collection('users').findOneAndUpdate({
|
||||
username
|
||||
_id: user
|
||||
}, {
|
||||
$set: {
|
||||
enabled2fa: false,
|
||||
|
@ -482,7 +609,7 @@ class UserHandler {
|
|||
}
|
||||
}, {}, (err, result) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', username, err.message);
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
}
|
||||
|
||||
|
@ -490,13 +617,20 @@ class UserHandler {
|
|||
return callback(new Error('Could not update user, check if 2FA is not already disabled'));
|
||||
}
|
||||
|
||||
return callback(null, true);
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: 'disable 2fa',
|
||||
ip: data.ip
|
||||
},
|
||||
() => callback(null, true)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
check2fa(username, userToken, callback) {
|
||||
check2fa(user, data, callback) {
|
||||
this.users.collection('users').findOne({
|
||||
username
|
||||
_id: user
|
||||
}, {
|
||||
fields: {
|
||||
username: true,
|
||||
|
@ -504,14 +638,11 @@ class UserHandler {
|
|||
}
|
||||
}, (err, userData) => {
|
||||
if (err) {
|
||||
log.error('DB', 'LOADFAIL username=%s error=%s', username, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to find user'));
|
||||
}
|
||||
if (!userData) {
|
||||
let err = new Error('This username does not exist');
|
||||
err.fields = {
|
||||
username: err.message
|
||||
};
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
|
@ -523,86 +654,107 @@ class UserHandler {
|
|||
let verified = speakeasy.totp.verify({
|
||||
secret: userData.seed,
|
||||
encoding: 'base32',
|
||||
token: userToken,
|
||||
token: data.token,
|
||||
window: 6
|
||||
});
|
||||
|
||||
return callback(null, verified);
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: '2fa',
|
||||
ip: data.ip,
|
||||
result: verified ? 'success' : 'fail'
|
||||
},
|
||||
() => callback(null, verified)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
update(data, callback) {
|
||||
let query = {
|
||||
username: data.username
|
||||
};
|
||||
update(user, data, callback) {
|
||||
let $set = {};
|
||||
let updates = false;
|
||||
let passwordChanged = false;
|
||||
|
||||
if (data.resetPassword) {
|
||||
query.requirePasswordChange = true;
|
||||
Object.keys(data).forEach(key => {
|
||||
if (['user', 'existingPassword', 'ip'].includes(key)) {
|
||||
return;
|
||||
}
|
||||
if (key === 'password') {
|
||||
$set.password = bcrypt.hashSync(data[key], 11);
|
||||
$set.passwordChange = new Date();
|
||||
passwordChanged = true;
|
||||
return;
|
||||
}
|
||||
$set[key] = data[key];
|
||||
updates = true;
|
||||
});
|
||||
|
||||
if (!updates) {
|
||||
return callback(new Error('Nothing was updated'));
|
||||
}
|
||||
|
||||
this.users.collection('users').findOne(query, {
|
||||
fields: {
|
||||
username: true,
|
||||
password: true
|
||||
let verifyExistingPassword = next => {
|
||||
if (!data.existingPassword) {
|
||||
return next();
|
||||
}
|
||||
}, (err, userData) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', data.username, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
}
|
||||
if (!userData) {
|
||||
let err = new Error('This username does not exist');
|
||||
err.fields = {
|
||||
username: err.message
|
||||
};
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!data.resetPassword && data.oldpassword && !bcrypt.compareSync(data.oldpassword, userData.password || '')) {
|
||||
let err = new Error('Password does not match');
|
||||
err.fields = {
|
||||
oldpassword: err.message
|
||||
};
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let update = {};
|
||||
|
||||
if (data.hasOwnProperty('name')) {
|
||||
update.name = data.name || '';
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('forward')) {
|
||||
update.forward = data.forward || '';
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('targetUrl')) {
|
||||
update.targetUrl = data.targetUrl || '';
|
||||
}
|
||||
|
||||
if (data.hasOwnProperty('autoreply')) {
|
||||
update.autoreply = data.autoreply || '';
|
||||
}
|
||||
|
||||
if (data.password) {
|
||||
update.password = bcrypt.hashSync(data.password, 11);
|
||||
}
|
||||
|
||||
if (data.resetPassword) {
|
||||
update.requirePasswordChange = false;
|
||||
}
|
||||
|
||||
return this.users.collection('users').findOneAndUpdate({
|
||||
_id: userData._id
|
||||
}, {
|
||||
$set: update
|
||||
}, {}, err => {
|
||||
this.users.collection('users').findOne({ _id: user }, { fields: { password: true } }, (err, userData) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL username=%s error=%s', data.username, err.message);
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to find user'));
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, 'User was not found');
|
||||
return callback(new Error('User was not found'));
|
||||
}
|
||||
|
||||
if (bcrypt.compareSync(data.existingPassword, userData.password || '')) {
|
||||
return next();
|
||||
} else {
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: 'password change',
|
||||
result: 'fail',
|
||||
ip: data.ip
|
||||
},
|
||||
() => callback(new Error('Password verification failed'))
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
verifyExistingPassword(() => {
|
||||
this.users.collection('users').findOneAndUpdate({
|
||||
_id: user
|
||||
}, {
|
||||
$set
|
||||
}, {
|
||||
returnOriginal: false
|
||||
}, (err, result) => {
|
||||
if (err) {
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, err.message);
|
||||
return callback(new Error('Database Error, failed to update user'));
|
||||
}
|
||||
|
||||
return callback(null, userData._id);
|
||||
if (!result || !result.value) {
|
||||
log.error('DB', 'UPDATEFAIL id=%s error=%s', user, 'User was not found');
|
||||
return callback(new Error('user was not found'));
|
||||
}
|
||||
|
||||
if (passwordChanged) {
|
||||
return this.logAuthEvent(
|
||||
user,
|
||||
{
|
||||
action: 'password change',
|
||||
result: 'success',
|
||||
ip: data.ip
|
||||
},
|
||||
() => callback(null, true)
|
||||
);
|
||||
} else {
|
||||
return callback(null, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -643,6 +795,15 @@ class UserHandler {
|
|||
flags: []
|
||||
}));
|
||||
}
|
||||
|
||||
logAuthEvent(user, meta, callback) {
|
||||
if (user) {
|
||||
meta.user = user;
|
||||
}
|
||||
meta.action = meta.action || 'authentication';
|
||||
meta.created = new Date();
|
||||
this.users.collection('authlog').insertOne(meta, callback);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserHandler;
|
||||
|
|
435
lmtp.js
435
lmtp.js
|
@ -70,7 +70,6 @@ const serverOptions = {
|
|||
}, {
|
||||
fields: {
|
||||
name: true,
|
||||
filters: true,
|
||||
forwards: true,
|
||||
forward: true,
|
||||
targetUrl: true,
|
||||
|
@ -161,235 +160,241 @@ const serverOptions = {
|
|||
let mailboxQueryKey = 'path';
|
||||
let mailboxQueryValue = 'INBOX';
|
||||
|
||||
let filters = (user.filters || []).concat(
|
||||
spamHeader
|
||||
? {
|
||||
id: 'SPAM',
|
||||
query: {
|
||||
headers: {
|
||||
[spamHeader]: 'Yes'
|
||||
}
|
||||
},
|
||||
action: {
|
||||
// only applies if any other filter does not already mark message as spam or ham
|
||||
spam: true
|
||||
}
|
||||
}
|
||||
: []
|
||||
);
|
||||
|
||||
let forwardTargets = new Set();
|
||||
let forwardTargetUrls = new Set();
|
||||
let matchingFilters = [];
|
||||
let filterActions = new Map();
|
||||
|
||||
filters
|
||||
// apply all filters to the message
|
||||
.map(filter => checkFilter(filter, prepared, maildata))
|
||||
// remove all unmatched filers
|
||||
.filter(filter => filter)
|
||||
// apply filter actions
|
||||
.forEach(filter => {
|
||||
matchingFilters.push(filter.id);
|
||||
|
||||
// apply matching filter
|
||||
if (!filterActions) {
|
||||
filterActions = filter.action;
|
||||
} else {
|
||||
Object.keys(filter.action).forEach(key => {
|
||||
if (key === 'forward') {
|
||||
forwardTargets.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'targetUrl') {
|
||||
forwardTargetUrls.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// if a previous filter already has set a value then do not touch it
|
||||
if (!filterActions.has(key)) {
|
||||
filterActions.set(key, filter.action[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let forwardMessage = done => {
|
||||
if (user.forward && !filterActions.get('delete')) {
|
||||
// forward to default recipient only if the message is not deleted
|
||||
forwardTargets.add(user.forward);
|
||||
}
|
||||
|
||||
if (user.targetUrl && !filterActions.get('delete')) {
|
||||
// forward to default URL only if the message is not deleted
|
||||
forwardTargetUrls.add(user.targetUrl);
|
||||
}
|
||||
|
||||
// never forward messages marked as spam
|
||||
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
// check limiting counters
|
||||
messageHandler.counters.ttlcounter(
|
||||
'wdf:' + user._id.toString(),
|
||||
forwardTargets.size + forwardTargetUrls.size,
|
||||
user.forwards,
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
// failed checks
|
||||
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), err.message);
|
||||
} else if (!result.success) {
|
||||
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), 'Precondition failed');
|
||||
return done();
|
||||
}
|
||||
|
||||
forward(
|
||||
{
|
||||
user,
|
||||
sender,
|
||||
recipient,
|
||||
|
||||
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
|
||||
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
|
||||
|
||||
chunks
|
||||
},
|
||||
done
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
let sendAutoreply = done => {
|
||||
// never reply to messages marked as spam
|
||||
if (!sender || !user.autoreply || !user.autoreply.status || !user.autoreply.message || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
autoreply(
|
||||
{
|
||||
user,
|
||||
sender,
|
||||
recipient,
|
||||
chunks,
|
||||
messageHandler
|
||||
},
|
||||
done
|
||||
);
|
||||
};
|
||||
|
||||
forwardMessage((err, id) => {
|
||||
db.database.collection('filters').find({ user: user._id }).sort({ _id: 1 }).toArray((err, filters) => {
|
||||
if (err) {
|
||||
log.error(
|
||||
'LMTP',
|
||||
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
|
||||
prepared.id.toString(),
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets).concat(forwardTargetUrls).join(','),
|
||||
err.message
|
||||
);
|
||||
} else if (id) {
|
||||
log.silly(
|
||||
'LMTP',
|
||||
'%s FRWRDOK id=%s from=%s to=%s target=%s',
|
||||
prepared.id.toString(),
|
||||
id,
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets).concat(forwardTargetUrls).join(',')
|
||||
);
|
||||
// ignore, as filtering is not so important
|
||||
}
|
||||
// append generic spam header check to the filters
|
||||
filters = (filters || []).concat(
|
||||
spamHeader
|
||||
? {
|
||||
id: 'SPAM',
|
||||
query: {
|
||||
headers: {
|
||||
[spamHeader]: 'Yes'
|
||||
}
|
||||
},
|
||||
action: {
|
||||
// only applies if any other filter does not already mark message as spam or ham
|
||||
spam: true
|
||||
}
|
||||
}
|
||||
: []
|
||||
);
|
||||
|
||||
sendAutoreply((err, id) => {
|
||||
if (err) {
|
||||
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
|
||||
} else if (id) {
|
||||
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
|
||||
}
|
||||
let forwardTargets = new Set();
|
||||
let forwardTargetUrls = new Set();
|
||||
let matchingFilters = [];
|
||||
let filterActions = new Map();
|
||||
|
||||
if (filterActions.get('delete')) {
|
||||
// nothing to do with the message, just continue
|
||||
responses.push({
|
||||
user,
|
||||
response: 'Message dropped by policy as ' + prepared.id.toString()
|
||||
});
|
||||
prepared = false;
|
||||
maildata = false;
|
||||
return storeNext();
|
||||
}
|
||||
filters
|
||||
// apply all filters to the message
|
||||
.map(filter => checkFilter(filter, prepared, maildata))
|
||||
// remove all unmatched filters
|
||||
.filter(filter => filter)
|
||||
// apply filter actions
|
||||
.forEach(filter => {
|
||||
matchingFilters.push(filter.id);
|
||||
|
||||
// apply filter results to the message
|
||||
filterActions.forEach((value, key) => {
|
||||
switch (key) {
|
||||
case 'spam':
|
||||
if (value > 0) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'specialUse';
|
||||
mailboxQueryValue = '\\Junk';
|
||||
// apply matching filter
|
||||
if (!filterActions) {
|
||||
filterActions = filter.action;
|
||||
} else {
|
||||
Object.keys(filter.action).forEach(key => {
|
||||
if (key === 'forward') {
|
||||
forwardTargets.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'seen':
|
||||
if (value) {
|
||||
flags.push('\\Seen');
|
||||
|
||||
if (key === 'targetUrl') {
|
||||
forwardTargetUrls.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'flag':
|
||||
if (value) {
|
||||
flags.push('\\Flagged');
|
||||
|
||||
// if a previous filter already has set a value then do not touch it
|
||||
if (!filterActions.has(key)) {
|
||||
filterActions.set(key, filter.action[key]);
|
||||
}
|
||||
break;
|
||||
case 'mailbox':
|
||||
if (value) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'mailbox';
|
||||
mailboxQueryValue = value;
|
||||
}
|
||||
break;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let messageOptions = {
|
||||
user: (user && user._id) || user,
|
||||
[mailboxQueryKey]: mailboxQueryValue,
|
||||
let forwardMessage = done => {
|
||||
if (user.forward && !filterActions.get('delete')) {
|
||||
// forward to default recipient only if the message is not deleted
|
||||
forwardTargets.add(user.forward);
|
||||
}
|
||||
|
||||
prepared,
|
||||
maildata,
|
||||
if (user.targetUrl && !filterActions.get('delete')) {
|
||||
// forward to default URL only if the message is not deleted
|
||||
forwardTargetUrls.add(user.targetUrl);
|
||||
}
|
||||
|
||||
meta: {
|
||||
source: 'LMTP',
|
||||
from: sender,
|
||||
to: recipient,
|
||||
origin: session.remoteAddress,
|
||||
originhost: session.clientHostname,
|
||||
transhost: session.hostNameAppearsAs,
|
||||
transtype: session.transmissionType,
|
||||
time: Date.now()
|
||||
},
|
||||
// never forward messages marked as spam
|
||||
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
filters: matchingFilters,
|
||||
// check limiting counters
|
||||
messageHandler.counters.ttlcounter(
|
||||
'wdf:' + user._id.toString(),
|
||||
forwardTargets.size + forwardTargetUrls.size,
|
||||
user.forwards,
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
// failed checks
|
||||
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), err.message);
|
||||
} else if (!result.success) {
|
||||
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), 'Precondition failed');
|
||||
return done();
|
||||
}
|
||||
|
||||
date: false,
|
||||
flags,
|
||||
forward(
|
||||
{
|
||||
user,
|
||||
sender,
|
||||
recipient,
|
||||
|
||||
// if similar message exists, then skip
|
||||
skipExisting: true
|
||||
};
|
||||
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
|
||||
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
|
||||
|
||||
messageHandler.add(messageOptions, (err, inserted, info) => {
|
||||
// remove Delivered-To
|
||||
chunks.shift();
|
||||
chunklen -= header.length;
|
||||
chunks
|
||||
},
|
||||
done
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// push to response list
|
||||
responses.push({
|
||||
let sendAutoreply = done => {
|
||||
// never reply to messages marked as spam
|
||||
if (!sender || !user.autoreply || !user.autoreply.status || !user.autoreply.message || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
autoreply(
|
||||
{
|
||||
user,
|
||||
response: err ? err : 'Message stored as ' + info.id.toString()
|
||||
sender,
|
||||
recipient,
|
||||
chunks,
|
||||
messageHandler
|
||||
},
|
||||
done
|
||||
);
|
||||
};
|
||||
|
||||
forwardMessage((err, id) => {
|
||||
if (err) {
|
||||
log.error(
|
||||
'LMTP',
|
||||
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
|
||||
prepared.id.toString(),
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets).concat(forwardTargetUrls).join(','),
|
||||
err.message
|
||||
);
|
||||
} else if (id) {
|
||||
log.silly(
|
||||
'LMTP',
|
||||
'%s FRWRDOK id=%s from=%s to=%s target=%s',
|
||||
prepared.id.toString(),
|
||||
id,
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets).concat(forwardTargetUrls).join(',')
|
||||
);
|
||||
}
|
||||
|
||||
sendAutoreply((err, id) => {
|
||||
if (err) {
|
||||
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
|
||||
} else if (id) {
|
||||
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
|
||||
}
|
||||
|
||||
if (filterActions.get('delete')) {
|
||||
// nothing to do with the message, just continue
|
||||
responses.push({
|
||||
user,
|
||||
response: 'Message dropped by policy as ' + prepared.id.toString()
|
||||
});
|
||||
prepared = false;
|
||||
maildata = false;
|
||||
return storeNext();
|
||||
}
|
||||
|
||||
// apply filter results to the message
|
||||
filterActions.forEach((value, key) => {
|
||||
switch (key) {
|
||||
case 'spam':
|
||||
if (value > 0) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'specialUse';
|
||||
mailboxQueryValue = '\\Junk';
|
||||
}
|
||||
break;
|
||||
case 'seen':
|
||||
if (value) {
|
||||
flags.push('\\Seen');
|
||||
}
|
||||
break;
|
||||
case 'flag':
|
||||
if (value) {
|
||||
flags.push('\\Flagged');
|
||||
}
|
||||
break;
|
||||
case 'mailbox':
|
||||
if (value) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'mailbox';
|
||||
mailboxQueryValue = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
storeNext();
|
||||
let messageOptions = {
|
||||
user: (user && user._id) || user,
|
||||
[mailboxQueryKey]: mailboxQueryValue,
|
||||
|
||||
prepared,
|
||||
maildata,
|
||||
|
||||
meta: {
|
||||
source: 'LMTP',
|
||||
from: sender,
|
||||
to: recipient,
|
||||
origin: session.remoteAddress,
|
||||
originhost: session.clientHostname,
|
||||
transhost: session.hostNameAppearsAs,
|
||||
transtype: session.transmissionType,
|
||||
time: Date.now()
|
||||
},
|
||||
|
||||
filters: matchingFilters,
|
||||
|
||||
date: false,
|
||||
flags,
|
||||
|
||||
// if similar message exists, then skip
|
||||
skipExisting: true
|
||||
};
|
||||
|
||||
messageHandler.add(messageOptions, (err, inserted, info) => {
|
||||
// remove Delivered-To
|
||||
chunks.shift();
|
||||
chunklen -= header.length;
|
||||
|
||||
// push to response list
|
||||
responses.push({
|
||||
user,
|
||||
response: err ? err : 'Message stored as ' + info.id.toString()
|
||||
});
|
||||
|
||||
storeNext();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -466,14 +471,14 @@ function checkFilter(filter, prepared, maildata) {
|
|||
}
|
||||
}
|
||||
|
||||
if (query.ha) {
|
||||
if (typeof query.ha === 'boolean') {
|
||||
let hasAttachments = maildata.attachments && maildata.attachments.length;
|
||||
// negative ha means no attachmens
|
||||
if (hasAttachments && query.ha < 0) {
|
||||
// false ha means no attachmens
|
||||
if (hasAttachments && !query.ha) {
|
||||
return false;
|
||||
}
|
||||
// positive ha means attachmens must exist
|
||||
if (!hasAttachments && query.ha > 0) {
|
||||
// true ha means attachmens must exist
|
||||
if (!hasAttachments && query.ha) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -490,7 +495,7 @@ function checkFilter(filter, prepared, maildata) {
|
|||
}
|
||||
}
|
||||
|
||||
if (query.text && maildata.text.toLowerCase().indexOf(query.text.toLowerCase()) < 0) {
|
||||
if (query.text && maildata.text.toLowerCase().replace(/\s+/g, ' ').indexOf(query.text.toLowerCase()) < 0) {
|
||||
// message plaintext does not match the text field value
|
||||
return false;
|
||||
}
|
||||
|
|
3
pop3.js
3
pop3.js
|
@ -39,6 +39,7 @@ const serverOptions = {
|
|||
userHandler.authenticate(
|
||||
auth.username,
|
||||
auth.password,
|
||||
'pop3',
|
||||
{
|
||||
protocol: 'POP3',
|
||||
ip: session.remoteAddress
|
||||
|
@ -51,7 +52,7 @@ const serverOptions = {
|
|||
return callback();
|
||||
}
|
||||
|
||||
if (result.scope === 'master' && result.enabled2fa) {
|
||||
if (result.scope === 'master' && result.require2fa) {
|
||||
// master password not allowed if 2fa is enabled!
|
||||
return callback();
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue