This commit is contained in:
Andris Reinman 2019-04-05 15:08:46 +03:00
parent e5919187a4
commit feda41d58b
11 changed files with 281 additions and 133 deletions

23
api.js
View file

@ -12,6 +12,7 @@ const ImapNotifier = require('./lib/imap-notifier');
const db = require('./lib/db'); const db = require('./lib/db');
const certs = require('./lib/certs'); const certs = require('./lib/certs');
const tools = require('./lib/tools'); const tools = require('./lib/tools');
const consts = require('./lib/consts');
const crypto = require('crypto'); const crypto = require('crypto');
const Gelf = require('gelf'); const Gelf = require('gelf');
const os = require('os'); const os = require('os');
@ -156,7 +157,7 @@ server.use((req, res, next) => {
server.use(restify.plugins.gzipResponse()); server.use(restify.plugins.gzipResponse());
server.use(restify.plugins.queryParser()); server.use(restify.plugins.queryParser({ allowDots: true }));
server.use( server.use(
restify.plugins.bodyParser({ restify.plugins.bodyParser({
maxBodySize: 0, maxBodySize: 0,
@ -240,19 +241,18 @@ server.use(
.digest('hex'); .digest('hex');
if (signature !== tokenData.s) { if (signature !== tokenData.s) {
// rogue token // rogue token or invalidated secret
try { try {
await db.redis await db.redis
.multi() .multi()
.del('tn:token:' + tokenHash) .del('tn:token:' + tokenHash)
.srem('tn:user:' + tokenData.user, tokenHash)
.exec(); .exec();
} catch (err) { } catch (err) {
// ignore // ignore
} }
} else if (tokenData.ttl && isNaN(tokenData.ttl) && Number(tokenData.ttl) > 0) { } else if (tokenData.ttl && isNaN(tokenData.ttl) && Number(tokenData.ttl) > 0) {
let tokenTTL = Number(tokenData.ttl); let tokenTTL = Number(tokenData.ttl);
let tokenLifetime = config.api.accessControl.tokenLifetime || 30 * 24 * 3600; let tokenLifetime = config.api.accessControl.tokenLifetime || consts.ACCESS_TOKEN_MAX_LIFETIME;
// check if token is not too old // check if token is not too old
if (tokenLifetime < (Date.now() - Number(tokenData.created)) / 1000) { if (tokenLifetime < (Date.now() - Number(tokenData.created)) / 1000) {
@ -261,13 +261,26 @@ server.use(
await db.redis await db.redis
.multi() .multi()
.expire('tn:token:' + tokenHash, tokenTTL) .expire('tn:token:' + tokenHash, tokenTTL)
.expire('tn:user:' + tokenData.user, tokenTTL)
.exec(); .exec();
} catch (err) { } catch (err) {
// ignore // ignore
} }
req.role = tokenData.role; req.role = tokenData.role;
req.user = tokenData.user; req.user = tokenData.user;
req.accessToken = {
hash: tokenHash,
user: tokenData.user
};
} else {
// expired token, clear it
try {
await db.redis
.multi()
.del('tn:token:' + tokenHash)
.exec();
} catch (err) {
// ignore
}
} }
} else { } else {
req.role = tokenData.role; req.role = tokenData.role;

View file

@ -12,11 +12,19 @@ secure=false
[accessControl] [accessControl]
# If true then require a valid access token to perform API calls # If true then require a valid access token to perform API calls
# If a client provides a token then it is validated even if using a token is not required
enabled=false enabled=false
# Secret for HMAC # Secret for HMAC
# Changing this value invalidates all tokens # Changing this value invalidates all tokens
secret="a secret cat" secret="a secret cat"
# Generated access token TTL in seconds. Token TTL gets extended by this value every time the token is used. Defaults to 14 days
#tokenTTL=1209600
# Generated access token max lifetime in seconds. Defaults to 180 days
#tokenLifetime=15552000
[roles] [roles]
# @include "roles.json" # @include "roles.json"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2019-03-26T14:41:36.494Z", "url": "http://apidocjs.com", "version": "0.17.7" } }); define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2019-04-05T12:08:29.034Z", "url": "http://apidocjs.com", "version": "0.17.7" } });

View file

@ -1 +1 @@
{ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2019-03-26T14:41:36.494Z", "url": "http://apidocjs.com", "version": "0.17.7" } } { "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2019-04-05T12:08:29.034Z", "url": "http://apidocjs.com", "version": "0.17.7" } }

View file

@ -196,7 +196,7 @@ module.exports = (db, server, userHandler) => {
} catch (err) { } catch (err) {
let response = { let response = {
error: err.message, error: err.message,
code: 'AuthFailed' || err.code, code: err.code || 'AuthFailed',
id: user.toString() id: user.toString()
}; };
res.json(response); res.json(response);
@ -213,6 +213,54 @@ module.exports = (db, server, userHandler) => {
}) })
); );
/**
* @api {delete} /authenticate Invalidate authentication token
* @apiName DeleteAuth
* @apiGroup Authentication
* @apiDescription This method invalidates currently used authentication token. If token is not provided then nothing happens
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiSuccess {Boolean} success Indicates successful response
*
* @apiError error Description of the error
* @apiError [code] Error code
*
* @apiExample {curl} Example usage:
* curl -i -XDELETE "http://localhost:8080/authenticate" \
* -H 'X-Access-Token: 59fc66a03e54454869460e45'
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
*/
server.del(
'/authenticate',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
if (req.accessToken) {
try {
await db.redis
.multi()
.del('tn:token:' + req.accessToken.hash)
.exec();
} catch (err) {
// ignore
}
}
res.json({ success: true });
return next();
})
);
/** /**
* @api {get} /users/:user/authlog List authentication Events * @api {get} /users/:user/authlog List authentication Events
* @apiName GetAuthlog * @apiName GetAuthlog

View file

@ -553,6 +553,11 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
* @apiParam {Boolean} [attachments] If true, then matches only messages with attachments * @apiParam {Boolean} [attachments] If true, then matches only messages with attachments
* @apiParam {Boolean} [flagged] If true, then matches only messages with \Flagged flags * @apiParam {Boolean} [flagged] If true, then matches only messages with \Flagged flags
* @apiParam {Boolean} [searchable] If true, then matches messages not in Junk or Trash * @apiParam {Boolean} [searchable] If true, then matches messages not in Junk or Trash
* @apiParam {Object} [or] Allows to specify some requests as OR (default is AND). At least one of the values in or block must match
* @apiParam {String} [or.query] Search string, uses MongoDB fulltext index. Covers data from mesage body and also common headers like from, to, subject etc.
* @apiParam {String} [or.from] Partial match for the From: header line
* @apiParam {String} [or.to] Partial match for the To: and Cc: header lines
* @apiParam {String} [or.subject] Partial match for the Subject: header line
* @apiParam {Number} [limit=20] How many records to return * @apiParam {Number} [limit=20] How many records to return
* @apiParam {Number} [page=1] Current page number. Informational only, page numbers start from 1 * @apiParam {Number} [page=1] Current page number. Informational only, page numbers start from 1
* @apiParam {Number} [next] Cursor value for next page, retrieved from <code>nextCursor</code> response value * @apiParam {Number} [next] Cursor value for next page, retrieved from <code>nextCursor</code> response value
@ -596,6 +601,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
* @apiExample {curl} Example usage: * @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/search?query=Ryan" * curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/search?query=Ryan"
* *
* @apiExample {curl} Using OR:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/search?or.from=Ryan&or.to=Ryan"
*
* @apiSuccessExample {json} Success-Response: * @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK * HTTP/1.1 200 OK
* { * {
@ -659,6 +667,23 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
.hex() .hex()
.length(24) .length(24)
.empty(''), .empty(''),
or: Joi.object().keys({
query: Joi.string()
.trim()
.max(255)
.empty(''),
from: Joi.string()
.trim()
.empty(''),
to: Joi.string()
.trim()
.empty(''),
subject: Joi.string()
.trim()
.empty('')
}),
query: Joi.string() query: Joi.string()
.trim() .trim()
.max(255) .max(255)
@ -736,6 +761,10 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
let user = new ObjectID(result.value.user); let user = new ObjectID(result.value.user);
let mailbox = result.value.mailbox ? new ObjectID(result.value.mailbox) : false; let mailbox = result.value.mailbox ? new ObjectID(result.value.mailbox) : false;
let thread = result.value.thread ? new ObjectID(result.value.thread) : false; let thread = result.value.thread ? new ObjectID(result.value.thread) : false;
let orTerms = result.value.or || {};
let orQuery = [];
let query = result.value.query; let query = result.value.query;
let datestart = result.value.datestart || false; let datestart = result.value.datestart || false;
let dateend = result.value.dateend || false; let dateend = result.value.dateend || false;
@ -791,6 +820,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
if (query) { if (query) {
filter.searchable = true; filter.searchable = true;
filter.$text = { $search: query, $language: 'none' }; filter.$text = { $search: query, $language: 'none' };
} else if (orTerms.query) {
filter.searchable = true;
orQuery.push({ $text: { $search: query, $language: 'none' } });
} }
if (mailbox) { if (mailbox) {
@ -845,6 +877,22 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
mailboxNeeded = true; mailboxNeeded = true;
} }
if (orTerms.from) {
let regex = orTerms.from.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
orQuery.push({
headers: {
$elemMatch: {
key: 'from',
value: {
$regex: regex,
$options: 'i'
}
}
}
});
mailboxNeeded = true;
}
if (filterTo) { if (filterTo) {
let regex = filterTo.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); let regex = filterTo.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) { if (!filter.$and) {
@ -879,6 +927,36 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
mailboxNeeded = true; mailboxNeeded = true;
} }
if (orTerms.to) {
let regex = orTerms.to.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
orQuery.push({
headers: {
$elemMatch: {
key: 'to',
value: {
$regex: regex,
$options: 'i'
}
}
}
});
orQuery.push({
headers: {
$elemMatch: {
key: 'cc',
value: {
$regex: regex,
$options: 'i'
}
}
}
});
mailboxNeeded = true;
}
if (filterSubject) { if (filterSubject) {
let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) { if (!filter.$and) {
@ -898,11 +976,31 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
mailboxNeeded = true; mailboxNeeded = true;
} }
if (orTerms.subject) {
let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
orQuery.push({
headers: {
$elemMatch: {
key: 'subject',
value: {
$regex: regex,
$options: 'i'
}
}
}
});
mailboxNeeded = true;
}
if (filterAttachments) { if (filterAttachments) {
filter.ha = true; filter.ha = true;
mailboxNeeded = true; mailboxNeeded = true;
} }
if (orQuery.length) {
filter.$or = orQuery;
}
if (!mailbox && mailboxNeeded) { if (!mailbox && mailboxNeeded) {
// generate a list of mailbox ID values // generate a list of mailbox ID values
let mailboxes; let mailboxes;

View file

@ -90,5 +90,10 @@ module.exports = {
// mongdb query TTL limits // mongdb query TTL limits
DB_MAX_TIME_USERS: 1 * 1000, DB_MAX_TIME_USERS: 1 * 1000,
DB_MAX_TIME_MAILBOXES: 800, DB_MAX_TIME_MAILBOXES: 800,
DB_MAX_TIME_MESSAGES: 10 * 1000 DB_MAX_TIME_MESSAGES: 10 * 1000,
// access token default ttl in seconds (token ttl time is extended every time token is used by this value)
ACCESS_TOKEN_DEFAULT_TTL: 14 * 24 * 3600,
// access token can be extended until max lifetime value is reached in seconds
ACCESS_TOKEN_MAX_LIFETIME: 180 * 24 * 3600
}; };

View file

@ -45,6 +45,12 @@ class UserHandler {
} }
resolveAddress(address, options, callback) { resolveAddress(address, options, callback) {
this.asyncResolveAddress(address, options)
.catch(err => callback(err))
.then(result => callback(null, result));
}
async asyncResolveAddress(address, options) {
options = options || {}; options = options || {};
let wildcard = !!options.wildcard; let wildcard = !!options.wildcard;
@ -53,8 +59,9 @@ class UserHandler {
removeDots: true removeDots: true
}); });
let username = address.substr(0, address.indexOf('@')); let atPos = address.indexOf('@');
let domain = address.substr(address.indexOf('@') + 1); let username = address.substr(0, atPos);
let domain = address.substr(atPos + 1);
let projection = { let projection = {
user: true, user: true,
@ -65,69 +72,53 @@ class UserHandler {
projection[key] = true; projection[key] = true;
}); });
try {
let addressData;
// try exact match // try exact match
this.users.collection('addresses').findOne( addressData = await this.users.collection('addresses').findOne(
{ {
addrview: username + '@' + domain addrview: username + '@' + domain
}, },
{ {
projection, projection,
maxTimeMS: consts.DB_MAX_TIME_USERS maxTimeMS: consts.DB_MAX_TIME_USERS
},
(err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
} }
);
if (addressData) { if (addressData) {
return callback(null, addressData); return addressData;
} }
let aliasDomain;
// try an alias // try an alias
let checkAliases = done => { let aliasDomain;
this.users.collection('domainaliases').findOne( let aliasData = await this.users.collection('domainaliases').findOne(
{ alias: domain }, { alias: domain },
{ {
maxTimeMS: consts.DB_MAX_TIME_USERS maxTimeMS: consts.DB_MAX_TIME_USERS
},
(err, aliasData) => {
if (err) {
return done(err);
}
if (!aliasData) {
return done();
} }
);
if (aliasData) {
aliasDomain = aliasData.domain; aliasDomain = aliasData.domain;
this.users.collection('addresses').findOne( addressData = await this.users.collection('addresses').findOne(
{ {
addrview: username + '@' + aliasDomain addrview: username + '@' + aliasDomain
}, },
{ {
projection, projection,
maxTimeMS: consts.DB_MAX_TIME_USERS maxTimeMS: consts.DB_MAX_TIME_USERS
},
done
);
} }
); );
};
checkAliases((err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
if (addressData) { if (addressData) {
return callback(null, addressData); return addressData;
}
} }
if (!wildcard) { if (!wildcard) {
return callback(null, false); // wildcard not allowed, so there is nothing else to check for
return false;
} }
let query = { let query = {
@ -140,49 +131,36 @@ class UserHandler {
} }
// try to find a catch-all address // try to find a catch-all address
this.users.collection('addresses').findOne( addressData = await this.users.collection('addresses').findOne(query, {
query,
{
projection, projection,
maxTimeMS: consts.DB_MAX_TIME_USERS maxTimeMS: consts.DB_MAX_TIME_USERS
}, });
(err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
if (addressData) { if (addressData) {
return callback(null, addressData); return addressData;
} }
// try to find a catch-all user (eg. "postmaster@*") // try to find a catch-all user (eg. "postmaster@*")
this.users.collection('addresses').findOne( addressData = await this.users.collection('addresses').findOne(
{ {
addrview: username + '@*' addrview: username + '@*'
}, },
{ {
projection, projection,
maxTimeMS: consts.DB_MAX_TIME_USERS maxTimeMS: consts.DB_MAX_TIME_USERS
}, }
(err, addressData) => { );
if (err) {
if (addressData) {
return addressData;
}
} catch (err) {
err.code = 'InternalDatabaseError'; err.code = 'InternalDatabaseError';
return callback(err); throw err;
} }
if (!addressData) { // no match was found
return callback(null, false); return false;
}
return callback(null, addressData);
}
);
}
);
});
}
);
} }
/** /**
@ -3331,7 +3309,7 @@ class UserHandler {
.update(accessToken) .update(accessToken)
.digest('hex'); .digest('hex');
let key = 'tn:token:' + tokenHash; let key = 'tn:token:' + tokenHash;
let ttl = config.api.accessControl.tokenTTL || 3600; let ttl = config.api.accessControl.tokenTTL || consts.ACCESS_TOKEN_DEFAULT_TTL;
let tokenData = { let tokenData = {
user: user.toString(), user: user.toString(),
@ -3354,9 +3332,7 @@ class UserHandler {
await this.redis await this.redis
.multi() .multi()
.hmset(key, tokenData) .hmset(key, tokenData)
.sadd('tn:user:' + user, tokenHash)
.expire(key, ttl) .expire(key, ttl)
.expire('tn:user:' + user, ttl)
.exec(); .exec();
return accessToken; return accessToken;

View file

@ -1,6 +1,6 @@
{ {
"name": "wildduck", "name": "wildduck",
"version": "1.16.2", "version": "1.17.0",
"description": "IMAP/POP3 server built with Node.js and MongoDB", "description": "IMAP/POP3 server built with Node.js and MongoDB",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -19,7 +19,7 @@
"apidoc": "0.17.7", "apidoc": "0.17.7",
"browserbox": "0.9.1", "browserbox": "0.9.1",
"chai": "4.2.0", "chai": "4.2.0",
"eslint": "5.15.3", "eslint": "5.16.0",
"eslint-config-nodemailer": "1.2.0", "eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "4.1.0", "eslint-config-prettier": "4.1.0",
"grunt": "1.0.4", "grunt": "1.0.4",
@ -29,7 +29,7 @@
"grunt-shell-spawn": "0.4.0", "grunt-shell-spawn": "0.4.0",
"grunt-wait": "0.3.0", "grunt-wait": "0.3.0",
"icedfrisby": "1.5.0", "icedfrisby": "1.5.0",
"mailparser": "2.5.0", "mailparser": "2.6.0",
"mocha": "5.2.0", "mocha": "5.2.0",
"request": "2.88.0" "request": "2.88.0"
}, },
@ -53,7 +53,7 @@
"libbase64": "1.0.3", "libbase64": "1.0.3",
"libmime": "4.0.1", "libmime": "4.0.1",
"libqp": "1.1.0", "libqp": "1.1.0",
"mailsplit": "4.2.4", "mailsplit": "4.3.1",
"mobileconfig": "2.2.0", "mobileconfig": "2.2.0",
"mongo-cursor-pagination": "7.1.0", "mongo-cursor-pagination": "7.1.0",
"mongodb": "3.2.2", "mongodb": "3.2.2",