utils/authorizer.js

/**
* Module providing one time authentication
* @module api/utils/authorizer
*/

/** @lends module:api/utils/authorizer */
var authorizer = {};
var common = require("./common.js");
var crypto = require("crypto");
const log = require('./log.js')('core:authorizer');


/**
* Store token for later authentication
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {number} options.ttl - amount of seconds for token to work, 0 works indefinately
* @param {boolean} [options.multi=false] - if true, can be used many times until expired
* @param {string} options.token - token to store, if not provided, will be generated
* @param {string} options.owner - id of the user who created this token
* @param {string} options.app - list of the apps for which token was created
* @param {string} options.endpoint - regexp of endpoint(any string - is used as substring,to mach exact ^{yourpath}$). Deprecated as an authorization mechanism, kept for tokens created before the permission model.
* @param {object} [options.token_permission] - CRUD permissions granted to this token, in the same shape as member.permission ({_:{a,u}, c/r/u/d:{appId:{all, allowed}}}). When set, the token authorizes only the intersection of this object and its owner's own permissions. When omitted, the token inherits the owner's full permissions.
* @param {boolean} [options.can_login=false] - if true, the token may be redeemed for a dashboard session at /login/token. SERVER-ASSIGNED ONLY - see the note below.
* @param {string} options.tryReuse - if true - tries to find not expired token with same parameters. If not founds cretes new token. If found - updates token expiration time to new one and returns token.
* @param {boolean} [options.temporary=false] - If logged in with temporary token. Doesn't kill other sessions on logout.
* @param {function} options.callback - function called when saving was completed or errored, providing error object as first param and token string as second
*/
authorizer.save = function(options) {
    options.db = options.db || common.db;
    options.token = options.token || authorizer.getToken();
    options.ttl = options.ttl || 0;
    options.multi = options.multi || false;
    options.app = options.app || "";
    options.endpoint = options.endpoint || "";
    options.purpose = options.purpose || "";
    options.temporary = options.temporary || false; //If logged in with temporary token. Doesn't kill other sessions on logout

    // Login capability is a property of the token, never of its purpose string. It is set only
    // where the server itself establishes or propagates a session (setLoggedInVariables, the
    // renderer, the ban-warning mail, OIDC login), and by /i/token/create only when the creating
    // credential already holds it. Defaulting to false here means any caller that does not ask
    // for it explicitly produces a token that cannot be redeemed at /login/token.
    options.can_login = options.can_login === true;

    if (options.endpoint !== "" && !Array.isArray(options.endpoint)) {
        options.endpoint = [options.endpoint];
    }

    if (options.app !== "" && !Array.isArray(options.app)) {
        options.app = [options.app];
    }

    if (options.owner && options.owner !== "") {
        options.owner = options.owner + "";
        options.db.collection('members').findOne({'_id': options.db.ObjectID(options.owner)}, function(err, member) {
            if (err) {
                if (typeof options.callback === "function") {
                    options.callback(err, "");
                    return;
                }
            }
            else if (member) {
                authorizer.clearExpiredTokens(options);
                /**
                * Build the token document to store. token_permission is only written when the
                * token is actually scoped, so an unscoped token stays absent-means-unrestricted.
                * @returns {object} document to insert into auth_tokens
                */
                var buildTokenDoc = function() {
                    var ends = options.ttl + Math.round(Date.now() / 1000);
                    // A caller bounding this token by another credential's lifetime passes that
                    // credential's absolute end. The cap is applied here, at insertion, rather than
                    // by the caller turning it into a relative ttl: the member lookup above is
                    // asynchronous, so a ttl computed before it would land ends later than the
                    // bound by however long the lookup took.
                    if (options.maxEnds > 0 && options.ttl > 0 && ends > options.maxEnds) {
                        ends = options.maxEnds;
                    }
                    var doc = {
                        _id: options.token,
                        ttl: options.ttl,
                        ends: ends,
                        multi: options.multi,
                        owner: options.owner,
                        app: options.app,
                        endpoint: options.endpoint,
                        purpose: options.purpose,
                        temporary: options.temporary,
                        can_login: options.can_login
                    };
                    if (options.token_permission) {
                        doc.token_permission = options.token_permission;
                    }
                    if (options.maxEnds > 0) {
                        // kept on the document so that extend_token can honour it: the bound is
                        // a property of this token for its whole life, not of the insert alone
                        doc.max_ends = options.maxEnds;
                    }
                    return doc;
                };
                if (options.tryReuse === true) {
                    var rules = {"multi": options.multi, "endpoint": options.endpoint, "app": options.app, "owner": options.owner, "purpose": options.purpose, "can_login": options.can_login};
                    // Never reuse a token that grants something different from what is being asked
                    // for: an identical-looking token with a wider (or narrower) permission set is a
                    // different credential.
                    if (options.token_permission) {
                        rules.token_permission = options.token_permission;
                    }
                    else {
                        rules.token_permission = {$exists: false};
                    }
                    if (options.purpose === "LoggedInAuth") {
                        //Login token, allow switching from expiring to not expiring(and other way around)
                        //If there is changes to session expiration - this will allow to treat those tokens as same token.
                        rules.$or = [{"ttl": 0}, {"ttl": {"$gt": 0}, "ends": {$gt: Math.round(Date.now() / 1000)}}];
                    }
                    else {
                        if (options.ttl > 0) {
                            rules.ttl = {$gt: 0};
                            rules.ends = {$gt: Math.round(Date.now() / 1000)};
                        }
                        else {
                            rules.ttl = options.ttl;
                        }
                    }


                    var setObj = {ttl: options.ttl};
                    setObj.ends = options.ttl + Math.round(Date.now() / 1000);
                    options.db.collection("auth_tokens").findAndModify(rules, {}, {$set: setObj}, function(err_token, token) {
                        if (token && token.value) {
                            options.callback(err_token, token.value._id);
                        }
                        else {
                            options.db.collection("auth_tokens").insert(buildTokenDoc(), function(err1) {
                                if (typeof options.callback === "function") {
                                    options.callback(err1, options.token);
                                }
                            });
                        }
                    });
                }
                else {
                    options.db.collection("auth_tokens").insert(buildTokenDoc(), function(err1) {
                        if (typeof options.callback === "function") {
                            options.callback(err1, options.token);
                        }
                    });
                }
            }
            else {
                if (typeof options.callback === "function") {
                    options.callback("Token must have owner. Please provide correct user id", "");
                }
            }
        });
    }
    else {
        if (typeof options.callback === "function") {
            options.callback("Token must have owner. Please provide correct user id", "");
        }
    }
};

