UNPKG

btrz-auth-api-key

Version:
538 lines (469 loc) 18.5 kB
"use strict"; const assert = require("assert"); const constants = require("./constants"); const InternalAuthTokenProvider = require("./internalAuthTokenProvider"); const {SuperUserAuthenticator} = require("./superUserAuthenticator"); const axios = require("axios"); const audiences = require("./audiences.js"); const authPolicy = require("./authPolicies.js"); const allAudience = audiences; function Authenticator(options, logger) { assert(logger && logger.info && logger.error, "you must provide a logger"); assert(options.internalAuthTokenSigningSecrets, "you must provide 'internalAuthTokenSigningSecrets'"); assert(options.internalAuthTokenSigningSecrets.main, "you must provide 'internalAuthTokenSigningSecrets.main'"); assert(options.internalAuthTokenSigningSecrets.secondary, "you must provide 'internalAuthTokenSigningSecrets.secondary'"); const internalAuthTokenSigningSecrets = options.internalAuthTokenSigningSecrets; const ignoredRoutes = options.ignoredRoutes && Array.isArray(options.ignoredRoutes) ? options.ignoredRoutes : []; const strategyOptions = { passReqToCallback: true, apiKeyHeader: options.authKeyFields && options.authKeyFields.header ? options.authKeyFields.header : "x-api-key", apiKeyField: options.authKeyFields && options.authKeyFields.request ? options.authKeyFields.request : "x-api-key" }; let preLoadedUser = null; // username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]] const passport = require("passport"); const LocalStrategy = require("passport-localapikey-update").Strategy; const SimpleDao = require("btrz-simple-dao").SimpleDao; const simpleDao = new SimpleDao(options); const jwt = require("jsonwebtoken"); function useTestKey() { return new Promise(function (resolve) { if (options.testUser) { resolve(options.testUser); } else { resolve(true); } }); } function getTestUser(token) { if (isTestToken(token)) { if (options.testUser) { return options.testUser; } else { return true; } } return null; } function isTestToken(token) { return (token && token === options.testToken); } function isCorrectBackOfficeAudience(audience, application) { const userHasBackofficeAudience = Array.isArray(options.audiences) ? options.audiences.indexOf(audience) > -1 : audience === "betterez-app"; if (!userHasBackofficeAudience) { return isCorrectBackOfficeApplication(application); } return userHasBackofficeAudience; } function isCorrectBackOfficeApplication(application) { return application && Array.isArray(application.channels) && (application.channels.includes("backoffice") || application.channels.includes("agency-backoffice")); } function useDb(apikey) { let query = {}; query[options.collection.property] = apikey; return simpleDao.connect() .then((db) => { return db.collection(options.collection.name).findOne(query) .then((result) => { if(!result) { logger.error("api-key not found"); } return result; }); }) .catch((err) => { return Promise.reject(err); }); } function getAuthInfo(apikey) { const url = `${options.apiUrl}/${apikey}`; const payload = { headers: { "Authorization": `Bearer ${options.internalAuthTokenProvider.getToken()}` }, body: {}, json: true }; return axios.get(url, payload) .then((info) => { preLoadedUser = info.data.user; return info.data.application; }) .catch((err) => { logger.error("ERROR getting auth info::getAuthInfo::", err); return null; }); } function useApiAuth() { return options.apiAuth && options.apiUrl && options.internalAuthTokenProvider; } function findByApiKey(apikey) { if (apikey === options.testKey) { return useTestKey(apikey); } if (useApiAuth()) { return getAuthInfo(apikey); } return useDb(apikey); } function findUserById(userId) { if (preLoadedUser) { return Promise.resolve(preLoadedUser); } if(typeof userId !== "string") { return Promise.reject(new Error("userId must be a string")); } return simpleDao.connect() .then((db) => { return db.collection(constants.DB_USER_COLLECTION_NAME).findOne({_id: simpleDao.objectId(userId), deleted: false}); }); } function shouldIgnoreRoute(originalUrl, method) { return ignoredRoutes.some(function (ignoredRoute) { if (typeof ignoredRoute === "string") { return originalUrl.match(ignoredRoute) || null; } else if (typeof ignoredRoute === "object") { let methodMatches = (ignoredRoute.methods.indexOf(method) > -1); return (methodMatches && originalUrl.match(ignoredRoute.route)); } else { return null; } }); } function isIgnoredRouteWithoutAuthAttempt(req, strategyOptions) { const isXApiKey = req.headers[strategyOptions.apiKeyHeader] || req.query[strategyOptions.apiKeyField]; return shouldIgnoreRoute(req.originalUrl, req.method) && !isXApiKey; } function innerAuthenticateMiddleware(req, res, next) { const jwtToken = getAuthToken(req); const decodedToken = decodeToken(jwtToken); const isXApiKey = req.headers[strategyOptions.apiKeyHeader] || req.query[strategyOptions.apiKeyField]; const someAuthAttempt = decodedToken || isXApiKey; const isInternalToken = decodedToken && decodedToken.iss === constants.INTERNAL_AUTH_TOKEN_ISSUER; if(isInternalToken) { req.internalUser = true; } if (shouldIgnoreRoute(req.originalUrl, req.method) && (!someAuthAttempt || isInternalToken)) { next(); } else { passport.authenticate("localapikey", {session: false})(req, res, next); } } passport.use(new LocalStrategy(strategyOptions, function (req, apikey, done) { let onSuccess = function (result) { const jwtToken = getAuthToken(req); req.application = result; req.tokens = { token: apikey, jwtToken }; // if jwtToken is present obtain user from it if (result && result.privateKey && jwtToken) { const user = fetchUserFromJwtToken(jwtToken, result.privateKey); if (user && user.aud === 'customer') { req.customer = user; } else { req.user = user; } } // done executes Passport login and fills req.user (or an alias if userProperty is defined in root index.js) return done(null, result); }; let onErr = function (err) { return done(err, null); }; let result = findByApiKey(apikey, req).then(onSuccess, onErr); if (result.done) { result.done(); } } )); function getAuthToken(req) { return req.headers.authorization ? req.headers.authorization.replace(/^Bearer /, "") : null; } function decodeToken(token) { try { return jwt.decode(token); } catch (err) { return null; } } function shouldValidateAccount(bypassAccount, req) { return !bypassAccount && !(req.account && req.account.privateKey); } function verifyInternalToken(token, secrets) { function verify(keyName, opts) { try { return jwt.verify(token, secrets[keyName], opts); } catch (err) { // failing to validate the token against one of the signing keys is expected behaviour when a key rotation is in progress logger.info(`authenticateTokenMiddleware: Failed to validate internal auth token using ${keyName} signing key`, err); return false; } } const opts = { algorithms: ["HS512"], issuer: constants.INTERNAL_AUTH_TOKEN_ISSUER, }; return verify("main", opts) || verify("secondary", opts); } // if this setup is used then the endpoint will be opened, please sanitize the content if needed // if some auth attempt is done then authtentication will be performed function optionalTokenSecured(req, res, next) { if (isIgnoredRouteWithoutAuthAttempt(req, strategyOptions)) { return next(); } return authenticateTokenMiddleware(req, res, next); } function authenticateTokenMiddleware(req, res, next, options = {}) { const {bypassAccount = false} = options; if (shouldValidateAccount(bypassAccount, req)) { logger.error("authenticateTokenMiddleware: No account or account has no private key"); return res.status(401).send("Unauthorized"); } else if (!req.headers.authorization) { logger.info("authenticateTokenMiddleware: Request is missing 'authorization' header"); return res.status(401).send("Unauthorized"); } // at this point headers.authorization was already checked for existency const jwtToken = getAuthToken(req); // moving a the token process to a unified function // which will also be used after the passport login happens // but omitting res, next and options processJwtToken(req, res, jwtToken, next, options); } //if channel 'backoffice' or 'agency-backoffice' is requested in the body or querystring, //checks request has a valid token for backoffice ('betterez-app' internal application) function tokenSecuredForBackoffice(req, res, next) { let channel = (req.body ? req.body.channel : "") || (req.query ? req.query.channel : ""); let channels = (req.body ? req.body.channels : "") || (req.query ? req.query.channels : ""); let mustAuth = false; if (channels) { if (!Array.isArray(channels)) { channels = channels.split(","); } channels.forEach(function (ch) { if (ch.trim().toLowerCase() === "backoffice" || ch.trim().toLowerCase() === "agency-backoffice") { mustAuth = true; return; } }); } if (!mustAuth && channel && (channel.trim().toLowerCase() === "backoffice" || channel.trim().toLowerCase() === "agency-backoffice")) { mustAuth = true; } if (mustAuth) { authenticateTokenMiddleware(req, res, function (err) { if (err) { return next(err); } if (isTestToken(getAuthToken(req))) { return next(); } if (!req.user || !isCorrectBackOfficeAudience(req.user.aud, req.application)) { return res.status(401).send("Unauthorized"); } else { return next(); } }); } else { return next(); } } function customerTokenSecured(req, res, next) { return authenticateTokenMiddleware(req, res, next, {audience: "customer"}); } function tokenSecuredForInternal(req, res, next) { return authenticateTokenMiddleware(req, res, function (err) { if (err) { return next(err); } if (isTestToken(getAuthToken(req))) { return next(); } if (!req.internalUser) return res.status(401).send("Unauthorized"); return next(); }, {bypassAccount: true}); } function tokenSecuredForAudiences(audiences) { return function (req, res, next) { return authenticateTokenMiddleware(req, res, function (err) { if (err) { return next(err); } if (isTestToken(getAuthToken(req))) { return next(); } const notAuthenticated = !req.user, wrongAudience = audiences.indexOf(req.user.aud) === -1; if (notAuthenticated || (wrongAudience && !req.internalUser)) { if (audiences.includes(allAudience.BETTEREZ_APP) && isCorrectBackOfficeApplication(req.application)) { return next(); } return res.status(401).send("Unauthorized"); } else { return next(); } }); }; } function validateJwtIfGiven(req, res, next) { const jwtToken = getAuthToken(req); if (!jwtToken) { return next(); } return authenticateTokenMiddleware(req, res, next); } function findOneAdministrator(accountId) { const query = { accountId, deleted: false, "roles.administrator": 1, "locked.status": false, }; return simpleDao.connect() .then((db) => { return db.collection(constants.DB_USER_COLLECTION_NAME).findOne(query); }); } function findUserForInternalToken(application) { // find the "original user", using the userId of the Application found by x-api-key return findUserById(application.userId).then((user) => { if (user) { return user; } // the "original user" is no longer enabled, fetch a valid administrator to impersonate return findOneAdministrator(application.accountId); }); } function processJwtToken(req, res, jwtToken, next, options = {}) { // will only assign req.user if it's not present. Because it could've been assigned previously const {audience = null, bypassAccount = false} = options; const decodedToken = decodeToken(jwtToken); if (isTestToken(jwtToken)) { if (!req.user) { req.user = getTestUser(jwtToken); } return next(); } if (!decodedToken) { logger.error("authenticateTokenMiddleware: Token is malformed"); return res.status(401).send("Unauthorized"); } else if (!decodedToken.iss) { logger.error("authenticateTokenMiddleware: Token does not specify its issuer"); return res.status(401).send("Unauthorized"); } const isInternalToken = decodedToken.iss === constants.INTERNAL_AUTH_TOKEN_ISSUER; if (isInternalToken) { const tokenPayload = verifyInternalToken(jwtToken, internalAuthTokenSigningSecrets); if (!tokenPayload) { logger.error("authenticateTokenMiddleware: Failed to validate internal auth token using any signing key"); return res.status(401).send("Unauthorized"); } if (bypassAccount) { return next(); } return findUserForInternalToken(req.account) .then((user) => { assert(user, "unable to find user to impersonate"); Reflect.deleteProperty(user, "password"); if (!req.user) { req.user = Object.assign({}, user, tokenPayload); req.user._id = req.user._id.toString(); } return next(); }) .catch((err) => { logger.error(`authenticateTokenMiddleware: Error occurred finding user to impersonate for internal token. Check user ${req.account.userId} exists, or the account has at least one enabled administrator.`, err); return res.status(401).send("Unauthorized"); }); } else { // Validate a user-provided token try { const userTokenVerifyOptions = { algorithms: ["HS512"], subject: "account_user_sign_in", issuer: constants.USER_AUTH_TOKEN_ISSUER }; if (audience) { userTokenVerifyOptions.audience = audience; } // audience does not exist at api-key verification time, so if it's defined, token needs to be verified again if (!req.user || audience) { req.user = jwt.verify(jwtToken, req.account.privateKey, userTokenVerifyOptions); } return next(); } catch (err) { if (err.name === "TokenExpiredError" || err.name === "JsonWebTokenError") { logger.info(`authenticateTokenMiddleware: Token expired or 'JsonWebTokenError' occurred`, err); return res.status(401).send("Unauthorized"); } logger.error(`authenticateTokenMiddleware: Unexpected error occurred validating user token`, err); return res.status(401).send("Unauthorized"); } } } function fetchUserFromJwtToken(jwtToken, privateKey) { let user = null; try { const decodedToken = decodeToken(jwtToken); const testToken = isTestToken(jwtToken); if (decodedToken) { const isInternalToken = decodedToken.iss === constants.INTERNAL_AUTH_TOKEN_ISSUER; if (!isInternalToken) { if (testToken) { user = getTestUser(jwtToken); } else { const userTokenVerifyOptions = { algorithms: ["HS512"], subject: "account_user_sign_in", issuer: constants.USER_AUTH_TOKEN_ISSUER, }; user = jwt.verify(jwtToken, privateKey, userTokenVerifyOptions); } } } } catch (err) { logger.error(`Passport localapikey validation: ${err.name || 'Unexpected error'} occurred fetching the user from the jwt`, err); } return user; } /* * Returns the authorization middleware that corresponds to a particular "policy". This function provides a level of * indirection for Express routes which need authorization logic. Using this function, the desired authorization logic * can be expressed using one of the constant "authPolicy" values instead of directly importing middleware. */ function getMiddlewareForAuthPolicy(policy) { switch (policy) { case authPolicy.USER_MUST_BE_LOGGED_IN_TO_BACKOFFICE_APP: return this.tokenSecuredForAudiences([audiences.BETTEREZ_APP]); case authPolicy.USER_MUST_BE_LOGGED_IN_TO_BACKOFFICE_APP_OR_MOBILE_SCANNER: return this.tokenSecuredForAudiences([audiences.BETTEREZ_APP, audiences.MOBILE_SCANNER]); case authPolicy.USER_MUST_BE_LOGGED_IN_TO_BACKOFFICE_APP_OR_PUBLIC_SALES_APP: return this.tokenSecuredForAudiences([audiences.BETTEREZ_APP, audiences.CUSTOMER]); case authPolicy.USER_MUST_BE_LOGGED_IN_TO_BACKOFFICE_APP_OR_MOBILE_SCANNER_OR_PUBLIC_SALES_APP: return this.tokenSecuredForAudiences([audiences.BETTEREZ_APP, audiences.MOBILE_SCANNER, audiences.CUSTOMER]); case authPolicy.ONLY_ALLOW_REQUESTS_FROM_OTHER_BETTEREZ_SERVICES: return this.tokenSecuredForInternal; default: throw new Error(`Unrecognized authorization policy: ${policy}`); } } return { initialize: function (passportInitOptions) { return passport.initialize(passportInitOptions); }, authenticate: function () { return innerAuthenticateMiddleware; }, tokenSecuredForBackoffice, tokenSecuredForAudiences, customerTokenSecured, optionalTokenSecured, validateJwtIfGiven, tokenSecuredForInternal, getMiddlewareForAuthPolicy, }; } module.exports = { Authenticator, InternalAuthTokenProvider, SuperUserAuthenticator, audiences, authPolicy, };