api/access.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

 /**
  * @module api/access
  */

const lib = require(__dirname + '/../lib');
const api = require(__dirname + '/../api');
const logger = require(__dirname + '/../logger');

const mod =

/**
 * Default security implementation using {@link module:acl}, {@link module:signature},
 * {@link module:session}, {@link module:users} modules, no external dependencies.
 *
 * The Express middleware parses cookies with session signature, verifies it against the bk_user table, checks ACL
 * if access to requested endpoint is allowed, stores current user in the *req.user* property.
 */

module.exports = {
    name: "api.access",
    args: [
        { name: "err-(.+)", descr: "Error messages for various cases" },
        { name: "disabled", type: "bool", descr: "Disable default security middleware" },
    ],

    errDeny: "Access denied",
    errInvalidLogin: "Authentication is required",
    errInvalidUser: "Authentication failed",
    errInvalidSecret: "Authentication failed",
    errInvalidSession: "This session has expired",
    errInvalidRequest: "Invalid request",
    errNoMatch: "Access is not allowed",
};

/**
 * Install authentication/authorization middleware
 * @memberof module:api/access
 * @method configureMiddleware
 */
mod.configureMiddleware = function(options, callback)
{
    api.app.use(mod.middleware.bind(mod));

    callback();
}

/**
 * Implements full authentication and authorizarion of each request
 * Steps:
 *  1. check if access is allowed by path via "access" hooks, calls {@link module:api/access.allow}
 *  2. if status given and not 200 return the error
 *  3. if not matched continue othwewise check CSRF
 *  4. run {@link module:api/access.authenticate}, if status is not 200 return an error
 *  5. check CSRF token {@link module:api/csrf.check}
 *  5. run {@link module:api/access.authorize}, at least one ACL must match, on error return
 *  6. check for routing {@link module:api/routing.check}
 * @param {Request} req
 * @param {Response} res
 * @param {function} callback
 * @memberof module:api/access
 * @method middleware
 */
mod.middleware = function(req, res, callback)
{
    if (mod.disabled) return callback();

    const trace = req.trace.start("access");

    lib.everySeries([
        function(next) {
            mod.allow(req, (status) => {
                if (!status) {
                    return next();
                }

                // Status is given, return an error or proceed to the next middleware
                if (status.status != 200) {
                    api.session.clear(req);
                    return api.sendReply(res, status);
                }

                var err = api.csrf.check(req);
                if (err) return api.sendReply(res, err);

                trace.stop();
                callback();
            });
        },

        function(next) {
            mod.authenticate(req, (err) => {
                if (!err || err.status == 200) return next();

                if (err?.status == 417) {
                    if (api.acl.isAnonymous(req)) {
                        return next();
                    }
                }

                if (err?.status >= 401 && err.status < 500) {
                    var loc = api.redirect.check(req, "login");
                    if (loc) return api.sendReply(res, loc);
                }

                api.sendReply(res, err);
            });
        },

        function(next) {
            var err = api.csrf.check(req);
            if (err) return api.sendReply(res, err);

            mod.authorize(req, (err) => {
                if (err && err.status != 200) {
                    return api.sendReply(res, err);
                }

                api.routing.check(req, "auth");

                trace.stop();
                callback();
            });
        },
    ], null, true);
}

/**
 * Perform URL based access checks, this is called before the signature verification, very early in the request processing step.
 *
 * Checks access permissions, calls the callback with the following argument:
 * - null or undefined to proceed with authentication
 * - an object with status: 200 to skip authentication and proceed with other routes
 * - an object with status other than 0 or 200 to return the status and stop request processing,
 *    for statuses 301,302 there should be url property in the object returned
 * @param {Request} req
 * @param {function} callback
 * @memberof module:api/access
 * @method allow
 */
mod.allow = function(req, callback)
{
    var status = api.acl.isPublic(req) ? { status: 200 } : null;

    // Call custom access handler for the endpoint
    api.hooks.run("access", req, status, (err) => {
        logger.debug("checkPublic:", mod.name, req.method, req.options.path, status, "ERR:", err);
        callback(err || status);
    }, true);
}

/**
 * Assign or clear the current user record for the given request, if user is null the current is cleared.
 * @param {Request} req
 * @param {object} user
 * @memberof module:api/access
 * @method setUser
 */
mod.setUser = function(req, user)
{
    if (!req) return;
    if (user === null) {
        delete req.user;
    } else
    if (user?.id) {
        req.user = Object.assign({}, user);
    }
    logger.debug("setUser:", mod.name, req.user)
}

/**
 * Verify request signature from the request object, uses properties: .host, .method, .url or .originalUrl, .headers
 * @param {Request} req
 * @param {function} callback
 * @memberof module:api/access
 * @method authenticate
 */