/**
* Get whole token information from database
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.token - token to read
* @param {function} options.callback - function called when reading was completed or errored, providing error object as first param and token object from database as second
*/
authorizer.read = function(options) {
    options.db = options.db || common.db;
    if (!options.token || options.token === "") {
        options.callback(Error('Token not given'), null);
    }
    else {
        options.token = options.token + "";
        options.db.collection("auth_tokens").findOne({_id: options.token}, options.callback);
    }
};


authorizer.clearExpiredTokens = function(options) {
    var ends = Math.round(Date.now() / 1000);
    options.db.collection("auth_tokens").remove({ttl: {$gt: 0}, ends: {$lt: ends}});
};

/**
* Checks if token is not expired yet
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.token - token to rvalidate
* @param {function} options.callback - function called when reading was completed or errored, providing error object as first param, true or false if expired as second, seconds till expiration as third.(-1 if never expires, 0 - if expired) 
*/
authorizer.check_if_expired = function(options) {
    options.db = options.db || common.db;
    options.token = options.token + "";
    options.db.collection("auth_tokens").findOne({_id: options.token}, function(err, res) {
        var expires_after = 0;
        var valid = false;
        if (res) {
            if (res.ttl > 0 && res.ends >= Math.round(Date.now() / 1000)) {
                valid = true;
                expires_after = res.ends - Math.round(Date.now() / 1000);
            }
            else if (res.ttl === 0) {
                valid = true;
                expires_after = -1;
            }
        }
        options.callback(err, valid, expires_after);
    });
};

