UNPKG

@universis/janitor

Version:

Universis api plugin for handling user authorization and rate limiting

1,361 lines (1,273 loc) 46.8 kB
'use strict'; require('core-js/modules/es.string.replace-all'); var common = require('@themost/common'); var expressRateLimit = require('express-rate-limit'); var express = require('express'); var path = require('path'); var rxjs = require('rxjs'); var slowDown = require('express-slow-down'); var rateLimitRedis = require('rate-limit-redis'); var ioredis = require('ioredis'); require('@themost/promise-sequence'); var url = require('url'); var superagent = require('superagent'); var jwt = require('jsonwebtoken'); var BearerStrategy = require('passport-http-bearer'); var passport = require('passport'); class RateLimitService extends common.ApplicationService { /** * @param {import('@themost/express').ExpressDataApplication} app */ constructor(app) { super(app); // get proxy address forwarding option const proxyAddressForwarding = app.getConfiguration().getSourceAt('settings/universis/api/proxyAddressForwarding'); this.proxyAddressForwarding = typeof proxyAddressForwarding !== 'boolean' ? false : proxyAddressForwarding; /** * @type {BehaviorSubject<{ target: RateLimitService }>} */ this.loaded = new rxjs.BehaviorSubject(null); const serviceContainer = this.getServiceContainer(); if (serviceContainer == null) { common.TraceUtils.warn(`${this.getServiceName()} is being started but the parent router seems to be unavailable.`); return; } serviceContainer.subscribe((router) => { if (router == null) { return; } try { // set router for further processing Object.defineProperty(this, 'router', { value: express.Router(), writable: false, enumerable: false, configurable: true }); const serviceConfiguration = this.getServiceConfiguration(); // create maps const paths = serviceConfiguration.paths; if (paths.size === 0) { common.TraceUtils.warn(`${this.getServiceName()} is being started but the collection of paths is empty.`); } paths.forEach((value, path) => { this.set(path, value); }); if (router.stack) { router.stack.unshift.apply(router.stack, this.router.stack); } else { // use router router.use(this.router); // get router stack (use a workaround for express 4.x) const stack = router._router && router._router.stack; if (Array.isArray(stack)) { // stage #1 find logger middleware (for supporting request logging) let index = stack.findIndex((item) => { return item.name === 'logger'; }); if (index === -1) { // stage #2 find expressInit middleware index = stack.findIndex((item) => { return item.name === 'expressInit'; }); } // if found, move the last middleware to be after expressInit if (index > -1) { // move the last middleware to be after expressInit stack.splice(index + 1, 0, stack.pop()); } } else { common.TraceUtils.warn(`${this.getServiceName()} is being started but the container stack is not available.`); } } // notify that the service is loaded this.loaded.next({ target: this }); } catch (err) { common.TraceUtils.error('An error occurred while validating rate limit configuration.'); common.TraceUtils.error(err); common.TraceUtils.warn('Rate limit service is inactive due to an error occured while loading configuration.'); } }); } /** * Returns the service router that is used to register rate limit middleware. * @returns {import('rxjs').BehaviorSubject<import('express').Router | import('express').Application>} The service router. */ getServiceContainer() { return this.getApplication() && this.getApplication().serviceRouter; } /** * Returns the service name. * @returns {string} The service name. */ getServiceName() { return '@universis/janitor#RateLimitService'; } /** * Returns the service configuration. * @returns {{extends?: string, profiles?: Array, paths?: Array}} The service configuration. */ getServiceConfiguration() { if (this.serviceConfiguration) { return this.serviceConfiguration; } let serviceConfiguration = { profiles: [], paths: [] }; // get service configuration const serviceConfigurationSource = this.getApplication().getConfiguration().getSourceAt('settings/universis/janitor/rateLimit'); if (serviceConfigurationSource) { if (typeof serviceConfigurationSource.extends === 'string') { // get additional configuration const configurationPath = this.getApplication().getConfiguration().getConfigurationPath(); const extendsPath = path.resolve(configurationPath, serviceConfigurationSource.extends); common.TraceUtils.log(`${this.getServiceName()} will try to extend service configuration using ${extendsPath}`); serviceConfiguration = Object.assign({}, { profiles: [], paths: [] }, require(extendsPath)); } else { common.TraceUtils.log(`${this.getServiceName()} will use service configuration from settings/universis/janitor/rateLimit`); serviceConfiguration = Object.assign({}, { profiles: [], paths: [] }, serviceConfigurationSource); } } const pathsArray = serviceConfiguration.paths || []; const profilesArray = serviceConfiguration.profiles || []; // create maps serviceConfiguration.paths = new Map(pathsArray); serviceConfiguration.profiles = new Map(profilesArray); // set service configuration Object.defineProperty(this, 'serviceConfiguration', { value: serviceConfiguration, writable: false, enumerable: false, configurable: true }); return this.serviceConfiguration; } /** * Sets the rate limit configuration for a specific path. * @param {string} path * @param {{ profile: string } | import('express-rate-limit').Options} options * @returns {RateLimitService} The service instance for chaining. */ set(path, options) { let opts; // get profile if (options.profile) { opts = this.serviceConfiguration.profiles.get(options.profile); } else { // or options defined inline opts = options; } /** * @type { import('express-rate-limit').Options } */ const rateLimitOptions = Object.assign({ windowMs: 5 * 60 * 1000, // 5 minutes limit: 50, // 50 requests legacyHeaders: true // send headers }, opts, { keyGenerator: (req) => { let remoteAddress; if (this.proxyAddressForwarding) { // get proxy headers or remote address remoteAddress = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || (req.connection ? req.connection.remoteAddress : req.socket.remoteAddress); } else { // get remote address remoteAddress = req.connection ? req.connection.remoteAddress : req.socket.remoteAddress; } return `${path}:${remoteAddress}`; } }); if (typeof rateLimitOptions.store === 'undefined') { const StoreClass = this.getStoreType(); if (typeof StoreClass === 'function') { rateLimitOptions.store = new StoreClass(this, rateLimitOptions); } } this.router.use(path, expressRateLimit.rateLimit(rateLimitOptions)); return this; } /** * @returns {function} The type of store used for rate limiting. */ getStoreType() { const serviceConfiguration = this.getServiceConfiguration(); if (typeof serviceConfiguration.storeType !== 'string') { return; } let StoreClass; const store = serviceConfiguration.storeType.split('#'); if (store.length === 2) { const storeModule = require(store[0]); if (Object.prototype.hasOwnProperty.call(storeModule, store[1])) { StoreClass = storeModule[store[1]]; return StoreClass; } else { throw new Error(`${store} cannot be found or is inaccessible`); } } else { StoreClass = require(store[0]); return StoreClass; } } /** * Unsets the rate limit configuration for a specific path. * @param {string} path * @returns {RateLimitService} The service instance for chaining. */ unset(path) { const index = this.router.stack.findIndex((layer) => { return layer.route && layer.route.path === path; }); if (index !== -1) { this.router.stack.splice(index, 1); } return this; } } class SpeedLimitService extends common.ApplicationService { constructor(app) { super(app); // get proxy address forwarding option const proxyAddressForwarding = app.getConfiguration().getSourceAt('settings/universis/api/proxyAddressForwarding'); this.proxyAddressForwarding = typeof proxyAddressForwarding !== 'boolean' ? false : proxyAddressForwarding; /** * @type {BehaviorSubject<{ target: SpeedLimitService }>} */ this.loaded = new rxjs.BehaviorSubject(null); const serviceContainer = this.getServiceContainer(); if (serviceContainer == null) { common.TraceUtils.warn(`${this.getServiceName()} is being started but the parent router seems to be unavailable.`); return; } serviceContainer.subscribe((router) => { if (router == null) { return; } try { // set router for further processing Object.defineProperty(this, 'router', { value: express.Router(), writable: false, enumerable: false, configurable: true }); const serviceConfiguration = this.getServiceConfiguration(); // create maps const paths = serviceConfiguration.paths; if (paths.size === 0) { common.TraceUtils.warn(`${this.getServiceName()} is being started but the collection of paths is empty.`); } paths.forEach((value, path) => { this.set(path, value); }); if (router.stack) { router.stack.unshift.apply(router.stack, this.router.stack); } else { // use router router.use(this.router); // get router stack (use a workaround for express 4.x) const stack = router._router && router._router.stack; if (Array.isArray(stack)) { // stage #1 find logger middleware (for supporting request logging) let index = stack.findIndex((item) => { return item.name === 'logger'; }); if (index === -1) { // stage #2 find expressInit middleware index = stack.findIndex((item) => { return item.name === 'expressInit'; }); } } else { common.TraceUtils.warn(`${this.getServiceName()} is being started but the container stack is not available.`); } } // notify that the service is loaded this.loaded.next({ target: this }); } catch (err) { common.TraceUtils.error('An error occurred while validating speed limit configuration.'); common.TraceUtils.error(err); common.TraceUtils.warn('Speed limit service is inactive due to an error occured while loading configuration.'); } }); } /** * @returns {function} The type of store used for rate limiting. */ getStoreType() { const serviceConfiguration = this.getServiceConfiguration(); if (typeof serviceConfiguration.storeType !== 'string') { return; } let StoreClass; const store = serviceConfiguration.storeType.split('#'); if (store.length === 2) { const storeModule = require(store[0]); if (Object.prototype.hasOwnProperty.call(storeModule, store[1])) { StoreClass = storeModule[store[1]]; return StoreClass; } else { throw new Error(`${store} cannot be found or is inaccessible`); } } else { StoreClass = require(store[0]); return StoreClass; } } /** * Returns the service name. * @returns {string} The service name. */ getServiceName() { return '@universis/janitor#SpeedLimitService'; } /** * Returns the service router that is used to register speed limit middleware. * @returns {import('express').Router | import('express').Application} The service router. */ getServiceContainer() { return this.getApplication() && this.getApplication().serviceRouter; } /** * Sets the speed limit configuration for a specific path. * @param {string} path * @param {{ profile: string } | import('express-slow-down').Options} options * @returns {SpeedLimitService} The service instance for chaining. */ set(path, options) { let opts; // get profile if (options.profile) { opts = this.serviceConfiguration.profiles.get(options.profile); } else { // or options defined inline opts = options; } const slowDownOptions = Object.assign({ windowMs: 5 * 60 * 1000, // 5 minutes delayAfter: 20, // 20 requests delayMs: 500, // 500 ms maxDelayMs: 10000 // 10 seconds }, opts, { keyGenerator: (req) => { let remoteAddress; if (this.proxyAddressForwarding) { // get proxy headers or remote address remoteAddress = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || (req.connection ? req.connection.remoteAddress : req.socket.remoteAddress); } else { // get remote address remoteAddress = req.connection ? req.connection.remoteAddress : req.socket.remoteAddress; } return `${path}:${remoteAddress}`; } }); if (Array.isArray(slowDownOptions.randomDelayMs)) { slowDownOptions.delayMs = () => { const delayMs = Math.floor(Math.random() * (slowDownOptions.randomDelayMs[1] - slowDownOptions.randomDelayMs[0] + 1) + slowDownOptions.randomDelayMs[0]); return delayMs; }; } if (Array.isArray(slowDownOptions.randomMaxDelayMs)) { slowDownOptions.maxDelayMs = () => { const maxDelayMs = Math.floor(Math.random() * (slowDownOptions.randomMaxDelayMs[1] - slowDownOptions.randomMaxDelayMs[0] + 1) + slowDownOptions.randomMaxDelayMs[0]); return maxDelayMs; }; } if (typeof slowDownOptions.store === 'undefined') { const StoreClass = this.getStoreType(); if (typeof StoreClass === 'function') { slowDownOptions.store = new StoreClass(this, slowDownOptions); } } this.router.use(path, slowDown(slowDownOptions)); return this; } /** * Unsets the speed limit configuration for a specific path. * @param {string} path * @return {SpeedLimitService} The service instance for chaining. */ unset(path) { const index = this.router.stack.findIndex((layer) => { return layer.route && layer.route.path === path; }); if (index !== -1) { this.router.stack.splice(index, 1); } return this; } /** * * @returns {{extends?: string, profiles?: Array, paths?: Array}} The service configuration. */ getServiceConfiguration() { if (this.serviceConfiguration) { return this.serviceConfiguration; } let serviceConfiguration = { profiles: [], paths: [] }; // get service configuration const serviceConfigurationSource = this.getApplication().getConfiguration().getSourceAt('settings/universis/janitor/speedLimit'); if (serviceConfigurationSource) { if (typeof serviceConfigurationSource.extends === 'string') { // get additional configuration const configurationPath = this.getApplication().getConfiguration().getConfigurationPath(); const extendsPath = path.resolve(configurationPath, serviceConfigurationSource.extends); common.TraceUtils.log(`${this.getServiceName()} will try to extend service configuration using ${extendsPath}`); serviceConfiguration = Object.assign({}, { profiles: [], paths: [] }, require(extendsPath)); } else { common.TraceUtils.log(`${this.getServiceName()} will use service configuration from settings/universis/janitor/speedLimit`); serviceConfiguration = Object.assign({}, { profiles: [], paths: [] }, serviceConfigurationSource); } } const profilesArray = serviceConfiguration.profiles || []; serviceConfiguration.profiles = new Map(profilesArray); const pathsArray = serviceConfiguration.paths || []; serviceConfiguration.paths = new Map(pathsArray); Object.defineProperty(this, 'serviceConfiguration', { value: serviceConfiguration, writable: false, enumerable: false, configurable: true }); return this.serviceConfiguration; } } function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: true, configurable: true, writable: true }) : e[r] = t, e; } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } let superLoadIncrementScript; let superLoadGetScript; function noLoadGetScript() { // } function noLoadIncrementScript() { // } if (rateLimitRedis.RedisStore.prototype.loadIncrementScript.name === 'loadIncrementScript') { // get super method for future use superLoadIncrementScript = rateLimitRedis.RedisStore.prototype.loadIncrementScript; rateLimitRedis.RedisStore.prototype.loadIncrementScript = noLoadIncrementScript; } if (rateLimitRedis.RedisStore.prototype.loadGetScript.name === 'loadGetScript') { // get super method superLoadGetScript = rateLimitRedis.RedisStore.prototype.loadGetScript; rateLimitRedis.RedisStore.prototype.loadGetScript = noLoadGetScript; } class RedisClientStore extends rateLimitRedis.RedisStore { /** * * @param {import('@themost/common').ApplicationService} service * @param {{windowMs: number}} options */ constructor(service, options) { // IMPORTANT NOTE: call super with a dummy sendCommand() // for implementing a custom sendCommand method // which binds sendCommand to this instance super({ /** * @param {...string} args * @returns {Promise<*>} */ sendCommand: function () { const args = Array.from(arguments); const [command] = args.splice(0, 1); const self = this; if (command === 'SCRIPT') { const connectOptions = service.getApplication().getConfiguration().getSourceAt('settings/redis/options') || { host: '127.0.0.1', port: 6379 }; const client = new ioredis.Redis(connectOptions); return client.call(command, args).catch((error) => { if (error instanceof TypeError && error.message === 'Invalid argument type') { common.TraceUtils.warn('RedisClientStore: Invalid argument type: ' + JSON.stringify(args)); } return Promise.reject(error); }).finally(() => { if (client.isOpen) { client.disconnect().catch((errDisconnect) => { common.TraceUtils.error(errDisconnect); }); } }); } if (self.client == null) { const connectOptions = service.getApplication().getConfiguration().getSourceAt('settings/redis/options') || { host: '127.0.0.1', port: 6379 }; self.client = new ioredis.Redis(connectOptions); } if (self.client.isOpen) { return self.client.call(command, args).catch((error) => { if (error instanceof TypeError && error.message === 'Invalid argument type') { common.TraceUtils.warn('RedisClientStore: Invalid argument type: ' + JSON.stringify(args)); } return Promise.reject(error); }); } // send load script commands once return (() => { if (self.incrementScriptSha == null) { return self.postInit(); } return Promise.resolve(); })().then(() => { // send command args[0] = self.incrementScriptSha; return self.client.call(command, args).catch((error) => { if (error instanceof TypeError && error.message === 'Invalid argument type') { common.TraceUtils.warn('RedisClientStore: Invalid argument type: ' + JSON.stringify(args)); } return Promise.reject(error); }); }); } }); /** * @type {import('ioredis').Redis} */_defineProperty(this, "client", void 0);const opts = Object.assign({ windowMs: 60000 }, options); this.init(opts); common.TraceUtils.debug('RedisClientStore: Starting up and loading increment and get scripts.'); void this.postInit().then(() => { common.TraceUtils.debug('RedisClientStore: Successfully loaded increment and get scripts.'); }).catch((err) => { common.TraceUtils.error('RedisClientStore: Failed to load increment and get scripts.'); common.TraceUtils.error(err); }); } async postInit() { const [incrementScriptSha, getScriptSha] = await Promise.sequence([ () => superLoadIncrementScript.call(this), () => superLoadGetScript.call(this)] ); this.incrementScriptSha = incrementScriptSha; this.getScriptSha = getScriptSha; } } const HTTP_METHOD_REGEXP = /^\b(POST|PUT|PATCH|DELETE)\b$/i; class ScopeString { constructor(str) { this.value = str; } toString() { return this.value; } /** * Splits a comma-separated or space-separated scope string e.g. "profile email" or "profile,email" * * Important note: https://www.rfc-editor.org/rfc/rfc6749#section-3.3 defines the regular expression of access token scopes * which is a space separated string. Several OAuth2 servers use a comma-separated list instead. * * The operation will try to use both implementations by excluding comma ',' from access token regular expressions * @returns {Array<string>} */ split() { // the default regular expression includes comma /([\x21\x23-\x5B\x5D-\x7E]+)/g // the modified regular expression excludes comma /x2C /([\x21\x23-\x2B\x2D-\x5B\x5D-\x7E]+)/g const re = /([\x21\x23-\x2B\x2D-\x5B\x5D-\x7E]+)/g; const results = []; let match = re.exec(this.value); while (match !== null) { results.push(match[0]); match = re.exec(this.value); } return results; } } class ScopeAccessConfiguration extends common.ConfigurationStrategy { /** * @param {import('@themost/common').ConfigurationBase} configuration */ constructor(configuration) { super(configuration); let elements = []; // define property Object.defineProperty(this, 'elements', { get: () => { return elements; }, enumerable: true }); } /** * @param {Request} req * @returns Promise<ScopeAccessConfigurationElement> */ verify(req) { return new Promise((resolve, reject) => { try { // validate request context common.Args.notNull(req.context, 'Context'); // validate request context user common.Args.notNull(req.context.user, 'User'); if (req.context.user.authenticationScope && req.context.user.authenticationScope.length > 0) { // get original url let reqUrl = url.parse(req.originalUrl).pathname; // get user context scopes as array e.g, ['students', 'students:read'] let reqScopes = new ScopeString(req.context.user.authenticationScope).split(); // get user access based on HTTP method e.g. GET -> read access let reqAccess = HTTP_METHOD_REGEXP.test(req.method) ? 'write' : 'read'; // first phase: find element by resource and scope let result = this.elements.find((x) => { // filter element by access level return new RegExp("^" + x.resource, 'i').test(reqUrl) // and scopes && x.scope.find((y) => { // search user scopes (validate wildcard scope) return y === "*" || reqScopes.indexOf(y) >= 0; }); }); // second phase: check access level if (result == null) { return resolve(); } // if access is missing or access is not an array if (Array.isArray(result.access) === false) { // the requested access is not allowed because the access is not defined return resolve(); } // if the requested access is not in the access array if (result.access.indexOf(reqAccess) < 0) { // the requested access is not allowed because the access levels do not match return resolve(); } // otherwise, return result return resolve(result); } return resolve(); } catch (err) { return reject(err); } }); } } /** * @class */ class DefaultScopeAccessConfiguration extends ScopeAccessConfiguration { /** * @param {import('@themost/common').ConfigurationBase} configuration */ constructor(configuration) { super(configuration); let defaults = []; // load scope access from configuration resource try { /** * @type {Array<ScopeAccessConfigurationElement>} */ defaults = require(path.resolve(configuration.getConfigurationPath(), 'scope.access.json')); } catch (err) { // if an error occurred other than module not found (there are no default access policies) if (err.code !== 'MODULE_NOT_FOUND') { // throw error throw err; } // otherwise continue } this.elements.push.apply(this.elements, defaults); } } class EnableScopeAccessConfiguration extends common.ApplicationService { /** * @param {import('@themost/express').ExpressDataApplication|import('@themost/common').ApplicationBase} app */ constructor(app) { super(app); // register scope access configuration app.getConfiguration().useStrategy(ScopeAccessConfiguration, DefaultScopeAccessConfiguration); } } /** * @class */ class ExtendScopeAccessConfiguration extends common.ApplicationService { /** * @param {import('@themost/express').ExpressDataApplication|import('@themost/common').ApplicationBase} app */ constructor(app) { super(app); // Get the additional scope access extensions from the configuration const scopeAccessExtensions = app.getConfiguration().settings.universis?.janitor?.scopeAccess.imports; if (app && app.container && scopeAccessExtensions != null) { app.container.subscribe((container) => { if (container) { const scopeAccess = app.getConfiguration().getStrategy(function ScopeAccessConfiguration() {}); if (scopeAccess != null) { for (const scopeAccessExtension of scopeAccessExtensions) { try { const elements = require(path.resolve(app.getConfiguration().getConfigurationPath(), scopeAccessExtension)); if (elements) { // add extra scope access elements scopeAccess.elements.unshift(...elements); } } catch (err) { // if an error occurred other than module not found (there are no default access policies) if (err.code !== 'MODULE_NOT_FOUND') { // throw error throw err; } } } } } }); } } } function validateScope() { return (req, res, next) => { /** * @type {ScopeAccessConfiguration} */ let scopeAccessConfiguration = req.context.getApplication().getConfiguration().getStrategy(ScopeAccessConfiguration); if (typeof scopeAccessConfiguration === 'undefined') { return next(new Error('Invalid application configuration. Scope access configuration strategy is missing or is in accessible.')); } scopeAccessConfiguration.verify(req).then((value) => { if (value) { return next(); } return next(new common.HttpForbiddenError('Access denied due to authorization scopes.')); }).catch((reason) => { return next(reason); }); }; } function responseHander(resolve, reject) { return function (err, response) { if (err) { /** * @type {import('superagent').Response} */ const response = err.response; if (response && response.headers['content-type'] === 'application/json') { // get body const clientError = response.body; const error = new common.HttpError(response.status); return reject(Object.assign(error, { clientError })); } return reject(err); } if (response.status === 204 && response.headers['content-type'] === 'application/json') { return resolve(null); } return resolve(response.body); }; } /** * @class */ class OAuth2ClientService extends common.ApplicationService { /** * @param {import('@themost/express').ExpressDataApplication} app */ constructor(app) { super(app); /** * @name OAuth2ClientService#settings * @type {{server_uri:string,token_uri?:string}} */ Object.defineProperty(this, 'settings', { writable: false, value: app.getConfiguration().getSourceAt('settings/auth'), enumerable: false, configurable: false }); } /** * Gets keycloak server root * @returns {string} */ getServer() { return this.settings.server_uri; } /** * Gets keycloak server root * @returns {string} */ getAdminRoot() { return this.settings.admin_uri; } // noinspection JSUnusedGlobalSymbols /** * Gets user's profile by calling OAuth2 server profile endpoint * @param {ExpressDataContext} context * @param {string} token */ getUserInfo(token) { return new Promise((resolve, reject) => { const userinfo_uri = this.settings.userinfo_uri ? new url.URL(this.settings.userinfo_uri, this.getServer()) : new url.URL('me', this.getServer()); return new superagent.Request('GET', userinfo_uri). set({ 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }). query({ 'access_token': token }).end(responseHander(resolve, reject)); }); } // noinspection JSUnusedGlobalSymbols /** * Gets the token info of the current context * @param {ExpressDataContext} context */ getContextTokenInfo(context) { if (context.user == null) { return Promise.reject(new Error('Context user may not be null')); } if (context.user.authenticationType !== 'Bearer') { return Promise.reject(new Error('Invalid context authentication type')); } if (context.user.authenticationToken == null) { return Promise.reject(new Error('Context authentication data may not be null')); } return this.getTokenInfo(context, context.user.authenticationToken); } /** * Gets token info by calling OAuth2 server endpoint * @param {ExpressDataContext} _context * @param {string} token */ getTokenInfo(_context, token) { return new Promise((resolve, reject) => { const introspection_uri = this.settings.introspection_uri ? new url.URL(this.settings.introspection_uri, this.getServer()) : new url.URL('tokeninfo', this.getServer()); return new superagent.Request('POST', introspection_uri). auth(this.settings.client_id, this.settings.client_secret). set('Accept', 'application/json'). type('form'). send({ 'token_type_hint': 'access_token', 'token': token }).end(responseHander(resolve, reject)); }); } /** * @param {AuthorizeUser} authorizeUser */ authorize(authorizeUser) { const tokenURL = this.settings.token_uri ? new url.URL(this.settings.token_uri) : new url.URL('authorize', this.getServer()); return new Promise((resolve, reject) => { return new superagent.Request('POST', tokenURL). type('form'). send(authorizeUser).end(responseHander(resolve, reject)); }); } /** * Gets a user by name * @param {*} user_id * @param {AdminMethodOptions} options */ getUserById(user_id, options) { return new Promise((resolve, reject) => { return new superagent.Request('GET', new url.URL(`users/${user_id}`, this.getAdminRoot())). set('Authorization', `Bearer ${options.access_token}`). end(responseHander(resolve, reject)); }); } /** * Gets a user by name * @param {string} username * @param {AdminMethodOptions} options */ getUser(username, options) { return new Promise((resolve, reject) => { return new superagent.Request('GET', new url.URL('users', this.getAdminRoot())). set('Authorization', `Bearer ${options.access_token}`). query({ '$filter': `name eq '${username}'` }). end(responseHander(resolve, reject)); }); } /** * Gets a user by email address * @param {string} email * @param {AdminMethodOptions} options */ getUserByEmail(email, options) { return new Promise((resolve, reject) => { return new superagent.Request('GET', new url.URL('users', this.getAdminRoot())). set('Authorization', `Bearer ${options.access_token}`). query({ '$filter': `alternateName eq '${email}'` }). end(responseHander(resolve, reject)); }); } /** * Updates an existing user * @param {*} user * @param {AdminMethodOptions} options */ updateUser(user, options) { return new Promise((resolve, reject) => { if (user.id == null) { return reject(new common.DataError('E_IDENTIFIER', 'User may not be empty at this context.', null, 'User', 'id')); } const request = new superagent.Request('PUT', new url.URL(`users/${user.id}`, this.getAdminRoot())); return request.set('Authorization', `Bearer ${options.access_token}`). set('Content-Type', 'application/json'). send(user). end(responseHander(resolve, reject)); }); } /** * Creates a new user * @param {*} user * @param {AdminMethodOptions} options */ createUser(user, options) { return new Promise((resolve, reject) => { const request = new superagent.Request('POST', new url.URL('users', this.getAdminRoot())); return request.set('Authorization', `Bearer ${options.access_token}`). set('Content-Type', 'application/json'). send(Object.assign({}, user, { $state: 1 // for create })). end(responseHander(resolve, reject)); }); } /** * Deletes a user * @param {{id: any}} user * @param {AdminMethodOptions} options */ deleteUser(user, options) { return new Promise((resolve, reject) => { if (user.id == null) { return reject(new common.DataError('E_IDENTIFIER', 'User may not be empty at this context.', null, 'User', 'id')); } const request = new superagent.Request('DELETE', new url.URL(`users/${user.id}`, this.getAdminRoot())); return request.set('Authorization', `Bearer ${options.access_token}`). end(responseHander(resolve, reject)); }); } /** * @param {boolean=} force * @returns {*} */ getWellKnownConfiguration(force) { if (force) { this.well_known_configuration = null; } if (this.well_known_configuration) { return Promise.resolve(this.well_known_configuration); } return new Promise((resolve, reject) => { const well_known_configuration_uri = this.settings.well_known_configuration_uri ? new url.URL(this.settings.well_known_configuration_uri, this.getServer()) : new url.URL('.well-known/openid-configuration', this.getServer()); return new superagent.Request('GET', well_known_configuration_uri). end(responseHander(resolve, reject)); }).then((configuration) => { this.well_known_configuration = configuration; return configuration; }); } } class HttpRemoteAddrForbiddenError extends common.HttpForbiddenError { constructor() { super('Access is denied due to remote address conflict. The client network has been changed or cannot be determined.'); this.statusCode = 403.6; } } class RemoteAddressValidator extends common.ApplicationService { constructor(app) { super(app); // get proxy address forwarding option let proxyAddressForwarding = app.getConfiguration().getSourceAt('settings/universis/api/proxyAddressForwarding'); if (typeof proxyAddressForwarding !== 'boolean') { proxyAddressForwarding = false; } this.proxyAddressForwarding = proxyAddressForwarding; // get token claim name this.claim = app.getConfiguration().getSourceAt('settings/universis/janitor/remoteAddress/claim') || 'remoteAddress'; app.serviceRouter.subscribe((serviceRouter) => { if (serviceRouter == null) { return; } const addRouter = express.Router(); addRouter.use((req, res, next) => { void this.validateRemoteAddress(req).then((value) => { if (value === false) { return next(new HttpRemoteAddrForbiddenError()); } return next(); }).catch((err) => { return next(err); }); }); // insert router at the beginning of serviceRouter.stack serviceRouter.stack.unshift.apply(serviceRouter.stack, addRouter.stack); }); } /** * Gets remote address from request * @param {import('express').Request} req * @returns */ getRemoteAddress(req) { let remoteAddress; if (this.proxyAddressForwarding) { // get proxy headers or remote address remoteAddress = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || (req.connection ? req.connection.remoteAddress : req.socket.remoteAddress); } else { remoteAddress = req.connection ? req.connection.remoteAddress : req.socket.remoteAddress; } return remoteAddress; } /** * Validates token remote address with request remote address * @param {import('express').Request} req * @returns {Promise<boolean>} */ async validateRemoteAddress(req) { const authenticationToken = req.context?.user?.authenticationToken; if (authenticationToken != null) { const access_token = jwt.decode(authenticationToken); const remoteAddress = access_token[this.claim]; if (remoteAddress == null) { common.TraceUtils.warn(`Remote address validation failed. Expected a valid remote address claimed by using "${this.claim}" attribute but got none.`); return false; } // get context remote address const requestRemoteAddress = this.getRemoteAddress(req); if (remoteAddress !== requestRemoteAddress) { common.TraceUtils.warn(`Remote address validation failed. Expected remote address is ${remoteAddress || 'Uknown'} but request remote address is ${requestRemoteAddress}`); return false; } return true; } common.TraceUtils.warn('Remote address validation cannot be completed because authentication token is not available.'); return false; } } class AppRateLimitService extends RateLimitService { /** * * @param {import('@themost/common').ApplicationBase} app */ constructor(app) { super(app); } getServiceName() { return '@universis/janitor#AppRateLimitService'; } getServiceContainer() { return this.getApplication() && this.getApplication().container; } } class AppSpeedLimitService extends SpeedLimitService { /** * * @param {import('@themost/common').ApplicationBase} app */ constructor(app) { super(app); } getServiceName() { return '@universis/janitor#AppSpeedLimitService'; } getServiceContainer() { return this.getApplication() && this.getApplication().container; } } class HttpBearerTokenRequired extends common.HttpError { constructor() { super(499, 'A token is required to fulfill the request.'); this.code = 'E_TOKEN_REQUIRED'; this.title = 'Token Required'; } } class HttpBearerTokenNotFound extends common.HttpError { constructor() { super(498, 'Token was not found.'); this.code = 'E_TOKEN_NOT_FOUND'; this.title = 'Invalid token'; } } class HttpBearerTokenExpired extends common.HttpError { constructor() { super(498, 'Token was expired or is in invalid state.'); this.code = 'E_TOKEN_EXPIRED'; this.title = 'Invalid token'; } } class HttpAccountDisabled extends common.HttpForbiddenError { constructor() { super('Access is denied. User account is disabled.'); this.code = 'E_ACCOUNT_DISABLED'; this.statusCode = 403.2; this.title = 'Disabled account'; } } class HttpBearerStrategy extends BearerStrategy { constructor() { super({ passReqToCallback: true }, /** * @param {Request} req * @param {string} token * @param {Function} done */ function (req, token, done) { /** * Gets OAuth2 client services * @type {import('./OAuth2ClientService').OAuth2ClientService} */ let client = req.context.getApplication().getStrategy(function OAuth2ClientService() {}); // if client cannot be found if (client == null) { // throw configuration error return done(new Error('Invalid application configuration. OAuth2 client service cannot be found.')); } if (token == null) { // throw 499 Token Required error return done(new HttpBearerTokenRequired()); } // get token info client.getTokenInfo(req.context, token).then((info) => { if (info == null) { // the specified token cannot be found - 498 invalid token with specific code return done(new HttpBearerTokenNotFound()); } // if the given token is not active throw token expired - 498 invalid token with specific code if (!info.active) { return done(new HttpBearerTokenExpired()); } // find user from token info return function () { /** * @type {import('./services/user-provisioning-mapper-service').UserProvisioningMapperService} */ const mapper = req.context.getApplication().getService(function UserProvisioningMapperService() {}); if (mapper == null) { return req.context.model('User').where('name').equal(info.username).silent().getItem(); } return mapper.getUser(req.context, info); }().then((user) => { // check if userProvisioning service is installed and try to find related user only if user not found if (user == null) { /** * @type {import('./services/user-provisioning-service').UserProvisioningService} */ const service = req.context.getApplication().getService(function UserProvisioningService() {}); if (service == null) { return user; } return service.validateUser(req.context, info); } return user; }).then((user) => { // user cannot be found and of course cannot be authenticated (throw forbidden error) if (user == null) { // write access log for forbidden return done(new common.HttpForbiddenError()); } // check if user has enabled attribute if (Object.prototype.hasOwnProperty.call(user, 'enabled') && !user.enabled) { //if user.enabled is off throw forbidden error return done(new HttpAccountDisabled('Access is denied. User account is disabled.')); } // otherwise return user data return done(null, { 'name': user.name, 'authenticationProviderKey': user.id, 'authenticationType': 'Bearer', 'authenticationToken': token, 'authenticationScope': info.scope }); }); }).catch((err) => { // end log token info request with error if (err && err.statusCode === 404) { // revert 404 not found returned by auth server to 498 invalid token return done(new HttpBearerTokenNotFound()); } // otherwise continue with error return done(err); }); }); } } class PassportService extends common.ApplicationService { constructor(app) { super(app); const authenticator = new passport.Authenticator(); Object.defineProperty(this, 'authenticator', { configurable: true, enumerable: false, writable: false, value: authenticator }); } /** * @returns {import('passport').Authenticator} */ getInstance() { return this.authenticator; } } exports.AppRateLimitService = AppRateLimitService; exports.AppSpeedLimitService = AppSpeedLimitService; exports.DefaultScopeAccessConfiguration = DefaultScopeAccessConfiguration; exports.EnableScopeAccessConfiguration = EnableScopeAccessConfiguration; exports.ExtendScopeAccessConfiguration = ExtendScopeAccessConfiguration; exports.HttpAccountDisabled = HttpAccountDisabled; exports.HttpBearerStrategy = HttpBearerStrategy; exports.HttpBearerTokenExpired = HttpBearerTokenExpired; exports.HttpBearerTokenNotFound = HttpBearerTokenNotFound; exports.HttpBearerTokenRequired = HttpBearerTokenRequired; exports.HttpRemoteAddrForbiddenError = HttpRemoteAddrForbiddenError; exports.OAuth2ClientService = OAuth2ClientService; exports.PassportService = PassportService; exports.RateLimitService = RateLimitService; exports.RedisClientStore = RedisClientStore; exports.RemoteAddressValidator = RemoteAddressValidator; exports.ScopeAccessConfiguration = ScopeAccessConfiguration; exports.ScopeString = ScopeString; exports.SpeedLimitService = SpeedLimitService; exports.validateScope = validateScope; //# sourceMappingURL=index.js.map