mod.authenticate = function(req, callback)
{
    var now = Date.now();
    // Make sure we will not crash on wrong object
    if (!req || !req.headers) req = { headers: {} };

    // Extract all signature components from the request
    var sig = api.signature.get(req);

    logger.debug("authenticate:", mod.name, req.options);

    lib.everySeries([
        function(next) {
            // Sanity checks, required headers must be present and not empty
            if (!sig.method || !sig.host) {
                return next({ status: 415, message: api.errInvalidRequest, code: "NOLOGIN" });
            }

            // Bad or empty signature result in empty login
            if (!sig.login) {
                return next({ status: 417, message: mod.errInvalidLogin, code: "NOLOGIN" });
            }

            // Make sure the request is not expired, it must be in milliseconds
            if (sig.expires && sig.expires < now - api.signature.age) {
                var msg = req.__("Expired request, check your clock, the server time is %s, your clock is %s",
                          lib.strftime(now, "%m/%d/%Y %H:%M:%S GMT", { utc: 1 }),
                          lib.strftime(sig.expires, "%m/%d/%Y %H:%M:%S GMT", { utc: 1 }));
                return next({ status: 406, message: msg, code: "EXPIRED" });
            }

            // Check the signature version consistency, do not accept wrong signatures in the unexpected places
            if ((sig.version == 2 && sig.source != "s") ||
                (sig.version == 3 && sig.source != "t") ||
                (sig.version == 4 && sig.source) ||
                (!sig.version && sig.source) ||
                (sig.version < 0 && sig.source != "l")) {
                return next({ status: 416, message: mod.errInvalidRequest, code: "NOLOGIN" });
            }

            api.session.check(sig, (err, rc) => {
                if (rc < 0) return next({ status: 401, message: mod.errInvalidSession, code: "INVALID" });

                // Pre-authenticated request (WS)
                if (req.user?.login == sig.login && req.user.id) {
                    mod.setUser(req, req.user);
                    return next({ status: 200 });
                }
                next();
            });
        },

        function(next, err) {
            if (err) return next(err);

            // Verify if the access key is valid, they all are cached so a bad cache may result in rejects
            api.users.get({ login: sig.login }, (err, user, info) => {
                if (err) return next({ status: 500, message: String(err) });
                if (!user) return next({ status: 401, message: mod.errInvalidUser, code: "NOLOGIN" });

                if (user.expires > 0 && user.expires < Date.now()) {
                    return next({ status: 412, message: mod.errInvalidUser, code: "EXPIRED" });
                }
                next(null, user);
            });
        },

        function(next, err, user) {
            if (err) return next(err);

            // Now we can proceed with signature verification, all other conditions are met
            api.signature.verify(req, sig, user, (sig) => {
                if (!sig) {
                    api.session.clear(req);
                    return next({ status: 401, message: mod.errInvalidSecret, code: "NOLOGIN" });
                }
                // Save user and signature in the request, it will be used later
                req.signature = sig;
                api.session.save(sig);
                mod.setUser(req, user);
                next();
            });
        },

        function(next, err) {
            // Run authentication hooks for alternative credentials, to proceed it must return nothing or status 200
            api.hooks.run("auth", req, err, (e) => {
                logger.debug('authenticate:', mod.name, e || err, req.signature, 'HDRS:', req.headers);
                next(e || err);
            });
        },
    ], callback, true);
}

/**
 * Perform authorization checks after the user been checked for valid signature.
 *
 * At least one acl must match to proceed.
 *
 * @param {Request} req - is Express request object
 * @param {function} callback is a function(status) to be called with the final status
 * @memberof module:api/access
 * @method authorize
 */
mod.authorize = function(req, callback)
{
    logger.debug("authorize:", mod.name, req.options);

    if (api.acl.isDenied(req)) {
        logger.debug("authorize:", mod.name, "deny:", 403, req.options);
        return api.hooks.runAll("pre", req, { status: 403, message: this.errDeny, code: "DENY" }, callback);
    }

    // Must satisfy at least one role
    if (api.acl.isAllowed(req)) {
        logger.debug("authorize:", mod.name, "allow:", 200, req.options);
        return api.hooks.runAll("pre", req, null, callback);
    }

    // Authenticated as the last resort
    if (api.acl.isAuthenticated(req)) {
        logger.debug("authorize:", mod.name, "authenticated:", 200, req.options);
        return api.hooks.runAll("pre", req, null, callback);
    }

    logger.debug("authorize:", mod.name, "nomatch:", 403, req.options);
    api.hooks.runAll("pre", req, { status: 403, message: this.errNoMatch, code: "NOMATCH" }, callback);
}