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 certs = require('./lib/certs');
const tools = require('./lib/tools');
const consts = require('./lib/consts');
const crypto = require('crypto');
const Gelf = require('gelf');
const os = require('os');
@ -156,7 +157,7 @@ server.use((req, res, next) => {
server.use(restify.plugins.gzipResponse());
server.use(restify.plugins.queryParser());
server.use(restify.plugins.queryParser({ allowDots: true }));
server.use(
restify.plugins.bodyParser({
maxBodySize: 0,
@ -240,19 +241,18 @@ server.use(
.digest('hex');
if (signature !== tokenData.s) {
// rogue token
// rogue token or invalidated secret
try {
await db.redis
.multi()
.del('tn:token:' + tokenHash)
.srem('tn:user:' + tokenData.user, tokenHash)
.exec();
} catch (err) {
// ignore
}
} else if (tokenData.ttl && isNaN(tokenData.ttl) && Number(tokenData.ttl) > 0) {
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
if (tokenLifetime < (Date.now() - Number(tokenData.created)) / 1000) {
@ -261,13 +261,26 @@ server.use(
await db.redis
.multi()
.expire('tn:token:' + tokenHash, tokenTTL)
.expire('tn:user:' + tokenData.user, tokenTTL)
.exec();
} catch (err) {
// ignore
}
req.role = tokenData.role;
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 {
req.role = tokenData.role;

View file

@ -12,11 +12,19 @@ secure=false
[accessControl]
# 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
# Secret for HMAC
# Changing this value invalidates all tokens
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]
# @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) {
let response = {
error: err.message,
code: 'AuthFailed' || err.code,
code: err.code || 'AuthFailed',
id: user.toString()
};
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
* @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} [flagged] If true, then matches only messages with \Flagged flags
* @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} [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
@ -596,6 +601,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
* @apiExample {curl} Example usage:
* 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:
* HTTP/1.1 200 OK
* {
@ -659,6 +667,23 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
.hex()
.length(24)
.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()
.trim()
.max(255)
@ -736,6 +761,10 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
let user = new ObjectID(result.value.user);
let mailbox = result.value.mailbox ? new ObjectID(result.value.mailbox) : 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 datestart = result.value.datestart || false;
let dateend = result.value.dateend || false;
@ -791,6 +820,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
if (query) {
filter.searchable = true;
filter.$text = { $search: query, $language: 'none' };
} else if (orTerms.query) {
filter.searchable = true;
orQuery.push({ $text: { $search: query, $language: 'none' } });
}
if (mailbox) {
@ -845,6 +877,22 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
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) {
let regex = filterTo.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) {
@ -879,6 +927,36 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
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) {
let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) {
@ -898,11 +976,31 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
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) {
filter.ha = true;
mailboxNeeded = true;
}
if (orQuery.length) {
filter.$or = orQuery;
}
if (!mailbox && mailboxNeeded) {
// generate a list of mailbox ID values
let mailboxes;

View file

@ -90,5 +90,10 @@ module.exports = {
// mongdb query TTL limits
DB_MAX_TIME_USERS: 1 * 1000,
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) {
this.asyncResolveAddress(address, options)
.catch(err => callback(err))
.then(result => callback(null, result));
}
async asyncResolveAddress(address, options) {
options = options || {};
let wildcard = !!options.wildcard;
@ -53,8 +59,9 @@ class UserHandler {
removeDots: true
});
let username = address.substr(0, address.indexOf('@'));
let domain = address.substr(address.indexOf('@') + 1);
let atPos = address.indexOf('@');
let username = address.substr(0, atPos);
let domain = address.substr(atPos + 1);
let projection = {
user: true,
@ -65,124 +72,95 @@ class UserHandler {
projection[key] = true;
});
// try exact match
this.users.collection('addresses').findOne(
{
addrview: username + '@' + domain
},
{
projection,
maxTimeMS: consts.DB_MAX_TIME_USERS
},
(err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
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 callback(null, addressData);
return addressData;
}
let aliasDomain;
// try an alias
let checkAliases = done => {
this.users.collection('domainaliases').findOne(
{ alias: domain },
{
maxTimeMS: consts.DB_MAX_TIME_USERS
},
(err, aliasData) => {
if (err) {
return done(err);
}
if (!aliasData) {
return done();
}
aliasDomain = aliasData.domain;
this.users.collection('addresses').findOne(
{
addrview: username + '@' + aliasDomain
},
{
projection,
maxTimeMS: consts.DB_MAX_TIME_USERS
},
done
);
}
);
};
checkAliases((err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
if (addressData) {
return callback(null, addressData);
}
if (!wildcard) {
return callback(null, false);
}
let query = {
addrview: '*@' + domain
};
if (aliasDomain) {
// search for alias domain as well
query.addrview = { $in: [query.addrview, '*@' + aliasDomain] };
}
// try to find a catch-all address
this.users.collection('addresses').findOne(
query,
{
projection,
maxTimeMS: consts.DB_MAX_TIME_USERS
},
(err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
if (addressData) {
return callback(null, addressData);
}
// try to find a catch-all user (eg. "postmaster@*")
this.users.collection('addresses').findOne(
{
addrview: username + '@*'
},
{
projection,
maxTimeMS: consts.DB_MAX_TIME_USERS
},
(err, addressData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
if (!addressData) {
return callback(null, false);
}
return callback(null, addressData);
}
);
}
);
});
}
);
if (!wildcard) {
// wildcard not allowed, so there is nothing else to check for
return false;
}
let query = {
addrview: '*@' + domain
};
if (aliasDomain) {
// search for alias domain as well
query.addrview = { $in: [query.addrview, '*@' + aliasDomain] };
}
// try to find a catch-all address
addressData = await this.users.collection('addresses').findOne(query, {
projection,
maxTimeMS: consts.DB_MAX_TIME_USERS
});
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;
}
/**
@ -3331,7 +3309,7 @@ class UserHandler {
.update(accessToken)
.digest('hex');
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 = {
user: user.toString(),
@ -3354,9 +3332,7 @@ class UserHandler {
await this.redis
.multi()
.hmset(key, tokenData)
.sadd('tn:user:' + user, tokenHash)
.expire(key, ttl)
.expire('tn:user:' + user, ttl)
.exec();
return accessToken;

View file

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