/**
* Module to record failed attempts and prevent brute force credential guessing
* @module frontend/express/libs/preventBruteforce
* @example
* var username, pass;
* preventBruteforce.isBlocked("login", username, function(isBlocked, fails, err) {
* if (isBlocked) {
* console.log("User is blocked");
* return;
* }
* else {
* if (login()) {
* //user logged in, reset any fails user had
* preventBruteforce.reset("login", username);
* }
* else {
* //user failed to login, increase fails
* preventBruteforce.fail("login", username);
* }
* }
* });
*
*/
/** @lends module:frontend/express/libs/preventBruteforce */
const preventBruteforce = {};
/**
* @property {object} db - Data base connection. Needs to be set befoe callng any other function.
*/
preventBruteforce.db = null;
/**
* @property {object} mail - Mail service to add mailing capabilities
*/
preventBruteforce.mail = null;
/**
* @property {number} fails - How many times user is allowed to fail
*/
preventBruteforce.fails = 3;
/**
* @property {number} wait - How long to wait in seconds until removing the block
*/
preventBruteforce.wait = 5 * 60;
/**
* @property {object} pathIdentifiers - Scope or path of check
*/
preventBruteforce.pathIdentifiers = {};
/**
* @property {object} userIdentifiers - map paths to functions that extract identifiers from requests i.e. (req) => {ip: req.ip}
*/
preventBruteforce.userIdentifiers = {};
/**
* @property {object} blockHooks - map path identifiers to functions that are executed when users are blocked e.g. send a mail
*/
preventBruteforce.blockHooks = {};
/**
* Middleware to listen to specific paths and check if user is blocked
* @param {object} req - request object
* @param {object} res - response object
* @param {function} next - callback to call next middleware
* @example
* app.use(preventBruteforce.middleware);
**/
preventBruteforce.middleware = function(req, res, next) {
const path = preventBruteforce.registeredPath(req.path);
if (req.method.toLowerCase() === "post" && path) {
const uid = path in preventBruteforce.userIdentifiers ? preventBruteforce.userIdentifiers[path](req) : req.ip;
const pid = preventBruteforce.pathIdentifiers[path];
if (pid) {
preventBruteforce.isBlocked(pid, uid, function(isBlocked, fails, err) {
req.session.fails = fails;
if (isBlocked) {
if (err) {
res.status(500).send("Server Error");
}
else {
if (path in preventBruteforce.blockHooks) {
preventBruteforce.blockHooks[pid](uid, req, res);
}
res.redirect(path + "?message=login.blocked");
}
}
else {
next();
}
});
}
else {
next();
}
}
else {
next();
}
};
/**
* Find the registered path a request path is routed to. Express routes
* case-insensitively and ignores a trailing slash, so /login, /Login and
* /login/ all reach the same handler and must all be checked.
* @param {string} path - request path
* @returns {string|null} the key in pathIdentifiers, or null when the path is not checked
**/
preventBruteforce.registeredPath = function(path) {
const wanted = normalizePath(path);
for (const registered in preventBruteforce.pathIdentifiers) {
if (normalizePath(registered) === wanted) {
return registered;
}
}
return null;
};
/**
* The key the login failure counter is stored under for a login identifier.
* It normalizes the identifier the way the credential lookup does: trimmed,
* and an email in lower case. Usernames are matched case-sensitively, so
* they keep their case and two members whose names differ only in case keep
* separate counters. Anything that reads, bumps or clears the "login"
* counter goes through this.
* @param {string} identifier - username or email as typed, or as stored
* @returns {string} the counter key
**/
preventBruteforce.loginKey = function(identifier) {
var key = (identifier + "").trim();
return key.indexOf("@") !== -1 ? key.toLowerCase() : key;
};
/**
* Ids of the login failure counters for an identifier: the normalized one the
* logins count under, then the identifier verbatim, which older versions used
* @param {string} identifier - username or email
* @returns {Array<string>} failed_logins document ids
**/
preventBruteforce.loginCounterIds = function(identifier) {
var ids = [JSON.stringify(["login", preventBruteforce.loginKey(identifier)]), JSON.stringify(["login", identifier])];
return ids[0] === ids[1] ? [ids[0]] : ids;
};
/**
* Clear every login failure counter of a member, by username and by email
* @param {object} member - member document
* @param {function} callback - callback to call when done
**/
preventBruteforce.resetForMember = function(member, callback) {
callback = callback || function() {};
var ids = [];
[member.username, member.email].forEach(function(value) {
if (typeof value === "string" && value.length) {
preventBruteforce.loginCounterIds(value).forEach(function(id) {
if (ids.indexOf(id) === -1) {
ids.push(id);
}
});
}
});
preventBruteforce.db.collection("failed_logins").remove({_id: {$in: ids}}, callback);
};
/**
* Check if user is blocked for provided namepsace and user idendtifier
* @param {string} pid - namespace of the block
* @param {string} uid - user identifier
* @param {function} callback - callback to call with result
* @example
* var username, pass;
* preventBruteforce.isBlocked("login", username, function(isBlocked, fails, err) {
* if (isBlocked) {
* console.log("User is blocked");
* return;
* }
* else {
* if (login()) {
* //user logged in, reset any fails user had
* preventBruteforce.reset("login", username);
* }
* else {
* //user failed to login, increase fails
* preventBruteforce.fail("login", username);
* }
* }
* });
**/
preventBruteforce.isBlocked = function(pid, uid, callback) {
preventBruteforce.db.collection("failed_logins").findOne({_id: JSON.stringify([pid, uid])}, function(err, result) {
result = result || {fails: 0};
if (err) {
callback(true, result.fails, err);
}
else if (result.fails > 0 && (result.fails % preventBruteforce.fails) === 0 && getTimestamp() < (((result.fails / preventBruteforce.fails) * preventBruteforce.wait) + result.lastFail)) {
callback(true, result.fails);
}
else {
callback(false, result.fails);
}
});
};
/**
* Reset fails for provided namepsace and user
* @param {string} pid - namespace of the block
* @param {string} uid - user identifier
* @param {function} callback - callback to call when done
* @example
* preventBruteforce.reset("login", username);
**/
preventBruteforce.reset = function(pid, uid, callback) {
callback = callback || function() {};
preventBruteforce.db.collection("failed_logins").remove({_id: JSON.stringify([pid, uid])}, callback);
};
/**
* Increase fails for provided namepsace and user
* @param {string} pid - namespace of the block
* @param {string} uid - user identifier
* @param {function} callback - callback to call when done
* @example
* preventBruteforce.fail("login", username);
**/
preventBruteforce.fail = function(pid, uid, callback) {
callback = callback || function() {};
preventBruteforce.db.collection("failed_logins").update({_id: JSON.stringify([pid, uid])}, {$inc: {fails: 1}, $set: {lastFail: getTimestamp()}}, {upsert: true}, callback);
};
/**
* Path as the router compares it: without trailing slashes, in lower case
* @param {string} path - path to normalize
* @returns {string} normalized path
**/
function normalizePath(path) {
return ((path || "") + "").replace(/\/+$/, "").toLowerCase();
}
/**
* Get current unix timestamp in seconds
* @returns {number} current unix timestamp in seconds
**/
function getTimestamp() {
return Math.floor(new Date().getTime() / 1000);
}
module.exports = preventBruteforce;