@universis/janitor
Version:
Universis api plugin for handling user authorization and rate limiting
1,361 lines (1,273 loc) • 46.8 kB
JavaScript
'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