Added authentication API endpoints

This commit is contained in:
Andris Reinman 2017-07-24 16:44:08 +03:00
parent cb0de3a15b
commit de98489a2c
7 changed files with 1881 additions and 454 deletions

1319
api.js

File diff suppressed because it is too large Load diff

View file

@ -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'
);
}

View file

@ -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:

View file

@ -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();
}

View file

@ -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
View file

@ -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;
}

View file

@ -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();
}