Added Permission Middleware, Renaming

This commit is contained in:
Sebastian Seedorf
2020-11-15 22:03:09 +01:00
parent da4cda2507
commit 2c15f0ba23
15 changed files with 256 additions and 95 deletions

40
package-lock.json generated
View File

@@ -2539,6 +2539,11 @@
}
}
},
"jsonpath-plus": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-0.18.1.tgz",
"integrity": "sha512-4yQiuV641HROc4z9YGvnsr8yAdmzbu8JjdAen8WfiXsXewKvTG4ie2bSygF2maek9PcbtROmS6aLQs31BD+oNQ=="
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
@@ -2620,6 +2625,16 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
},
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"lodash.flattendeep": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
"integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI="
},
"logform": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
@@ -2673,6 +2688,14 @@
"integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=",
"dev": true
},
"matcher": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-1.1.1.tgz",
"integrity": "sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==",
"requires": {
"escape-string-regexp": "^1.0.4"
}
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -2923,6 +2946,11 @@
"validate-npm-package-license": "^3.0.1"
}
},
"notation": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/notation/-/notation-1.3.6.tgz",
"integrity": "sha512-DIuJmrP/Gg1DcXKaApsqcjsJD6jEccqKSfmU3BUx/f1GHsMiTJh70cERwYc64tOmTRTARCeMwkqNNzjh3AHhiw=="
},
"npmlog": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
@@ -3598,6 +3626,18 @@
"glob": "^7.1.3"
}
},
"role-acl": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/role-acl/-/role-acl-4.5.4.tgz",
"integrity": "sha512-T7baCc5BCFzLaxUrtAEIDcfK8HM/WTt6l2SLiMts6zLADSp7wOrrU3op0HVKhwjKi01d0Q6T4NeZ0jdLii9WuQ==",
"requires": {
"jsonpath-plus": "^0.18.0",
"lodash.clonedeep": "^4.5.0",
"lodash.flattendeep": "^4.4.0",
"matcher": "^1.0.0",
"notation": "^1.3.5"
}
},
"run-parallel": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz",

View File

@@ -7,7 +7,7 @@
"lint-fix": "eslint . --ext .ts --fix",
"update-client-hash": "node -e \"require('fs').writeFileSync('public/misc/hash.txt', require('randomstring').generate())\"",
"debug-client": "tsc-watch --project ./public/js-source",
"debug-server": "tsc-watch --onSuccess \"node --enable-source-maps --use-openssl-ca --unhandled-rejections=strict ./out/index\"",
"debug-server": "tsc-watch --project ./src --onSuccess \"node --enable-source-maps --use-openssl-ca --unhandled-rejections=strict ./out/index\"",
"debug": "concurrently npm:debug-*",
"build": "tsc",
"production": "node --use-openssl-ca --unhandled-rejections=strict ./out/index",
@@ -31,6 +31,7 @@
"proper-url-join": "^2.1.1",
"pug": "^3.0.0",
"redis": "^3.0.2",
"role-acl": "^4.5.4",
"threads": "^1.6.3",
"tiny-worker": "^2.3.0",
"uuid": "^8.3.1",

View File

