Skip to content

Commit

Permalink
feat: convenient guards rewriter
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremyben committed Dec 23, 2018
1 parent deb84ab commit d41c29e
Show file tree
Hide file tree
Showing 10 changed files with 95 additions and 26 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Build output
dist

# Fixtures
db.json
routes.json

# Environment variables file
.env

Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/shared/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ 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<typeof jsonServer.rewriter> = {}
resourceGuardMap: { [resource: string]: number } = {}
) {
const app = jsonServer.create()
const router = jsonServer.router(db)
// Must bind the router db to the app like the cli does
// 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)

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 22 additions & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -51,11 +56,27 @@ const argv = yargs
.require(1, 'Missing <source> argument').argv

// Add our index path to json-server middlewares.

if (argv.middlewares) {
;(<string[]>argv.middlewares).unshift(__dirname)
} else {
;(<string[]>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)
65 changes: 53 additions & 12 deletions src/guards.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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('/')
Expand Down Expand Up @@ -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)
}

Expand All @@ -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') {
Expand All @@ -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) => {
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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<typeof jsonServer.rewriter>
)
}

/**
* 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)
}
File renamed without changes.
9 changes: 5 additions & 4 deletions src/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions tslint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

0 comments on commit d41c29e

Please sign in to comment.