/**
* extent token life spas
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.token - token to extend
* @param {string} options.extendBy - extend token by given time(in ms)(optional) You have to provide extedBy or extendTill. extendBy==0 makes it never die
* @param {string} options.extendTill - extend till given timestamp. (optional) You have to provide extedBy or extendTill
* @param {function} options.callback - function called when reading was completed or errored, providing error object as first param and true as second if extending successful
*/
authorizer.extend_token = function(options) {
    if (!options.token || options.token === "") {
        if (typeof options.callback === "function") {
            options.callback(Error("Token not provided"), null);
        }
        return;
    }
    options.token = options.token + "";
    options.db = options.db || common.db;
    var updateArr = {
        ttl: 0,
        ends: 0
    };
    if (options.extendBy) {
        updateArr.ends = Math.round((options.extendBy + Date.now()) / 1000);
        updateArr.ttl = options.extendBy;
    }
    else if (options.extendTill) {
        updateArr.ends = Math.round(options.extendTill / 1000);
        updateArr.ttl = 1;
    }
    else {
        if (typeof options.callback === "function") {
            options.callback(Error("Please provide extendTill or extendBy"), null);
        }
        return;
    }
    // A token bounded by another credential's lifetime carries max_ends. An extension - the
    // heatmap route extends a token by ten minutes whenever it is close to expiry - must not
    // carry it past that bound, or repeated requests keep a scoped child alive indefinitely
    // after its parent expired. Read the document first; a token with no bound extends as
    // before.
    options.db.collection("auth_tokens").findOne({_id: options.token}, {projection: {max_ends: 1}}, function(readErr, doc) {
        if (readErr) {
            // fail closed: without the bound there is no safe extension to write
            if (typeof options.callback === "function") {
                options.callback(readErr, null);
            }
            return;
        }
        if (doc && doc.max_ends > 0 && updateArr.ends > doc.max_ends) {
            updateArr.ends = doc.max_ends;
            updateArr.ttl = Math.max(1, doc.max_ends - Math.round(Date.now() / 1000));
        }
        options.db.collection("auth_tokens").update({_id: options.token}, {$set: updateArr}, function(err) {
            if (typeof options.callback === "function") {
                options.callback(err, true);
            }
        });
    });
};
var ENDPOINT_SEGMENT = /^[A-Za-z0-9_-]+$/;
/**
* Whether a value is a literal API path a new token may be restricted to: a leading slash and at
* least two non-empty segments of letters, digits, "_" and "-". Nothing else, so no regex syntax,
* no wildcard and no path that covers a whole tree ("/", "/o").
* @param {string} path - requested endpoint
* @returns {boolean} true if it is a literal API path
*/
function isLiteralEndpointPath(path) {
    if (typeof path !== "string" || path.charAt(0) !== "/") {
        return false;
    }
    var segments = path.slice(1).split("/");
    return segments.length >= 2 && segments.every(function(segment) {
        return ENDPOINT_SEGMENT.test(segment);
    });
}
/**
* A literal query string key and value an endpointquery entry may require.
*/
var ENDPOINT_PARAM_KEY = /^[A-Za-z0-9_]+$/;
var ENDPOINT_PARAM_VALUE = /^[A-Za-z0-9_-]+$/;

/**
* Parse the endpoint restriction a request asks a new token to carry.
*
* Stored endpoints are regexes matched from the start of the request path, so a value such as
* "", "/o" or ".*" would cover every path or a whole tree. A new token therefore names concrete
* paths only, and each is stored anchored at both ends, so "/o/actions" matches /o/actions and
* nothing else. An endpointquery entry is validated the same way, and the query string values
* it requires are matched exactly. Tokens stored before keep their own matching.
* @param {string|undefined} endpointParam - comma separated paths (the endpoint parameter)
* @param {string|undefined} endpointQueryParam - JSON array of {endpoint, params} (the endpointquery parameter)
* @returns {{endpoint: (Array|string), error: (string|undefined)}} the endpoint field to store ("" for none), or an error
*/
function parseEndpointRestriction(endpointParam, endpointQueryParam) {
    var hasEndpoint = typeof endpointParam !== "undefined" && endpointParam !== null;
    var hasQuery = typeof endpointQueryParam !== "undefined" && endpointQueryParam !== null;
    if (!hasEndpoint && !hasQuery) {
        return {endpoint: ""};
    }
    if (hasEndpoint && hasQuery) {
        return {error: "Use either endpoint or endpointquery, not both"};
    }
    if (hasEndpoint) {
        if (typeof endpointParam !== "string") {
            return {error: "Invalid endpoint"};
        }
        var paths = endpointParam.split(",");
        for (var i = 0; i < paths.length; i++) {
            if (!isLiteralEndpointPath(paths[i])) {
                return {error: "Invalid endpoint"};
            }
        }
        var anchored = paths.map(function(path) {
            return "^" + path + "$";
        });
        return {endpoint: anchored};
    }
    var entries;
    try {
        entries = typeof endpointQueryParam === "string" ? JSON.parse(endpointQueryParam) : endpointQueryParam;
    }
    catch (ex) {
        return {error: "Invalid endpointquery"};
    }
    if (!Array.isArray(entries) || entries.length === 0) {
        return {error: "Invalid endpointquery"};
    }
    var stored = [];
    for (var e = 0; e < entries.length; e++) {
        var entry = entries[e];
        if (!entry || typeof entry !== "object" || Array.isArray(entry) || !isLiteralEndpointPath(entry.endpoint)) {
            return {error: "Invalid endpointquery"};
        }
        var requiredParams = {};
        if (typeof entry.params !== "undefined") {
            if (!entry.params || typeof entry.params !== "object" || Array.isArray(entry.params)) {
                return {error: "Invalid endpointquery"};
            }
            for (var key in entry.params) {
                if (!ENDPOINT_PARAM_KEY.test(key) || typeof entry.params[key] !== "string" || !ENDPOINT_PARAM_VALUE.test(entry.params[key])) {
                    return {error: "Invalid endpointquery"};
                }
                requiredParams[key] = "^" + entry.params[key] + "$";
            }
        }
        stored.push({endpoint: "^" + entry.endpoint + "$", params: requiredParams});
    }
    return {endpoint: stored};
}