@@ -31,6 +31,7 @@ module.exports = {
"comma-dangle": ["error", "always-multiline"],
"comma-style": "error",
"semi": "error",
"no-implicit-coercion": "error",
"no-restricted-imports": ["error",
"assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns", "domain", "events", "freelist",

View File

@@ -33,6 +33,8 @@ module.exports = {
"comma-dangle": ["error", "always-multiline"],
"comma-style": "error",
"semi": "error",
"no-implicit-coercion": "error",
"promise/always-return": "off",
},
};

View File

@@ -2,13 +2,10 @@ import * as createError from 'http-errors';
import * as express from 'express';
import {NextFunction, Request, Response} from 'express';
import * as path from 'path';
import * as redisStore from 'connect-redis';
import * as session from 'express-session';
import * as sassMiddleware from 'node-sass-middleware';
import * as compression from 'compression';
import indexRouter from './routes';
import {HttpLogger, Redis, Config, setupAuthProxy, getReloadRouter, polyfillRoute} from './utils';
import {Store} from 'express-session';
import {HttpLogger, Config, AuthProxy, AutoReloader, Polyfill, Session} from './utils';
export const app = express();
@@ -31,26 +28,15 @@ app.use(express.urlencoded({extended: false}));
app.use(compression());
// auth proxy middleware
router.use(setupAuthProxy);
router.use(AuthProxy.router);
// auto reloader (when running in debug mode)
router.use(getReloadRouter());
router.use(AutoReloader.router);
// session
let sessionStore: Store|undefined = undefined;
if (Redis.client) {
const RedisStore = redisStore(session);
sessionStore = new RedisStore({client: Redis.client});
}
router.use(session({
store: sessionStore,
secret: Config.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: {secure: false},
}));
router.use(Session.getRouter());
// static config
router.use("/js/polyfill.js", polyfillRoute);
router.use("/js/polyfill.js", Polyfill.router);
router.use(sassMiddleware({
src: path.join(__dirname, '../public'),
dest: path.join(__dirname, '../public'),

View File

@@ -4,7 +4,7 @@ import {Resolvable} from './helpers/resolvable';
import fetch from 'node-fetch';
import {urlJoin} from './helpers/urlJoin';
export const setupAuthProxy: RequestHandler = (req: Request, res, next) => {
const router: RequestHandler = (req: Request, res, next) => {
const resolvable = new Resolvable(async () => {
if (!Config.USERINFO_HEADER) {
return undefined;
@@ -34,3 +34,7 @@ export const setupAuthProxy: RequestHandler = (req: Request, res, next) => {
};
next();
};
export const AuthProxy = {
router,
};

View File

@@ -1,17 +1,15 @@
import {Router} from 'express';
import {Config} from './config';
import {Logger} from './logging';
import {Config, Logger} from '.';
import {urlJoin} from './helpers/urlJoin';
import {v4} from 'uuid';
import Timeout = NodeJS.Timeout;
export function getReloadRouter(): Router {
const reloadRouter = Router();
if (!Config.isProduction) {
const router = Router();
if (!Config.isProduction) {
let uuid = v4();
let updateTimeout: Timeout|undefined = undefined;
import("node-watch").then((watch) => {
watch.default('public', {recursive: true}, (evt, name) => {
watch.default('public', {recursive: true}, () => {
if (updateTimeout !== undefined) clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
uuid = v4();
@@ -19,7 +17,7 @@ export function getReloadRouter(): Router {
});
}).catch((err) => { Logger.error(err); });
reloadRouter.get("/auto-reload/client.js", (req, res) => {
router.get("/auto-reload/client.js", (req, res) => {
Logger.debug(req.url, req.originalUrl, req.baseUrl);
res.setHeader('Content-Type', "application/javascript");
// language=JavaScript
@@ -54,11 +52,12 @@ export function getReloadRouter(): Router {
`.replace(/\t/g, ""));
});
reloadRouter.get("/auto-reload", (req, res) => {
router.get("/auto-reload", (req, res) => {
req.noLogging = true;
res.json({uuid});
});
}
return reloadRouter;
}
export const AutoReloader = {
router,
};

View File

@@ -16,10 +16,10 @@ const envs = {
// external base url
EXTERNAL_BASE_URL: env.get('EXTERNAL_BASE_URL').asString(),
// url of redis session store
REDIS_URL: env.get('REDIS_URL').required(isProduction).asString() || undefined,
// cookie secret for the session id
SESSION_SECRET: env.get('REDIS_URL').required(isProduction).default('keyboard cat').asString(),
// url of redis session store (required in production using InMemory)
REDIS_URL: env.get('REDIS_URL').asString() || undefined,
// cookie secret for the session id (required in production using Session)
SESSION_SECRET: env.get('SESSION_SECRET').asString() || undefined,
// header where user info token is stored to request auth proxy
USERINFO_HEADER: env.get('USERINFO_HEADER').asString() || undefined,
@@ -29,14 +29,17 @@ const envs = {
AUTH_PROXY_USERINFO_URL: env.get('AUTH_PROXY_USERINFO_URL').asString() || undefined,
// override base url to init a logout
AUTH_PROXY_INIT_LOGOUT_URL: env.get('AUTH_PROXY_INIT_LOGOUT_URL').asString() || undefined,
};
function requireEnv(name: string, onlyInProduction = false): void {
env.get(name).required(!onlyInProduction || isProduction);
}
export const Config = {
...envs,
isProduction,
EXTERNAL_BASE_URL:
envs.EXTERNAL_BASE_URL ||
urlJoin(`http://${envs.HOSTNAME}${envs.PORT !== 80 ? `:${envs.PORT}` : ""}`, envs.BASE_PATH),
}
isProduction,
requireEnv,
};

View File

@@ -1,7 +1,9 @@
export {Config} from './config';
export {Redis} from './redis';
export {Logger, HttpLogger} from './logging';
export {setupAuthProxy} from './auth-proxy';
export {AuthProxy} from './auth-proxy';
export {Resolvable, WaitForSync} from './helpers/resolvable';
export {getReloadRouter} from './auto-reload';
export {polyfillRoute} from './polyfill';
export {AutoReloader} from './auto-reload';
export {Polyfill} from './polyfill';
export {Session} from './session';
export {Permissions} from './permissions';

85
src/utils/permissions.ts Normal file
View File

@@ -0,0 +1,85 @@
import {AccessControl, AccessControlError, IQueryInfo, Permission, Query} from 'role-acl';
import {Request, RequestHandler} from 'express';
// see https://www.npmjs.com/package/role-acl
class PermissionManager extends AccessControl {
public can(roleOrRequest: Request|string|string[]|IQueryInfo): PermQuery {
return new PermQuery(this.getGrants(), roleOrRequest);
}
public getRouter(resource: string, opts: Partial<RermRouterOpts>): RequestHandler {
return async (req: Request, res, next) => {
let query = this.can(req);
if (opts.context)
query = query.context(opts.context);
if (opts.action)
query = query.execute(opts.action);
if (opts.skipConditions)
query = query.skipConditions(opts.skipConditions);
const permission = await query.on(resource);
if (permission.granted) {
req.permissionDetails = permission;
next();
} else {
res.sendStatus(403);
}
};
}
}
export type RermRouterOpts = {
context: unknown,
action: string,
skipConditions: boolean
}
export class PermQuery extends Query {
protected resolveRequest: Request|undefined;
constructor(grants: unknown, roleOrRequest: Request|string|string[]|IQueryInfo) {
function isRequest(obj: unknown): obj is Request {
// eslint-disable-next-line no-prototype-builtins
return typeof obj === 'object' && obj && obj.hasOwnProperty('path') || false;
}
if (isRequest(roleOrRequest)) {
super(grants, []);
this.resolveRequest = roleOrRequest;
} else {
super(grants, roleOrRequest);
}
}
public async on(resource: string, skipConditions?: boolean): Promise<Permission> {
if (this.resolveRequest) {
const userInfo = await this.resolveRequest.getUserInfo();
this.role(userInfo?.groups ?? []);
}
return super.on(resource, skipConditions);
}
public context(context: unknown): PermQuery {
super.context(context);
return this;
}
public skipConditions(value: boolean): PermQuery {
super.skipConditions(value);
return this;
}
public with(context: unknown): PermQuery {
super.with(context);
return this;
}
public execute(action: string): PermQuery {
super.execute(action);
return this;
}
public sync(): PermQuery {
throw new AccessControlError("Sync method is not allowed on PermissionManager!");
}
}
export const Permissions = new PermissionManager();

View File

@@ -1,8 +1,7 @@
import {RequestHandler} from 'express';
import * as polyfillLibrary from 'polyfill-library';
import {Config} from './config';
import {Config, Logger} from '.';
import {PolyfillFeatureList} from 'polyfill-library';
import {Logger} from './logging';
import {WaitForSync} from './helpers/resolvable';
import {spawn, Thread, Worker} from 'threads';
import {WorkerFunction} from 'threads/dist/types/worker';
@@ -23,7 +22,7 @@ const features = new WaitForSync<PolyfillFeatureList>();
.catch(err => features.setError(err));
export const polyfillRoute: RequestHandler = async (req, res) => {
const router: RequestHandler = async (req, res) => {
const polyfillBundle = await polyfillLibrary.getPolyfillString({
uaString: req.header("user-agent"),
features: await features.resolve(),
@@ -34,3 +33,7 @@ export const polyfillRoute: RequestHandler = async (req, res) => {
res.setHeader('Content-Type', 'text/javascript');
res.send(polyfillBundle);
};
export const Polyfill = {
router: router,
};

View File

@@ -2,6 +2,8 @@ import * as redis from 'redis';
import {Config} from '.';
import {promisify} from 'util';
Config.requireEnv('REDIS_URL', true);
const redisClient = Config.REDIS_URL && redis.createClient({url: Config.REDIS_URL});
const inMemory: {[key: string]: string} = {};

28
src/utils/session.ts Normal file
View File

@@ -0,0 +1,28 @@
import {Store} from 'express-session';
import {Redis, Config} from '.';
import * as redisStore from "connect-redis";
import * as session from 'express-session';
import {RequestHandler} from 'express';
Config.requireEnv('SESSION_SECRET', true);
let sessionStore: Store|undefined = undefined;
function getRouter(options?: Partial<session.SessionOptions>): RequestHandler {
if (Redis.client && sessionStore !== undefined) {
const RedisStore = redisStore(session);
sessionStore = new RedisStore({client: Redis.client});
}
return session({
store: sessionStore,
secret: Config.SESSION_SECRET || 'keyboard cat',
resave: false,
saveUninitialized: true,
cookie: {secure: false},
...options,
});
}
export const Session = {
getRouter,
};

5
src/utils/types/permissions.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace Express {
interface Request {
permissionDetails?: import('role-acl').Permission;
}
}

View File

@@ -3,16 +3,16 @@
"module": "commonjs",
"target": "es6",
"sourceMap": true,
"outDir": "out",
"outDir": "./out",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"strict": true
},
"include": [
"src/**/*.ts"
"./src/**/*.ts"
],
"exclude": [
"node_modules",
"public"
"./node_modules",
"./public"
]
}