From 2c15f0ba2356480fe7df1c1135e3f7ffe6b21490 Mon Sep 17 00:00:00 2001 From: Sebastian Seedorf Date: Sun, 15 Nov 2020 22:03:09 +0100 Subject: [PATCH] Added Permission Middleware, Renaming --- package-lock.json | 40 ++++++++++++ package.json | 3 +- public/js-source/.eslintrc.js | 1 + src/.eslintrc.js | 2 + src/app.ts | 24 ++----- src/utils/auth-proxy.ts | 6 +- src/utils/auto-reload.ts | 109 +++++++++++++++---------------- src/utils/config.ts | 21 +++--- src/utils/index.ts | 8 ++- src/utils/permissions.ts | 85 ++++++++++++++++++++++++ src/utils/polyfill.ts | 9 ++- src/utils/redis.ts | 2 + src/utils/session.ts | 28 ++++++++ src/utils/types/permissions.d.ts | 5 ++ tsconfig.json | 8 +-- 15 files changed, 256 insertions(+), 95 deletions(-) create mode 100644 src/utils/permissions.ts create mode 100644 src/utils/session.ts create mode 100644 src/utils/types/permissions.d.ts diff --git a/package-lock.json b/package-lock.json index e6f58f1..5421eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d2a1c46..2ecb397 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/js-source/.eslintrc.js b/public/js-source/.eslintrc.js index 401a44e..e19549d 100644 --- a/public/js-source/.eslintrc.js +++ b/public/js-source/.eslintrc.js @@ -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", diff --git a/src/.eslintrc.js b/src/.eslintrc.js index 2ce8160..d9f4df8 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -33,6 +33,8 @@ module.exports = { "comma-dangle": ["error", "always-multiline"], "comma-style": "error", "semi": "error", + "no-implicit-coercion": "error", + "promise/always-return": "off", }, }; diff --git a/src/app.ts b/src/app.ts index 1a26b5d..d30f384 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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'), diff --git a/src/utils/auth-proxy.ts b/src/utils/auth-proxy.ts index 95f01f4..ef65e69 100644 --- a/src/utils/auth-proxy.ts +++ b/src/utils/auth-proxy.ts @@ -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, +}; diff --git a/src/utils/auto-reload.ts b/src/utils/auto-reload.ts index ff6375a..60f4502 100644 --- a/src/utils/auto-reload.ts +++ b/src/utils/auto-reload.ts @@ -1,64 +1,63 @@ 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) { - let uuid = v4(); - let updateTimeout: Timeout|undefined = undefined; - import("node-watch").then((watch) => { - watch.default('public', {recursive: true}, (evt, name) => { - if (updateTimeout !== undefined) clearTimeout(updateTimeout); - updateTimeout = setTimeout(() => { - uuid = v4(); - }, 200); - }); - }).catch((err) => { Logger.error(err); }); - - reloadRouter.get("/auto-reload/client.js", (req, res) => { - Logger.debug(req.url, req.originalUrl, req.baseUrl); - res.setHeader('Content-Type', "application/javascript"); - // language=JavaScript - res.send(` - const loc = window.location; - const url = loc.protocol+'//'+loc.host+'${urlJoin(req.baseUrl, "/auto-reload")}'; - // const parse = async res => (await res.json()).uuid; - const parse = function(res) { - return res.json() - .then(function(json) {return json.uuid;}) } - let hash = undefined; - let hadError = false; - setInterval(function() { - try { - fetch(url) - .then(function(res) { return parse(res) }) - .then(function(data) { - if (data) { - hash = hash === undefined ? data : hash; - if (hash !== data) { - window.location.reload(); - } - } - }); - } catch (e) { - if (hadError === false) { - console.log(e); - hadError = true; - } - } - }, 3000); - `.replace(/\t/g, "")); +const router = Router(); +if (!Config.isProduction) { + let uuid = v4(); + let updateTimeout: Timeout|undefined = undefined; + import("node-watch").then((watch) => { + watch.default('public', {recursive: true}, () => { + if (updateTimeout !== undefined) clearTimeout(updateTimeout); + updateTimeout = setTimeout(() => { + uuid = v4(); + }, 200); }); + }).catch((err) => { Logger.error(err); }); - reloadRouter.get("/auto-reload", (req, res) => { - req.noLogging = true; - res.json({uuid}); - }); - } + router.get("/auto-reload/client.js", (req, res) => { + Logger.debug(req.url, req.originalUrl, req.baseUrl); + res.setHeader('Content-Type', "application/javascript"); + // language=JavaScript + res.send(` + const loc = window.location; + const url = loc.protocol+'//'+loc.host+'${urlJoin(req.baseUrl, "/auto-reload")}'; + // const parse = async res => (await res.json()).uuid; + const parse = function(res) { + return res.json() + .then(function(json) {return json.uuid;}) } + let hash = undefined; + let hadError = false; + setInterval(function() { + try { + fetch(url) + .then(function(res) { return parse(res) }) + .then(function(data) { + if (data) { + hash = hash === undefined ? data : hash; + if (hash !== data) { + window.location.reload(); + } + } + }); + } catch (e) { + if (hadError === false) { + console.log(e); + hadError = true; + } + } + }, 3000); + `.replace(/\t/g, "")); + }); - return reloadRouter; + router.get("/auto-reload", (req, res) => { + req.noLogging = true; + res.json({uuid}); + }); } + +export const AutoReloader = { + router, +}; diff --git a/src/utils/config.ts b/src/utils/config.ts index d396065..1e134a3 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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, + EXTERNAL_BASE_URL: + envs.EXTERNAL_BASE_URL || + urlJoin(`http://${envs.HOSTNAME}${envs.PORT !== 80 ? `:${envs.PORT}` : ""}`, envs.BASE_PATH), isProduction, - EXTERNAL_BASE_URL: - envs.EXTERNAL_BASE_URL || - urlJoin(`http://${envs.HOSTNAME}${envs.PORT !== 80 ? `:${envs.PORT}` : ""}`, envs.BASE_PATH), -} + requireEnv, +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index a62f0c3..10c431e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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'; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..ee02af4 --- /dev/null +++ b/src/utils/permissions.ts @@ -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): 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 { + 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(); diff --git a/src/utils/polyfill.ts b/src/utils/polyfill.ts index 22894aa..08737a2 100644 --- a/src/utils/polyfill.ts +++ b/src/utils/polyfill.ts @@ -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(); .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, +}; diff --git a/src/utils/redis.ts b/src/utils/redis.ts index 75fdb41..109c920 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -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} = {}; diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 0000000..608aa77 --- /dev/null +++ b/src/utils/session.ts @@ -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): 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, +}; diff --git a/src/utils/types/permissions.d.ts b/src/utils/types/permissions.d.ts new file mode 100644 index 0000000..49ed626 --- /dev/null +++ b/src/utils/types/permissions.d.ts @@ -0,0 +1,5 @@ +declare namespace Express { + interface Request { + permissionDetails?: import('role-acl').Permission; + } +} diff --git a/tsconfig.json b/tsconfig.json index 0e75896..4404f06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" ] }