/**
* Token validation function called from verify and verify return
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.token - token to validate
* @param {string} options.qstring - params.qstring. If not passed and there is limitation for this token on params - token will not be valid
* @param {function} options.callback - function called when verifying was completed or errored, providing error object as first param and true as second if extending successful
* @param {boolean} return_owner states if in callback owner shold be returned. If return_owner==false, returns true or false.
* @param {boolean} return_data states if in callback all token data should be returned.
*/
var verify_token = function(options, return_owner, return_data) {
    options.db = options.db || common.db;
    if (!options.token || options.token === "") {
        if (typeof options.callback === "function") {
            options.callback(false);
        }
        return;
    }
    else {
        options.token = options.token + "";
        options.db.collection("auth_tokens").findOne({_id: options.token}, function(err, res) {
            var valid = false;
            var expires_after = 0;
            if (res) {
                var valid_endpoint = true;
                if (Array.isArray(res.endpoint) && res.endpoint.length === 0) {
                    res.endpoint = "";
                }
                // The endpoint restriction limits which paths a token can call. A token that also
                // carries token_permission is held to both: the paths here, and the permissions
                // rights.js intersects with its owner's on every request.
                if (res.endpoint && res.endpoint !== "") {
                    //keep backwards compability
                    if (!Array.isArray(res.endpoint)) {
                        res.endpoint = [res.endpoint];
                    }
                    valid_endpoint = false;
                    if (options.req_path !== "") {
                        var my_regexp = "";
                        for (var p = 0; p < res.endpoint.length; p++) {
                            if (res.endpoint[p] && res.endpoint[p].endpoint) {
                                var missing_param = false;
                                if (res.endpoint[p].params) {
                                    for (var k in res.endpoint[p].params) {
                                        try {
                                            var my_regexp2 = new RegExp(res.endpoint[p].params[k]);
                                            if (!options.qstring || !options.qstring[k] || !my_regexp2.test(options.qstring[k])) {
                                                missing_param = true;
                                            }
                                        }
                                        catch (e) {
                                            log.e("Invalid regex: '" + res.endpoint[p].params[k] + "'");
                                            missing_param = true;
                                        }
                                    }
                                }
                                try {
                                    my_regexp = new RegExp(res.endpoint[p].endpoint);
                                    if (!missing_param && my_regexp.test(options.req_path)) {
                                        valid_endpoint = true;
                                    }
                                }
                                catch (e) {
                                    log.e("Invalid regex: '" + res.endpoint[p].endpoint + "'");
                                }
                            }
                            else {
                                try {
                                    my_regexp = new RegExp(res.endpoint[p]);
                                    if (my_regexp.test(options.req_path)) {
                                        valid_endpoint = true;
                                    }
                                }
                                catch (e) {
                                    log.e("Invalid regex: '" + res.endpoint[p] + "'");
                                }
                            }
                        }
                    }
                }
                var valid_app = true;
                if (res.app && res.app !== "") {
                    //keep backwards compability
                    if (!Array.isArray(res.app)) {
                        res.app = [res.app];
                    }
                    if (options.qstring && options.qstring.app_id) {
                        if (res.app.indexOf(options.qstring.app_id) === -1) {
                            valid_app = false;
                        }
                    }
                }

                // The bound a scoped parent put on this token is absolute. It is applied wherever
                // ends is written, but a writer that does not know about it - a session-timeout
                // change retimes every LoggedInAuth token of a member in one update - must not be
                // able to move the token past it either, so it is enforced here, at every use.
                var pastBound = res.max_ends > 0 && res.max_ends < Math.round(Date.now() / 1000);
                if (valid_endpoint && valid_app) {
                    if (pastBound) {
                        // expired by its bound: neither branch below may mark it valid, and the
                        // consume step at the end removes it like any other expired token
                    }
                    else if (res.ttl === 0) {
                        valid = true;
                        expires_after = -1;
                        if (return_owner) {
                            valid = res.owner;
                        }
                        else if (return_data) {
                            valid = res;
                        }
                    }
                    else if (res.ends >= Math.round(Date.now() / 1000)) {
                        valid = true;
                        expires_after = Math.max(0, res.ends - Math.round(Date.now() / 1000));
                        if (return_owner) {
                            valid = res.owner;
                        }
                        else if (return_data) {
                            valid = res;
                        }
                    }

                    //consume token if expired or not multi
                    if (!res.multi || (res.ttl > 0 && res.ends < Math.round(Date.now() / 1000)) || pastBound) {
                        if (valid && !res.multi) {
                            // A single use token is valid only for the request that removes it. The
                            // read above is not a claim: requests arriving together all find the
                            // document before any of them deletes it, so each would pass. The delete
                            // is the claim instead, and only the caller whose delete removed the
                            // document gets the token.
                            options.db.collection("auth_tokens").deleteOne({_id: options.token}, {}, function(errDelete, deleted) {
                                if (errDelete || !deleted || deleted.deletedCount !== 1) {
                                    valid = false;
                                    expires_after = 0;
                                }
                                if (typeof options.callback === "function") {
                                    options.callback(valid, expires_after);
                                }
                            });
                            return;
                        }
                        options.db.collection("auth_tokens").remove({_id: options.token});
                    }
                }
            }
            if (typeof options.callback === "function") {
                options.callback(valid, expires_after);
            }
        });
    }
};
    /**
* Verify token and expire it
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.token - token to verify
* @param {string} options.qstring - params.qstring. If not passed and there is limitation for this token on params - token will not be valid
* @param {string} options.req_path - current request path
* @param {function} options.callback - function called when verifying was completed, providing 1 argument, true if could verify token and false if couldn't
*/
authorizer.verify = function(options) {
    verify_token(options, false);
};

