libs/preventBruteforce.js

/**
 * 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;