@universis/janitor
Version:
Universis api plugin for handling user authorization and rate limiting
197 lines (184 loc) • 7.58 kB
JavaScript
import {ConfigurationStrategy, Args, ApplicationService} from '@themost/common';
import path from 'path';
import url from 'url';
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 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
Args.notNull(req.context,'Context');
// validate request context user
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 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 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;
}
}
}
}
}
});
}
}
}
export {
ScopeString,
ScopeAccessConfiguration,
DefaultScopeAccessConfiguration,
EnableScopeAccessConfiguration,
ExtendScopeAccessConfiguration
}