/** 
* Similar to authorizer.verify. Only difference - return token owner if valid.
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {string} options.token - token to verify
* @param {string} options.qstring - params.qstring. If not passed and there is limitation for this token on params - token will not be valid
* @param {string} options.req_path - current request path
* @param {function} options.callback - function called when verifying was completed, providing 1 argument, true if could verify token and false if couldn't
*/
authorizer.verify_return = function(options) {
    if (options.return_data) {
        verify_token(options, false, true);
    }
    else {
        verify_token(options, true);
    }
};
/**
* Generates auhtentication ID
* @returns {string} id to be used when saving the task
*/
authorizer.getToken = function() {
    return crypto.createHash('sha1').update(crypto.randomBytes(16).toString("hex") + "" + new Date().getTime()).digest('hex');
};

/**
* Clean all expired tokens
* @param {object} options - options for the task
* @param {object} options.db - database connection
* @param {function} options.callback - function called when cleaning completed
*/
authorizer.clean = function(options) {
    options.db = options.db || common.db;
    options.db.collection("auth_tokens").remove({
        ends: {$lt: Math.round(Date.now() / 1000)},
        ttl: {$ne: 0}
    }, options.callback);
};

/**
* Revoke the tokens a member owns. Used when a member signs out (purpose LoggedInAuth only)
* and when their password changes (every token, of any purpose).
* The owner is matched as a string, because save() stores it as one.
* @param {object} options - options for the task
* @param {object=} options.db - database connection
* @param {string|object} options.owner - id of the member whose tokens are revoked
* @param {string=} options.purpose - only revoke tokens with this purpose; every token when omitted
* @param {string=} options.keepToken - token to keep, such as the one of the current session
* @returns {Promise} resolves once the tokens are removed
*/
authorizer.revokeMemberTokens = async function(options) {
    var db = options.db || common.db;
    var query = {owner: options.owner + ''};
    if (options.purpose) {
        query.purpose = options.purpose;
    }
    if (options.keepToken) {
        query._id = {$ne: options.keepToken + ''};
    }
    await db.collection('auth_tokens').deleteMany(query);
};

authorizer.parseEndpointRestriction = parseEndpointRestriction;
module.exports = authorizer;