From d41c29e6fb49a51df278f4ecfbe268c94596955b Mon Sep 17 00:00:00 2001 From: jeremy Date: Sun, 23 Dec 2018 21:43:52 +0100 Subject: [PATCH] feat: convenient guards rewriter --- .gitignore | 4 ++ .vscode/settings.json | 1 + src/__tests__/guards.ts | 8 +-- src/__tests__/shared/tools.ts | 5 +- src/__tests__/users.ts | 2 +- src/bin.ts | 23 ++++++++- src/guards.ts | 65 +++++++++++++++++++----- src/{shared.ts => shared-middlewares.ts} | 0 src/users.ts | 9 ++-- tslint.json | 4 +- 10 files changed, 95 insertions(+), 26 deletions(-) rename src/{shared.ts => shared-middlewares.ts} (100%) diff --git a/.gitignore b/.gitignore index c6dc891..a6e5b13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Build output dist +# Fixtures +db.json +routes.json + # Environment variables file .env diff --git a/.vscode/settings.json b/.vscode/settings.json index 12d7c78..5335d25 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "editor.formatOnSave": true, "npm.packageManager": "yarn", "prettier.requireConfig": true, + "prettier.disableLanguages": ["markdown"], "rest-client.enableTelemetry": false, "rest-client.defaultHeaders": { "User-Agent": "vscode-restclient", diff --git a/src/__tests__/guards.ts b/src/__tests__/guards.ts index ed0bef4..b8e93d1 100644 --- a/src/__tests__/guards.ts +++ b/src/__tests__/guards.ts @@ -9,11 +9,11 @@ beforeEach(async () => { users: [{ id: 1, email: 'albert@gmail.com' }], messages: [{ id: 1, text: 'other', userId: 1 }, { id: 2, text: 'mine', userId: 2 }], } - const rules = { - '/users*': '/600/users$1', - '/messages*': '/640/messages$1', + const guards = { + users: 600, + messages: 640, } - const app = inMemoryJsonServer(db, rules) + const app = inMemoryJsonServer(db, guards) rq = supertest(app) // Create user (will have id:2) and keep access token diff --git a/src/__tests__/shared/tools.ts b/src/__tests__/shared/tools.ts index 0160ebb..3d75bf7 100644 --- a/src/__tests__/shared/tools.ts +++ b/src/__tests__/shared/tools.ts @@ -2,12 +2,13 @@ import { writeFileSync, unlinkSync } from 'fs' import { join } from 'path' import * as jsonServer from 'json-server' import * as jsonServerAuth from '../..' +import { rewriter } from '../../guards' export const USER = { email: 'jeremy@mail.com', password: '123456', name: 'Jeremy' } export function inMemoryJsonServer( db: object = {}, - rules: ArgumentType = {} + resourceGuardMap: { [resource: string]: number } = {} ) { const app = jsonServer.create() const router = jsonServer.router(db) @@ -15,7 +16,7 @@ export function inMemoryJsonServer( // https://github.com/typicode/json-server/blob/master/src/cli/run.js#L74 app['db'] = router['db'] - app.use(jsonServer.rewriter(rules)) + app.use(rewriter(resourceGuardMap)) app.use(jsonServerAuth) app.use(router) diff --git a/src/__tests__/users.ts b/src/__tests__/users.ts index b6c9550..da67eec 100644 --- a/src/__tests__/users.ts +++ b/src/__tests__/users.ts @@ -56,7 +56,7 @@ describe('Login user', () => { return rq .post('/login') .send({ ...USER, email: 'arthur@mail.com' }) - .expect(400, /incorrect email/i) + .expect(400, /cannot find user/i) }) test('[SAD] Wrong password', () => { diff --git a/src/bin.ts b/src/bin.ts index ee0381d..f9c4643 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,6 +3,11 @@ // tslint:disable:no-submodule-imports import * as yargs from 'yargs' import * as jsonServerPkg from 'json-server/package.json' +import { tmpdir } from 'os' +import { readFileSync, writeFileSync } from 'fs' +import { join, basename } from 'path' +import { parseGuardsRules } from './guards' + import run = require('json-server/lib/cli/run') // Get the json-server cli module and add our middlewares. @@ -51,11 +56,27 @@ const argv = yargs .require(1, 'Missing argument').argv // Add our index path to json-server middlewares. - if (argv.middlewares) { ;(argv.middlewares).unshift(__dirname) } else { ;(argv.middlewares) = [__dirname] } +// Adds guards to json-server routes. +// We are forced to create an intermediary file: +// https://github.com/typicode/json-server/blob/master/src/cli/run.js#L109 +if (argv.routes) { + let routes = JSON.parse(readFileSync(argv.routes, 'utf8')) + routes = parseGuardsRules(routes) + routes = JSON.stringify(routes) + + const tmpFilepath = join(tmpdir(), `routes-from-${basename(process.cwd())}.json`) + writeFileSync(tmpFilepath, routes, 'utf8') + + argv.routes = tmpFilepath +} +// But we won't be able to properly watch and reload custom routes: +// https://github.com/typicode/json-server/blob/master/src/cli/run.js#L229 + +// launch json-server run(argv) diff --git a/src/guards.ts b/src/guards.ts index cc6ae85..566e1c3 100644 --- a/src/guards.ts +++ b/src/guards.ts @@ -1,11 +1,13 @@ import { RequestHandler, Router } from 'express' import * as jwt from 'jsonwebtoken' +import * as jsonServer from 'json-server' import { stringify } from 'querystring' import { JWT_SECRET_KEY } from './constants' -import { bodyParsingHandler, errorHandler, goNext } from './shared' +import { bodyParsingHandler, errorHandler, goNext } from './shared-middlewares' /** - * Logged Guard + * Logged Guard. + * Check JWT. */ const loggedOnly: RequestHandler = (req, res, next) => { const { authorization } = req.headers @@ -38,9 +40,9 @@ const loggedOnly: RequestHandler = (req, res, next) => { } /** - * Owner Guard - * Checking userId reference in the request or the resource - * Inherits from logged guard + * Owner Guard. + * Checking userId reference in the request or the resource. + * Inherits from logged guard. */ // tslint:disable:triple-equals - so we simply compare resource id (integer) with jwt sub (string) const privateOnly: RequestHandler = (req, res, next) => { @@ -50,7 +52,6 @@ const privateOnly: RequestHandler = (req, res, next) => { throw Error('You must bind the router db to the app') } - // console.log('private only, claims', req.claims) // TODO: handle query params instead of removing them const path = req.url.replace(`?${stringify(req.query)}`, '') const [, mod, resource, id] = path.split('/') @@ -88,6 +89,10 @@ const privateOnly: RequestHandler = (req, res, next) => { hasRightUserId = userId == req.claims!.sub } else { const entities = db.get(resource).value() as any[] + + // TODO: Array.every() for properly secured access. + // Array.some() is too relax, but maybe useful for prototyping usecase. + // But first we must handle the query params. hasRightUserId = entities.some((entity) => entity.userId == req.claims!.sub) } @@ -110,7 +115,7 @@ const privateOnly: RequestHandler = (req, res, next) => { // tslint:enable /** - * + * Forbid all methods except GET. */ const readOnly: RequestHandler = (req, res, next) => { if (req.method === 'GET') { @@ -125,7 +130,8 @@ type ReadWriteBranch = ({ read, write }: { read: RequestHandler, write: RequestHandler }) => RequestHandler /** - * + * Allow applying a different middleware for GET request (read) and others (write) + * (middleware returning a middleware) */ const branch: ReadWriteBranch = ({ read, write }) => { return (req, res, next) => { @@ -138,7 +144,7 @@ const branch: ReadWriteBranch = ({ read, write }) => { } /** - * + * Remove guard mod from baseUrl, so lowdb can handle the resource. */ const flattenUrl: RequestHandler = (req, res, next) => { // req.url is writable and used for redirection, @@ -147,11 +153,14 @@ const flattenUrl: RequestHandler = (req, res, next) => { // so we can rewrite it. // https://stackoverflow.com/questions/14125997/ - req.url = req.url.replace(/\/[0-9]{3}/, '') + req.url = req.url.replace(/\/[640]{3}/, '') next() } -const guardsRouter = Router() +/** + * Guards router + */ +export default Router() .use(bodyParsingHandler) .all('/666/*', flattenUrl) .all('/664/*', branch({ read: goNext, write: loggedOnly }), flattenUrl) @@ -164,4 +173,36 @@ const guardsRouter = Router() .all('/400/*', privateOnly, readOnly, flattenUrl) .use(errorHandler) -export default guardsRouter +/** + * Transform resource-guard mapping to proper rewrite rule supported by express-urlrewrite. + * Return other rewrite rules as is, so we can use both types in routes.json. + * @example + * { 'users': 600 } => { '/users*': '/600/users$1' } + */ +export function parseGuardsRules(resourceGuardMap: { [resource: string]: any }) { + return Object.entries(resourceGuardMap).reduce( + (routes, [resource, guard]) => { + const isGuard = /^[640]{3}$/m.test(String(guard)) + + if (isGuard) { + routes[`/${resource}*`] = `/${guard}/${resource}$1` + } else { + // Return as is if not a guard + routes[resource] = guard + } + + return routes + }, + {} as ArgumentType + ) +} + +/** + * Conveniant method to use directly resource-guard mapping + * with JSON Server rewriter (which itself uses express-urlrewrite). + * Works with normal rewrite rules as well. + */ +export function rewriter(resourceGuardMap: { [resource: string]: number }) { + const routes = parseGuardsRules(resourceGuardMap) + return jsonServer.rewriter(routes) +} diff --git a/src/shared.ts b/src/shared-middlewares.ts similarity index 100% rename from src/shared.ts rename to src/shared-middlewares.ts diff --git a/src/users.ts b/src/users.ts index 797e5de..6767643 100644 --- a/src/users.ts +++ b/src/users.ts @@ -8,7 +8,7 @@ import { MIN_PASSWORD_LENGTH, SALT_LENGTH, } from './constants' -import { bodyParsingHandler, errorHandler } from './shared' +import { bodyParsingHandler, errorHandler } from './shared-middlewares' /** * User Interface @@ -140,12 +140,13 @@ const update: RequestHandler = (req, res, next) => { next() } -const usersRouter = Router() +/** + * Users router + */ +export default Router() .use(bodyParsingHandler) .post('/users|register|signup', create) .post('/login|signin', login) .put('/users/:id', update) .patch('/users/:id', update) .use(errorHandler) - -export default usersRouter diff --git a/tslint.json b/tslint.json index 74bf868..7f14d31 100644 --- a/tslint.json +++ b/tslint.json @@ -12,7 +12,7 @@ "ordered-imports": false, "object-literal-sort-keys": false, "curly": false, - "no-reference": false, - "no-implicit-dependencies": [true, "dev"] + "no-implicit-dependencies": [true, "dev"], + "no-object-literal-type-assertion": false } }