├── .gitignore ├── .prettierrc.yml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.MD ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── cli.ts │ ├── guards.ts │ ├── postman.http │ ├── shared │ │ └── tools.ts │ └── users.ts ├── bin.ts ├── constants.ts ├── guards.ts ├── index.ts ├── shared-middlewares.ts ├── types.d.ts └── users.ts ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | dist 3 | 4 | # Fixtures 5 | db.json 6 | routes.json 7 | 8 | # Environment variables file 9 | .env 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Dependencies directory 19 | node_modules/ 20 | 21 | # Output of 'npm pack' 22 | *.tgz 23 | 24 | # Yarn Integrity file 25 | .yarn-integrity 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # OS files 31 | .DS_Store 32 | Thumbs.db 33 | [Dd]esktop.ini 34 | 35 | # Vscode 36 | .vscode/* 37 | !.vscode/settings.json 38 | !.vscode/tasks.json 39 | !.vscode/launch.json 40 | !.vscode/extensions.json 41 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # https://prettier.io/docs/en/options.html 2 | 3 | printWidth: 100 4 | tabWidth: 4 5 | useTabs: true 6 | semi: false 7 | singleQuote: true 8 | trailingComma: "es5" 9 | bracketSpacing: true 10 | arrowParens: "always" 11 | proseWrap: "preserve" 12 | endOfLine: "lf" 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "ms-vscode.vscode-typescript-tslint-plugin", 5 | "humao.rest-client" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "npm.packageManager": "yarn", 4 | "prettier.requireConfig": true, 5 | "prettier.disableLanguages": ["markdown"], 6 | "rest-client.enableTelemetry": false, 7 | "rest-client.defaultHeaders": { 8 | "User-Agent": "vscode-restclient", 9 | "Accept-Encoding": "gzip", 10 | "Content-Type": "application/json" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [2.1.0](https://github.com/jeremyben/json-server-auth/compare/v2.0.2...v2.1.0) (2021-07-21) 6 | 7 | 8 | ### Features 9 | 10 | * register and login return user data besides access token ([5af16c9](https://github.com/jeremyben/json-server-auth/commit/5af16c940e8a41b0bd81c478813827561eb2d5b9)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * bin path and peer dependency version ([89af19e](https://github.com/jeremyben/json-server-auth/commit/89af19ef636136d8db389879857282cfc6a1636f)) 16 | * put and patch on user collection ([4382ef1](https://github.com/jeremyben/json-server-auth/commit/4382ef1a41dfa90734719eb2cc163c355ec0d733)) 17 | 18 | ### [2.0.2](https://github.com/jeremyben/json-server-auth/compare/v2.0.1...v2.0.2) (2020-06-01) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * allow other HTTP methods like OPTIONS to pass through guards ([7b8d1a0](https://github.com/jeremyben/json-server-auth/commit/7b8d1a0fe9d12b4d527b3a795d4aed9fdcf07961)) 24 | 25 | ### [2.0.1](https://github.com/jeremyben/json-server-auth/compare/v2.0.0...v2.0.1) (2020-06-01) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * allow user creation on users route after setting guards ([a29ec45](https://github.com/jeremyben/json-server-auth/commit/a29ec452141f79fc5967538d4a852b3462f2b928)) 31 | 32 | ## [2.0.0](https://github.com/jeremyben/json-server-auth/compare/v1.2.1...v2.0.0) (2020-05-31) 33 | 34 | 35 | ### ⚠ BREAKING CHANGES 36 | 37 | * well it's no longer a direct dependency 38 | 39 | ### Bug Fixes 40 | 41 | * LF end of line ([ed2483c](https://github.com/jeremyben/json-server-auth/commit/ed2483c53e6082f5beed6c758f9080218643ecbd)) 42 | * move json-server as a peer depency, remove express as direct dependency (already in json-server) ([a4edcdc](https://github.com/jeremyben/json-server-auth/commit/a4edcdcdffcbb2015a43f5cb8c80190a112e5a41)) 43 | 44 | ### [1.2.1](https://github.com/jeremyben/json-server-auth/compare/v1.2.0...v1.2.1) (2019-06-15) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * unique constraint on user email ([eb47b25](https://github.com/jeremyben/json-server-auth/commit/eb47b252612628b03821876db62bf6ed5ba1490f)) 50 | 51 | ### [1.2.0](https://github.com/jeremyben/json-server-auth/compare/v1.1.0...v1.2.0) (2019-05-11) 52 | 53 | 54 | ### Features 55 | 56 | * custom rewriter is now accessible from the module root ([4fbdf70](https://github.com/jeremyben/json-server-auth/commit/4fbdf70bc72119ff79ee8a686162e62020a64bb8)) 57 | 58 | ### Bug Fixes 59 | 60 | * compiler errors and missing flag in cli bin ([51546eb](https://github.com/jeremyben/json-server-auth/commit/51546ebe05f1d1300b6debc7eb5e0850f4fd1add)) 61 | * users put route would not validate properly email and password ([d3b73d3](https://github.com/jeremyben/json-server-auth/commit/d3b73d3f6e9d5479de6ba6a07f0eabf17f0c2cf8)) 62 | 63 | ### [1.1.0](https://github.com/jeremyben/json-server-auth/compare/v1.0.0...v1.1.0) (2018-12-24) 64 | 65 | 66 | ### Features 67 | 68 | * convenient guards rewriter ([d41c29e](https://github.com/jeremyben/json-server-auth/commit/d41c29e6fb49a51df278f4ecfbe268c94596955b)) 69 | 70 | ## 1.0.0 (2018-12-17) 71 | 72 | 73 | ### Features 74 | 75 | * users router, guards router, cli ([deb84ab](https://github.com/jeremyben/json-server-auth/commit/deb84abd65fcc95c10c6b3e5968d9495e4acf0d4)) 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jeremy Bensimon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # 🔐 JSON Server Auth 2 | 3 | JWT authentication middleware for **[JSON Server](https://github.com/typicode/json-server)** 4 | 5 | Because you also need a fake **authentication & authorization flow** for your prototyping. 6 | 7 | ## Getting started 8 | 9 | Install **both** JSON Server and JSON Server Auth : 10 | 11 | ```bash 12 | # NPM 13 | npm install -D json-server json-server-auth 14 | 15 | # Yarn 16 | yarn add -D json-server json-server-auth 17 | ``` 18 | 19 | Create a `db.json` file with a `users` collection : 20 | 21 | ```json 22 | { 23 | "users": [] 24 | } 25 | ``` 26 | 27 | Start JSON server (with _JSON server Auth_ as middleware) : 28 | 29 | ```bash 30 | json-server db.json -m ./node_modules/json-server-auth 31 | # with json-server installed globally and json-server-auth installed locally 32 | ``` 33 | 34 | ##### 📢 but wait ! 35 | 36 | As a convenience, **`json-server-auth`** CLI exposes `json-server` bundled with its middlewares : 37 | 38 | ```bash 39 | json-server-auth db.json 40 | # with json-server-auth installed globally 41 | ``` 42 | 43 | _It exposes and works the same for all [JSON Server flags](https://github.com/typicode/json-server#cli-usage)._ 44 | 45 | ## Authentication flow 🔑 46 | 47 | JSON Server Auth adds a simple [JWT based](https://jwt.io/) authentication flow. 48 | 49 | ### Register 👥 50 | 51 | Any of the following routes registers a new user : 52 | 53 | - **`POST /register`** 54 | - **`POST /signup`** 55 | - **`POST /users`** 56 | 57 | **`email`** and **`password`** are required in the request body : 58 | 59 | ```http 60 | POST /register 61 | { 62 | "email": "olivier@mail.com", 63 | "password": "bestPassw0rd" 64 | } 65 | ``` 66 | 67 | The password is encrypted by [bcryptjs](https://github.com/dcodeIO/bcrypt.js). 68 | 69 | The response contains the JWT access token (expiration time of 1 hour), and the user data (without the password) : 70 | 71 | ```http 72 | 201 Created 73 | { 74 | "accessToken": "xxx.xxx.xxx", 75 | "user": { 76 | "id": 1, 77 | "email": "olivier@mail.com" 78 | } 79 | } 80 | ``` 81 | 82 | ###### Other properties 83 | 84 | Any other property can be added to the request body without being validated : 85 | 86 | ```http 87 | POST /register 88 | { 89 | "email": "olivier@mail.com", 90 | "password": "bestPassw0rd", 91 | "firstname": "Olivier", 92 | "lastname": "Monge", 93 | "age": 32 94 | } 95 | ``` 96 | 97 | ###### Update 98 | 99 | Any update to an existing user (via `PATCH` or `PUT` methods) will go through the same process for `email` and `password`. 100 | 101 | ### Login 🛂 102 | 103 | Any of the following routes logs an existing user in : 104 | 105 | - **`POST /login`** 106 | - **`POST /signin`** 107 | 108 | **`email`** and **`password`** are required, of course : 109 | 110 | ```http 111 | POST /login 112 | { 113 | "email": "olivier@mail.com", 114 | "password": "bestPassw0rd" 115 | } 116 | ``` 117 | 118 | The response contains the JWT access token (expiration time of 1 hour), and the user data (without the password) : 119 | 120 | ```http 121 | 200 OK 122 | { 123 | "accessToken": "xxx.xxx.xxx", 124 | "user": { 125 | "id": 1, 126 | "email": "olivier@mail.com", 127 | "firstname": "Olivier", 128 | "lastname": "Monge" 129 | } 130 | } 131 | ``` 132 | 133 | #### JWT payload 📇 134 | 135 | The access token has the following claims : 136 | 137 | - **`sub` :** the user `id` (as per the [JWT specs](https://tools.ietf.org/html/rfc7519#section-4.1.2)). 138 | - **`email` :** the user `email`. 139 | 140 | ## Authorization flow 🛡️ 141 | 142 | JSON Server Auth provides generic guards as **route middlewares**. 143 | 144 | To handle common use cases, JSON Server Auth draws inspiration from **Unix filesystem permissions**, especialy the [numeric notation](https://en.wikipedia.org/wiki/File_system_permissions#Numeric_notation). 145 | 146 | - We add **`4`** for **read** permission. 147 | - We add **`2`** for **write** permission. 148 | 149 | _Of course CRUD is not a filesystem, so we don't add 1 for execute permission._ 150 | 151 | Similarly to Unix, we then have three digits to match each user type : 152 | 153 | - First digit are the permissions for the **resource owner**. 154 | - Second digit are the permissions for the **logged-in users**. 155 | - Third digit are the permissions for the **public users**. 156 | 157 | For example, **`640`** means that only the owner can write the resource, logged-in users can read the resource, and public users cannot access the resource at all. 158 | 159 | #### The resource owner 🛀 160 | 161 | A user is the owner of a resource if that resource has a **`userId`** property that matches his `id` property. Example: 162 | 163 | ```js 164 | // The owner of 165 | { id: 8, text: 'blabla', userId: 1 } 166 | // is 167 | { id: 1, email: 'olivier@mail.com' } 168 | ``` 169 | 170 | Private guarded routes will use the JWT `sub` claim (which equals the user `id`) to check if the user actually owns the requested resource, by comparing `sub` with the `userId` property. 171 | 172 | _Except for the actual `users` collection, where the JWT `sub` claim must match the `id` property._ 173 | 174 | ### Guarded routes 🚥 175 | 176 | Guarded routes exist at the root and can restrict access to any resource you put after them : 177 | 178 | | Route | Resource permissions | 179 | | :----------: | :--------------------------------------------------------------------------------------------------- | 180 | | **`/664/*`** | User must be logged to _write_ the resource.
Everyone can _read_ the resource. | 181 | | **`/660/*`** | User must be logged to _write_ or _read_ the resource. | 182 | | **`/644/*`** | User must own the resource to _write_ the resource.
Everyone can _read_ the resource. | 183 | | **`/640/*`** | User must own the resource to _write_ the resource.
User must be logged to _read_ the resource. | 184 | | **`/600/*`** | User must own the resource to _write_ or _read_ the resource. | 185 | | **`/444/*`** | No one can _write_ the resource.
Everyone can _read_ the resource. | 186 | | **`/440/*`** | No one can _write_ the resource.
User must be logged to _read_ the resource. | 187 | | **`/400/*`** | No one can _write_ the resource.
User must own the resource to _read_ the resource. | 188 | 189 | #### Examples 190 | 191 | - Public user (not logged-in) does the following requests : 192 | 193 | | _Request_ | _Response_ | 194 | | :-------------------------------------- | :----------------- | 195 | | `GET /664/posts` | `200 OK` | 196 | | `POST /664/posts`
`{text: 'blabla'}` | `401 UNAUTHORIZED` | 197 | 198 | - Logged-in user with `id: 1` does the following requests : 199 | 200 | | _Request_ | _Response_ | 201 | | :--------------------------------------------------------- | :-------------- | 202 | | `GET /600/users/1`
`Authorization: Bearer xxx.xxx.xxx` | `200 OK` | 203 | | `GET /600/users/23`
`Authorization: Bearer xxx.xxx.xxx` | `403 FORBIDDEN` | 204 | 205 | ### Setup permissions 💡 206 | 207 | Of course, you don't want to directly use guarded routes in your requests. 208 | We can take advantage of [JSON Server custom routes feature](https://github.com/typicode/json-server#add-custom-routes) to setup resource permissions ahead. 209 | 210 | Create a `routes.json` file : 211 | 212 | ```json 213 | { 214 | "/users*": "/600/users$1", 215 | "/messages*": "/640/messages$1" 216 | } 217 | ``` 218 | 219 | Then : 220 | 221 | ```bash 222 | json-server db.json -m ./node_modules/json-server-auth -r routes.json 223 | # with json-server installed globally and json-server-auth installed locally 224 | ``` 225 | 226 | ##### 📢 but wait ! 227 | 228 | As a convenience, **`json-server-auth`** CLI allows you to define permissions in a more succinct way : 229 | 230 | ```json 231 | { 232 | "users": 600, 233 | "messages": 640 234 | } 235 | ``` 236 | 237 | Then : 238 | 239 | ```bash 240 | json-server-auth db.json -r routes.json 241 | # with json-server-auth installed globally 242 | ``` 243 | 244 | You can still add any other _normal_ custom routes : 245 | 246 | ```json 247 | { 248 | "users": 600, 249 | "messages": 640, 250 | "/posts/:category": "/posts?category=:category" 251 | } 252 | ``` 253 | 254 | ## Module usage 🔩 255 | 256 | If you go the programmatic way and [use JSON Server as a module](https://github.com/typicode/json-server#module), there is an extra step to properly integrate JSON Server Auth : 257 | 258 | ⚠️ You must bind the router property `db` to the created app, like the [JSON Server CLI does](https://github.com/typicode/json-server/blob/master/src/cli/run.js#L74), and you must apply the middlewares in a specific order. 259 | 260 | ```js 261 | const jsonServer = require('json-server') 262 | const auth = require('json-server-auth') 263 | 264 | const app = jsonServer.create() 265 | const router = jsonServer.router('db.json') 266 | 267 | // /!\ Bind the router db to the app 268 | app.db = router.db 269 | 270 | // You must apply the auth middleware before the router 271 | app.use(auth) 272 | app.use(router) 273 | app.listen(3000) 274 | ``` 275 | 276 | #### Permisssions Rewriter 277 | 278 | The custom rewriter is accessible via a subproperty : 279 | 280 | ```js 281 | const auth = require('json-server-auth') 282 | 283 | const rules = auth.rewriter({ 284 | // Permission rules 285 | users: 600, 286 | messages: 640, 287 | // Other rules 288 | '/posts/:category': '/posts?category=:category', 289 | }) 290 | 291 | // You must apply the middlewares in the following order 292 | app.use(rules) 293 | app.use(auth) 294 | app.use(router) 295 | ``` 296 | 297 | ## TODO 📜 298 | 299 | - [ ] Use JSON Server `id` and `foreignKeySuffix` parameters 300 | - [ ] Handle query params in list requests to secure guarded routes more precisely 301 | - [ ] Allow configuration of : 302 | - [ ] Users collection name 303 | - [ ] Minimum password length 304 | - [ ] JWT expiry time 305 | - [ ] JWT property name in response 306 | - [ ] Implement JWT Refresh Token 307 | - [ ] Possibility to disable password encryption ? 308 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // tslint:disable 3 | 4 | // https://kulshekhar.github.io/ts-jest/user/config/ 5 | module.exports = { 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | // https://jestjs.io/docs/en/configuration.html#testpathignorepatterns-array-string 9 | testPathIgnorePatterns: ['/node_modules/', '/fixtures/', '/__tests__/shared/', '/.vscode/'], 10 | globals: { 11 | 'ts-jest': { 12 | diagnostics: { 13 | // https://kulshekhar.github.io/ts-jest/user/config/diagnostics 14 | warnOnly: true, 15 | }, 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-server-auth", 3 | "description": "Authentication middleware for JSON Server", 4 | "version": "2.1.0", 5 | "author": "Jeremy Bensimon", 6 | "repository": "github:jeremyben/json-server-auth", 7 | "license": "MIT", 8 | "main": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "bin": { 11 | "json-server-auth": "./dist/bin.js" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "engines": { 17 | "node": ">=10" 18 | }, 19 | "engineStrict": true, 20 | "scripts": { 21 | "build": "rimraf dist && tsc -p tsconfig.build.json", 22 | "prepublishOnly": "yarn run build", 23 | "test": "jest", 24 | "test:file": "jest --testPathPattern", 25 | "test:watch": "jest --watch --verbose false", 26 | "release": "standard-version" 27 | }, 28 | "peerDependencies": { 29 | "json-server": "*" 30 | }, 31 | "dependencies": { 32 | "bcryptjs": "^2.4.3", 33 | "jsonwebtoken": "^8.5.1", 34 | "yargs": "^16.2.0" 35 | }, 36 | "devDependencies": { 37 | "@types/bcryptjs": "^2.4.2", 38 | "@types/jest": "^26.0.24", 39 | "@types/json-server": "^0.14.4", 40 | "@types/jsonwebtoken": "^8.5.4", 41 | "@types/supertest": "^2.0.11", 42 | "@types/yargs": "^16.0.0", 43 | "jest": "^27.0.6", 44 | "json-server": "^0.16.3", 45 | "rimraf": "^3.0.2", 46 | "standard-version": "^9.3.1", 47 | "supertest": "^6.1.4", 48 | "tree-kill": "^1.2.2", 49 | "ts-jest": "^27.0.4", 50 | "ts-node-dev": "^1.1.8", 51 | "tslint": "^6.1.3", 52 | "tslint-config-prettier": "^1.18.0", 53 | "typescript": "^4.3.5", 54 | "typescript-tslint-plugin": "^1.0.1" 55 | }, 56 | "keywords": [ 57 | "JSON", 58 | "server", 59 | "fake", 60 | "REST", 61 | "API", 62 | "prototyping", 63 | "mock", 64 | "mocking", 65 | "test", 66 | "testing", 67 | "rest", 68 | "data", 69 | "dummy", 70 | "sandbox", 71 | "json-server", 72 | "middleware", 73 | "auth", 74 | "authentication", 75 | "authorization", 76 | "jwt" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /src/__tests__/cli.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import { unlinkSync, writeFileSync } from 'fs' 3 | import { join } from 'path' 4 | import * as treeKill from 'tree-kill' 5 | 6 | const JSON_DB_PATH = join(__dirname, 'db.json') 7 | const cmd = `yarn ts-node --transpile-only src/bin "${JSON_DB_PATH}"` 8 | 9 | beforeAll(() => { 10 | const db = { 11 | users: [], 12 | } 13 | 14 | writeFileSync(JSON_DB_PATH, JSON.stringify(db)) 15 | }) 16 | 17 | afterAll(() => unlinkSync(JSON_DB_PATH)) 18 | 19 | describe('CLI', () => { 20 | test('Basic CLI works', (done) => { 21 | const child = spawn(cmd, { shell: true }) 22 | 23 | child.stdout.on('data', (data: Buffer) => { 24 | // console.log(data.toString().trim()) 25 | const ok = data.toString().trim().includes('Done') 26 | if (ok) treeKill(child.pid) 27 | }) 28 | 29 | child.stderr.on('data', (data: Buffer) => { 30 | treeKill(child.pid) 31 | expect(data.toString()).toBeFalsy() 32 | }) 33 | 34 | child 35 | .on('close', () => done()) 36 | .on('error', (err) => { 37 | treeKill(child.pid) 38 | expect(err).toBeUndefined() 39 | done() 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/__tests__/guards.ts: -------------------------------------------------------------------------------- 1 | import * as supertest from 'supertest' 2 | import { inMemoryJsonServer, USER } from './shared/tools' 3 | 4 | let rq: supertest.SuperTest 5 | let bearer: { Authorization: string } 6 | let db: { users: any[]; messages: any[]; secrets: any[] } 7 | 8 | beforeEach(async () => { 9 | db = { 10 | users: [{ id: 1, email: 'albert@gmail.com' }], 11 | messages: [ 12 | { id: 1, text: 'other', userId: 1 }, 13 | { id: 2, text: 'mine', userId: 2 }, 14 | ], 15 | secrets: [], 16 | } 17 | 18 | const guards = { 19 | users: 600, 20 | messages: 640, 21 | secrets: 600, 22 | } 23 | 24 | const app = inMemoryJsonServer(db, guards) 25 | rq = supertest(app) 26 | 27 | // Create user (will have id:2) and keep access token 28 | const res = await rq.post('/register').send(USER) 29 | bearer = { Authorization: `Bearer ${res.body.accessToken}` } 30 | }) 31 | 32 | describe('600: owner can read/write', () => { 33 | test('[SAD] cannot list all users', async () => { 34 | await rq.get('/users').expect(401) 35 | await rq.get('/users').set(bearer).expect(403) 36 | }) 37 | 38 | test('[SAD] cannot get other users', async () => { 39 | await rq.get('/users/1').expect(401) 40 | await rq.get('/users/1').set(bearer).expect(403) 41 | }) 42 | 43 | test('[HAPPY] can get own information', async () => { 44 | await rq.get('/users/2').expect(401) 45 | await rq.get('/users/2').set(bearer).expect(200) 46 | }) 47 | 48 | test('[HAPPY] can write and read from private collection', async () => { 49 | await rq.post('/secrets').set(bearer).send({ size: 'big', userId: '2' }).expect(201) 50 | const res = await rq.get('/secrets/1').set(bearer) 51 | expect(res.body).toEqual({ id: 1, userId: '2', size: 'big' }) 52 | }) 53 | }) 54 | 55 | describe('640: owner can read/write, logged can read', () => { 56 | test('[HAPPY] can list messages if logged', () => { 57 | return rq.get('/messages').set(bearer).expect(200) 58 | }) 59 | 60 | test('[HAPPY] can write new messages if logged', () => { 61 | return rq 62 | .post('/messages') 63 | .send({ text: 'yo', userId: 2 }) 64 | .set(bearer) 65 | .expect(201, { text: 'yo', id: 3, userId: 2 }) 66 | }) 67 | 68 | test('[HAPPY] can edit own messages', () => { 69 | return rq 70 | .patch('/messages/2') 71 | .send({ text: 'changed' }) 72 | .set(bearer) 73 | .expect(200, { text: 'changed', id: 2, userId: 2 }) 74 | }) 75 | 76 | test('[HAPPY] can read messages from others', () => { 77 | return rq.get('/messages/1').set(bearer).expect(200, { id: 1, text: 'other', userId: 1 }) 78 | }) 79 | 80 | test("[SAD] can't list messages if not logged", () => { 81 | return rq.get('/messages').expect(401) 82 | }) 83 | }) 84 | 85 | test('[HAPPY] create another user after setting guards', async () => { 86 | const res = await rq 87 | .post('/users') 88 | .send({ email: 'arthur@email.com', password: '1234' }) 89 | .expect(201) 90 | 91 | const otherBearer = { Authorization: `Bearer ${res.body.accessToken}` } 92 | 93 | await rq.get('/users/3').set(otherBearer).expect(200) 94 | 95 | await rq 96 | .put('/users/3') 97 | .set(otherBearer) 98 | .send({ email: 'arthur@email.com', password: '1234' }) 99 | .expect(200) 100 | }) 101 | 102 | test('[HAPPY] other methods pass through', async () => { 103 | await rq.options('/users/1').expect(200) 104 | await rq.head('/users/1').expect(200) 105 | }) 106 | -------------------------------------------------------------------------------- /src/__tests__/postman.http: -------------------------------------------------------------------------------- 1 | # Content-Type predefined to application/json in settings.json 2 | @root = http://localhost:3000 3 | @jwt = Bearer {{signin.response.body.$.accessToken}} 4 | 5 | ### 6 | 7 | POST {{root}}/signup HTTP/1.1 8 | 9 | { 10 | "name": "Jeremy", 11 | "email": "jeremy@mail.com", 12 | "password": "123456" 13 | } 14 | 15 | ### 16 | 17 | # @name signin 18 | POST {{root}}/signin HTTP/1.1 19 | 20 | { 21 | "email": "jeremy@mail.com", 22 | "password": "123456" 23 | } 24 | 25 | ### 26 | 27 | GET {{root}}/users/1 HTTP/1.1 28 | Authorization: {{jwt}} 29 | 30 | ### 31 | 32 | GET {{root}}/660/users/1 HTTP/1.1 33 | Authorization: {{jwt}} 34 | 35 | ### 36 | 37 | GET {{root}}/600/todos?_expand=user&_sort=text&_order=desc HTTP/1.1 38 | Authorization: {{jwt}} 39 | 40 | ### 41 | 42 | POST {{root}}/640/todos HTTP/1.1 43 | Authorization: {{jwt}} 44 | 45 | { 46 | "userId": 1, 47 | "text": "orignal" 48 | } 49 | 50 | ### 51 | 52 | PATCH {{root}}/640/todos/1 HTTP/1.1 53 | Authorization: {{jwt}} 54 | 55 | { 56 | "text": "changed" 57 | } 58 | 59 | 60 | ### 61 | 62 | GET {{root}}/db HTTP/1.1 63 | -------------------------------------------------------------------------------- /src/__tests__/shared/tools.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'express' 2 | import * as jsonServer from 'json-server' 3 | import * as jsonServerAuth from '../..' 4 | 5 | export const USER = { email: 'jeremy@mail.com', password: '123456', name: 'Jeremy' } 6 | 7 | export function inMemoryJsonServer( 8 | db: object = {}, 9 | resourceGuardMap: { [resource: string]: number } = {} 10 | ): Application { 11 | const app = jsonServer.create() 12 | const router = jsonServer.router(db) 13 | // Must bind the router db to the app like the cli does 14 | // https://github.com/typicode/json-server/blob/master/src/cli/run.js#L74 15 | app['db'] = router['db'] 16 | 17 | app.use(jsonServerAuth.rewriter(resourceGuardMap)) 18 | app.use(jsonServerAuth) 19 | app.use(router) 20 | 21 | return app 22 | } 23 | -------------------------------------------------------------------------------- /src/__tests__/users.ts: -------------------------------------------------------------------------------- 1 | import * as supertest from 'supertest' 2 | import { inMemoryJsonServer, USER } from './shared/tools' 3 | 4 | let rq: supertest.SuperTest 5 | 6 | beforeEach(async () => { 7 | const db = { users: [] } 8 | const app = inMemoryJsonServer(db) 9 | rq = supertest(app) 10 | await rq.post('/signup').send(USER) 11 | }) 12 | 13 | describe('Register user', () => { 14 | test('[HAPPY] Register and return access token and user', async () => { 15 | const res = await rq 16 | .post('/register') 17 | .send({ email: 'albert@mail.com', password: 'azerty123', name: 'Albert' }) 18 | 19 | expect(res.status).toBe(201) 20 | expect(res.body.accessToken).toMatch(/^[\w-]*\.[\w-]*\.[\w-]*$/) 21 | expect(res.body.user).toStrictEqual({ id: 2, email: 'albert@mail.com', name: 'Albert' }) 22 | }) 23 | 24 | test('[SAD] Bad email', () => { 25 | return rq 26 | .post('/register') 27 | .send({ email: 'albert@', password: 'azerty123' }) 28 | .expect(400, /email format/i) 29 | }) 30 | 31 | test('[SAD] Lack password', () => { 32 | return rq 33 | .post('/register') 34 | .send({ email: 'albert@mail.com' }) 35 | .expect(400, /required/i) 36 | }) 37 | 38 | test('[SAD] Email already exists', () => { 39 | return rq 40 | .post('/register') 41 | .send({ email: 'jeremy@mail.com', password: 'azerty123' }) 42 | .expect(400, /already/i) 43 | }) 44 | 45 | test('Alternative routes', async () => { 46 | const signupRes = await rq.post('/signup') 47 | expect(signupRes.notFound).toBe(false) 48 | expect(signupRes.badRequest).toBe(true) 49 | 50 | const usersRes = await rq.post('/users') 51 | expect(usersRes.notFound).toBe(false) 52 | expect(usersRes.badRequest).toBe(true) 53 | }) 54 | }) 55 | 56 | describe('Login user', () => { 57 | test('[HAPPY] Login and return access token', () => { 58 | return rq 59 | .post('/login') 60 | .send(USER) 61 | .expect(200, /"accessToken": ".*"/) 62 | }) 63 | 64 | test('[SAD] User does not exist', () => { 65 | return rq 66 | .post('/login') 67 | .send({ ...USER, email: 'arthur@mail.com' }) 68 | .expect(400, /cannot find user/i) 69 | }) 70 | 71 | test('[SAD] Wrong password', () => { 72 | return rq 73 | .post('/login') 74 | .send({ ...USER, password: '172450' }) 75 | .expect(400, /incorrect password/i) 76 | }) 77 | 78 | test('Alternative route', async () => { 79 | const signinRes = await rq.post('/signin') 80 | expect(signinRes.notFound).toBe(false) 81 | expect(signinRes.badRequest).toBe(true) 82 | }) 83 | }) 84 | 85 | describe('Query user', () => { 86 | test('[HAPPY] List users', async () => { 87 | const res = await rq.get('/users') 88 | expect(res.ok).toBe(true) 89 | expect(res.body).toHaveLength(1) 90 | expect(res.body[0]).toMatchObject({ email: 'jeremy@mail.com' }) 91 | }) 92 | }) 93 | 94 | describe('Update user', () => { 95 | test('[HAPPY] update name', () => { 96 | return rq 97 | .patch('/users/1') 98 | .send({ name: 'Arthur' }) 99 | .expect(200, /"name": "Arthur"/) 100 | }) 101 | 102 | test('[HAPPY] add other property', () => { 103 | return rq 104 | .patch('/users/1') 105 | .send({ age: 20 }) 106 | .expect(200, /"age": 20/) 107 | }) 108 | 109 | test('[HAPPY] modify email', () => { 110 | return rq.patch('/users/1').send({ email: 'arthur@mail.com' }).expect(200) 111 | }) 112 | 113 | test('[HAPPY] modify and hash new password', async () => { 114 | const password = '965dsd3si' 115 | const { body, status } = await rq.patch('/users/1').send({ password }) 116 | 117 | expect(status).toBe(200) 118 | expect(body.password).not.toBe(password) 119 | }) 120 | 121 | test('[SAD] modify email with wrong input', () => { 122 | return rq 123 | .patch('/users/1') 124 | .send({ email: 'arthur' }) 125 | .expect(400, /email format/i) 126 | }) 127 | 128 | test('[SAD] Put with only one property', () => { 129 | return rq 130 | .put('/users/1') 131 | .send({ email: 'arthur@mail.com' }) 132 | .expect(400, /required/i) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as yargs from 'yargs' 4 | import { tmpdir } from 'os' 5 | import { readFileSync, writeFileSync } from 'fs' 6 | import { join, basename } from 'path' 7 | import { parseGuardsRules } from './guards' 8 | 9 | import run = require('json-server/lib/cli/run') 10 | import jsonServerPkg = require('json-server/package.json') 11 | 12 | // Get the json-server cli module and add our middlewares. 13 | // It's inside a closure in the json-server source code, so we unfortunately have to duplicate it. 14 | // https://github.com/typicode/json-server/blob/master/src/cli/index.js 15 | 16 | const argv = yargs 17 | .config('config') 18 | .usage('$0 [options] ') 19 | .options({ 20 | port: { alias: 'p', description: 'Set port', default: 3000 }, 21 | host: { alias: 'H', description: 'Set host', default: 'localhost' }, 22 | watch: { alias: 'w', description: 'Watch file(s)' }, 23 | routes: { alias: 'r', description: 'Path to routes file' }, 24 | middlewares: { alias: 'm', array: true, description: 'Paths to middleware files' }, 25 | static: { alias: 's', description: 'Set static files directory' }, 26 | 'read-only': { alias: 'ro', description: 'Allow only GET requests' }, 27 | 'no-cors': { alias: 'nc', description: 'Disable Cross-Origin Resource Sharing' }, 28 | 'no-gzip': { alias: 'ng', description: 'Disable GZIP Content-Encoding' }, 29 | snapshots: { alias: 'S', description: 'Set snapshots directory', default: '.' }, 30 | delay: { alias: 'd', description: 'Add delay to responses (ms)' }, 31 | id: { alias: 'i', description: 'Set database id property (e.g. _id)', default: 'id' }, 32 | foreignKeySuffix: { 33 | alias: 'fks', 34 | description: 'Set foreign key suffix (e.g. _id as in post_id)', 35 | default: 'Id', 36 | }, 37 | quiet: { alias: 'q', description: 'Suppress log messages from output' }, 38 | config: { alias: 'c', description: 'Path to config file', default: 'json-server.json' }, 39 | }) 40 | .boolean('watch') 41 | .boolean('read-only') 42 | .boolean('quiet') 43 | .boolean('no-cors') 44 | .boolean('no-gzip') 45 | .string('routes') 46 | .help('help') 47 | .alias('help', 'h') 48 | .version(jsonServerPkg.version) 49 | .alias('version', 'v') 50 | .example('$0 db.json', '') 51 | .example('$0 file.js', '') 52 | .example('$0 http://example.com/db.json', '') 53 | .epilog( 54 | 'https://github.com/typicode/json-server\nhttps://github.com/jeremyben/json-server-auth' 55 | ) 56 | .require(1, 'Missing argument').argv 57 | 58 | // Add our index path to json-server middlewares. 59 | if (argv.middlewares) { 60 | argv.middlewares.unshift(__dirname) 61 | } else { 62 | argv.middlewares = [__dirname] 63 | } 64 | 65 | // Adds guards to json-server routes. 66 | // We are forced to create an intermediary file: 67 | // https://github.com/typicode/json-server/blob/master/src/cli/run.js#L109 68 | if (argv.routes) { 69 | let routes = JSON.parse(readFileSync(argv.routes, 'utf8')) 70 | routes = parseGuardsRules(routes) 71 | routes = JSON.stringify(routes) 72 | 73 | const tmpFilepath = join(tmpdir(), `routes-from-${basename(process.cwd())}.json`) 74 | writeFileSync(tmpFilepath, routes, 'utf8') 75 | 76 | argv.routes = tmpFilepath 77 | } 78 | // But we won't be able to properly watch and reload custom routes: 79 | // https://github.com/typicode/json-server/blob/master/src/cli/run.js#L229 80 | 81 | // launch json-server 82 | run(argv) 83 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const JWT_SECRET_KEY = 'json-server-auth-123456' 2 | 3 | export const JWT_EXPIRES_IN = '1h' 4 | 5 | export const SALT_LENGTH = 10 6 | 7 | export const EMAIL_REGEX = new RegExp( 8 | "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" 9 | ) 10 | 11 | export const MIN_PASSWORD_LENGTH = 4 12 | -------------------------------------------------------------------------------- /src/guards.ts: -------------------------------------------------------------------------------- 1 | import { Router, Handler } from 'express' 2 | import * as jwt from 'jsonwebtoken' 3 | import * as jsonServer from 'json-server' 4 | import { stringify } from 'querystring' 5 | import { JWT_SECRET_KEY } from './constants' 6 | import { bodyParsingHandler, errorHandler, goNext } from './shared-middlewares' 7 | 8 | /** 9 | * Logged Guard. 10 | * Check JWT. 11 | */ 12 | const loggedOnly: Handler = (req, res, next) => { 13 | if ( 14 | req.method !== 'GET' && 15 | req.method !== 'POST' && 16 | req.method !== 'PUT' && 17 | req.method !== 'PATCH' && 18 | req.method !== 'DELETE' 19 | ) { 20 | // We let pass the other methods (HEAD, OPTIONS) 21 | // as they are not handled by json-server router, 22 | // but maybe by another user-defined middleware 23 | next() 24 | return 25 | } 26 | 27 | const { authorization } = req.headers 28 | 29 | if (!authorization) { 30 | res.status(401).jsonp('Missing authorization header') 31 | return 32 | } 33 | 34 | const [scheme, token] = authorization.split(' ') 35 | 36 | if (scheme !== 'Bearer') { 37 | res.status(401).jsonp('Incorrect authorization scheme') 38 | return 39 | } 40 | 41 | if (!token) { 42 | res.status(401).jsonp('Missing token') 43 | return 44 | } 45 | 46 | try { 47 | jwt.verify(token, JWT_SECRET_KEY) 48 | // Add claims to request 49 | req.claims = jwt.decode(token) as any 50 | next() 51 | } catch (err) { 52 | res.status(401).jsonp((err as jwt.JsonWebTokenError).message) 53 | } 54 | } 55 | 56 | /** 57 | * Owner Guard. 58 | * Checking userId reference in the request or the resource. 59 | * Inherits from logged guard. 60 | */ 61 | const privateOnly: Handler = (req, res, next) => { 62 | loggedOnly(req, res, () => { 63 | const { db } = req.app 64 | if (db == null) { 65 | throw Error('You must bind the router db to the app') 66 | } 67 | 68 | // TODO: handle query params instead of removing them 69 | const path = req.url.replace(`?${stringify(req.query)}`, '') 70 | const [, mod, resource, id] = path.split('/') 71 | 72 | // Creation and replacement 73 | // check userId on the request body 74 | if (req.method === 'POST') { 75 | // TODO: use foreignKeySuffix instead of assuming the default "Id" 76 | const hasRightUserId = String(req.body.userId) === req.claims!.sub 77 | 78 | // No userId reference when creating a new user (duh) 79 | const isUserResource = resource === 'users' 80 | 81 | if (hasRightUserId || isUserResource) { 82 | next() 83 | } else { 84 | res.status(403).jsonp( 85 | 'Private resource creation: request body must have a reference to the owner id' 86 | ) 87 | } 88 | 89 | return 90 | } 91 | 92 | if (req.method === 'PUT') { 93 | // TODO: use foreignKeySuffix instead of assuming the default "Id" 94 | const hasRightUserId = String(req.body.userId) === req.claims!.sub 95 | 96 | const isUserResourceAndIsRightId = resource === 'users' && id === req.claims!.sub 97 | 98 | if (hasRightUserId || isUserResourceAndIsRightId) { 99 | next() 100 | } else { 101 | res.status(403).jsonp( 102 | 'Private resource replacement: request body must have a reference to the owner id' 103 | ) 104 | } 105 | 106 | return 107 | } 108 | 109 | // Query and update 110 | // check userId on the resource 111 | if (req.method === 'GET' || req.method === 'PATCH' || req.method === 'DELETE') { 112 | let hasRightUserId: boolean 113 | 114 | // TODO: use foreignKeySuffix instead of assuming the default "Id" 115 | if (id) { 116 | const entity: Record = db.get(resource).getById(id).value() 117 | // get id if we are in the users collection 118 | const userId = resource === 'users' ? entity.id : entity.userId 119 | 120 | hasRightUserId = String(userId) === req.claims!.sub 121 | } else { 122 | const entities: Record[] = db.get(resource).value() 123 | 124 | // TODO: Array.every() for properly secured access. 125 | // Array.some() is too relax, but maybe useful for prototyping usecase. 126 | // But first we must handle the query params. 127 | hasRightUserId = entities.some( 128 | (entity) => String(entity.userId) === req.claims!.sub 129 | ) 130 | } 131 | 132 | if (hasRightUserId) { 133 | next() 134 | } else { 135 | res.status(403).jsonp( 136 | 'Private resource access: entity must have a reference to the owner id' 137 | ) 138 | } 139 | 140 | return 141 | } 142 | 143 | // We let pass the other methods (HEAD, OPTIONS) 144 | // as they are not handled by json-server router, 145 | // but maybe by another user-defined middleware 146 | next() 147 | }) 148 | } 149 | // tslint:enable 150 | 151 | /** 152 | * Forbid all methods except GET. 153 | */ 154 | const readOnly: Handler = (req, res, next) => { 155 | if (req.method === 'GET') { 156 | next() 157 | } else { 158 | res.status(403).jsonp('Read only') 159 | } 160 | } 161 | 162 | type ReadWriteBranch = ({ read, write }: { read: Handler; write: Handler }) => Handler 163 | 164 | /** 165 | * Allow applying a different middleware for GET request (read) and others (write) 166 | * (middleware returning a middleware) 167 | */ 168 | const branch: ReadWriteBranch = 169 | ({ read, write }) => 170 | (req, res, next) => { 171 | if (req.method === 'GET') { 172 | read(req, res, next) 173 | } else { 174 | write(req, res, next) 175 | } 176 | } 177 | 178 | /** 179 | * Remove guard mod from baseUrl, so lowdb can handle the resource. 180 | */ 181 | const flattenUrl: Handler = (req, res, next) => { 182 | // req.url is writable and used for redirection, 183 | // but app.use() already trim baseUrl from req.url, 184 | // so we use app.all() that leaves the baseUrl with req.url, 185 | // so we can rewrite it. 186 | // https://stackoverflow.com/questions/14125997/ 187 | 188 | req.url = req.url.replace(/\/[640]{3}/, '') 189 | next() 190 | } 191 | 192 | /** 193 | * Guards router 194 | */ 195 | export default Router() 196 | .use(bodyParsingHandler) 197 | .all('/666/*', flattenUrl) 198 | .all('/664/*', branch({ read: goNext, write: loggedOnly }), flattenUrl) 199 | .all('/660/*', loggedOnly, flattenUrl) 200 | .all('/644/*', branch({ read: goNext, write: privateOnly }), flattenUrl) 201 | .all('/640/*', branch({ read: loggedOnly, write: privateOnly }), flattenUrl) 202 | .all('/600/*', privateOnly, flattenUrl) 203 | .all('/444/*', readOnly, flattenUrl) 204 | .all('/440/*', loggedOnly, readOnly, flattenUrl) 205 | .all('/400/*', privateOnly, readOnly, flattenUrl) 206 | .use(errorHandler) 207 | 208 | /** 209 | * Transform resource-guard mapping to proper rewrite rule supported by express-urlrewrite. 210 | * Return other rewrite rules as is, so we can use both types in routes.json. 211 | * @example 212 | * { 'users': 600 } => { '/users*': '/600/users$1' } 213 | */ 214 | export function parseGuardsRules(resourceGuardMap: { [resource: string]: any }) { 215 | return Object.entries(resourceGuardMap).reduce((routes, [resource, guard]) => { 216 | const isGuard = /^[640]{3}$/m.test(String(guard)) 217 | 218 | if (isGuard) { 219 | routes[`/${resource}*`] = `/${guard}/${resource}$1` 220 | } else { 221 | // Return as is if not a guard 222 | routes[resource] = guard 223 | } 224 | 225 | return routes 226 | }, {} as Parameters[0]) 227 | } 228 | 229 | /** 230 | * Conveniant method to use directly resource-guard mapping 231 | * with JSON Server rewriter (which itself uses express-urlrewrite). 232 | * Works with normal rewrite rules as well. 233 | */ 234 | export function rewriter(resourceGuardMap: { [resource: string]: number }): Router { 235 | const routes = parseGuardsRules(resourceGuardMap) 236 | return jsonServer.rewriter(routes) 237 | } 238 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'express' 2 | import guardsRouter, { rewriter } from './guards' 3 | import usersRouter from './users' 4 | 5 | interface MiddlewaresWithRewriter extends Array { 6 | rewriter: typeof rewriter 7 | } 8 | 9 | // @ts-ignore shut the compiler up about defining in two steps 10 | const middlewares: MiddlewaresWithRewriter = [usersRouter, guardsRouter] 11 | Object.defineProperty(middlewares, 'rewriter', { value: rewriter, enumerable: false }) 12 | 13 | // export middlewares as is, so we can simply pass the module to json-server `--middlewares` flag 14 | export = middlewares 15 | -------------------------------------------------------------------------------- /src/shared-middlewares.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler, Handler, json, urlencoded } from 'express' 2 | 3 | /** 4 | * Use same body-parser options as json-server 5 | */ 6 | export const bodyParsingHandler = [json({ limit: '10mb' }), urlencoded({ extended: false })] 7 | 8 | /** 9 | * Json error handler 10 | */ 11 | export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { 12 | console.error(err) 13 | res.status(500).jsonp(err.message) 14 | } 15 | 16 | /** 17 | * Just executes the next middleware, 18 | * to pass directly the request to the json-server router 19 | */ 20 | export const goNext: Handler = (req, res, next) => { 21 | next() 22 | } 23 | 24 | /** 25 | * Look for a property in the request body and reject the request if found 26 | */ 27 | export function forbidUpdateOn(...forbiddenBodyParams: string[]): Handler { 28 | return (req, res, next) => { 29 | const bodyParams = Object.keys(req.body) 30 | const hasForbiddenParam = bodyParams.some(forbiddenBodyParams.includes) 31 | 32 | if (hasForbiddenParam) { 33 | res.status(403).jsonp(`Forbidden update on: ${forbiddenBodyParams.join(', ')}`) 34 | } else { 35 | next() 36 | } 37 | } 38 | } 39 | 40 | type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD' 41 | 42 | /** 43 | * Reject the request for a given method 44 | */ 45 | export function forbidMethod(method: RequestMethod): Handler { 46 | return (req, res, next) => { 47 | if (req.method === method) { 48 | res.sendStatus(405) 49 | } else { 50 | next() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-implicit-dependencies ban-types no-namespace 2 | 3 | declare namespace Express { 4 | export interface Application { 5 | /** 6 | * @see https://github.com/typicode/lowdb 7 | * TODO: better typings 8 | */ 9 | db?: { 10 | _: import('lodash').LoDashStatic 11 | getState: () => any 12 | setState: (state: any) => any 13 | get: (path: string) => any 14 | set: (path: string, value?: any) => any 15 | unset: (path: string) => any 16 | has: (path: string) => any 17 | defaults: (collections: object) => any 18 | read: () => any 19 | write: () => any 20 | } 21 | } 22 | 23 | export interface Request { 24 | claims?: { email: string; iat: number; exp: number; sub: string } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/users.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs' 2 | import { Handler, Router } from 'express' 3 | import * as jwt from 'jsonwebtoken' 4 | import { bodyParsingHandler, errorHandler } from './shared-middlewares' 5 | import { 6 | EMAIL_REGEX, 7 | JWT_EXPIRES_IN, 8 | JWT_SECRET_KEY, 9 | MIN_PASSWORD_LENGTH, 10 | SALT_LENGTH, 11 | } from './constants' 12 | 13 | interface User { 14 | id: string 15 | email: string 16 | password: string 17 | [key: string]: any // Allow any other field 18 | } 19 | 20 | type ValidateHandler = ({ required }: { required: boolean }) => Handler 21 | 22 | /** 23 | * Validate email and password 24 | */ 25 | const validate: ValidateHandler = 26 | ({ required }) => 27 | (req, res, next) => { 28 | const { email, password } = req.body as Partial 29 | 30 | if (required && (!email || !email.trim() || !password || !password.trim())) { 31 | res.status(400).jsonp('Email and password are required') 32 | return 33 | } 34 | 35 | if (email && !email.match(EMAIL_REGEX)) { 36 | res.status(400).jsonp('Email format is invalid') 37 | return 38 | } 39 | 40 | if (password && password.length < MIN_PASSWORD_LENGTH) { 41 | res.status(400).jsonp('Password is too short') 42 | return 43 | } 44 | 45 | next() 46 | } 47 | 48 | /** 49 | * Register / Create a user 50 | */ 51 | const create: Handler = (req, res, next) => { 52 | const { email, password, ...rest } = req.body as User 53 | const { db } = req.app 54 | 55 | if (db == null) { 56 | // json-server CLI expose the router db to the app 57 | // (https://github.com/typicode/json-server/blob/master/src/cli/run.js#L74), 58 | // but if we use the json-server module API, we must do the same. 59 | throw Error('You must bind the router db to the app') 60 | } 61 | 62 | const existingUser = db.get('users').find({ email }).value() 63 | if (existingUser) { 64 | res.status(400).jsonp('Email already exists') 65 | return 66 | } 67 | 68 | bcrypt 69 | .hash(password, SALT_LENGTH) 70 | .then((hash) => { 71 | // Create users collection if doesn't exist, 72 | // save password as hash and add any other field without validation 73 | try { 74 | return db 75 | .get('users') 76 | .insert({ email, password: hash, ...rest }) 77 | .write() 78 | } catch (error) { 79 | throw Error('You must add a "users" collection to your db') 80 | } 81 | }) 82 | .then((user: User) => { 83 | return new Promise<{ accessToken: string; user: User }>((resolve, reject) => { 84 | jwt.sign( 85 | { email }, 86 | JWT_SECRET_KEY, 87 | { expiresIn: JWT_EXPIRES_IN, subject: String(user.id) }, 88 | (error, accessToken) => { 89 | if (error) reject(error) 90 | else resolve({ accessToken: accessToken!, user }) 91 | } 92 | ) 93 | }) 94 | }) 95 | .then(({ accessToken, user }) => { 96 | const { password: _, ...userWithoutPassword } = user 97 | res.status(201).jsonp({ accessToken, user: userWithoutPassword }) 98 | }) 99 | .catch(next) 100 | } 101 | 102 | /** 103 | * Login 104 | */ 105 | const login: Handler = (req, res, next) => { 106 | const { email, password } = req.body as User 107 | const { db } = req.app 108 | 109 | if (db == null) { 110 | throw Error('You must bind the router db to the app') 111 | } 112 | 113 | const user = db.get('users').find({ email }).value() as User 114 | 115 | if (!user) { 116 | res.status(400).jsonp('Cannot find user') 117 | return 118 | } 119 | 120 | bcrypt 121 | .compare(password, user.password) 122 | .then((same) => { 123 | if (!same) throw 400 124 | 125 | return new Promise((resolve, reject) => { 126 | jwt.sign( 127 | { email }, 128 | JWT_SECRET_KEY, 129 | { expiresIn: JWT_EXPIRES_IN, subject: String(user.id) }, 130 | (error, accessToken) => { 131 | if (error) reject(error) 132 | else resolve(accessToken!) 133 | } 134 | ) 135 | }) 136 | }) 137 | .then((accessToken: string) => { 138 | const { password: _, ...userWithoutPassword } = user 139 | res.status(200).jsonp({ accessToken, user: userWithoutPassword }) 140 | }) 141 | .catch((err) => { 142 | if (err === 400) res.status(400).jsonp('Incorrect password') 143 | else next(err) 144 | }) 145 | } 146 | 147 | /** 148 | * Patch and Put user 149 | */ 150 | // TODO: create new access token when password or email changes 151 | const update: Handler = (req, res, next) => { 152 | const { password } = req.body as Partial 153 | 154 | if (!password) { 155 | next() // Simply continue with json-server router 156 | return 157 | } 158 | 159 | bcrypt 160 | .hash(password, SALT_LENGTH) 161 | .then((hash) => { 162 | req.body.password = hash 163 | next() 164 | }) 165 | .catch(next) 166 | } 167 | 168 | /** 169 | * Users router 170 | */ 171 | // Must match routes with and without guards 172 | export default Router() 173 | .use(bodyParsingHandler) 174 | .post('/users|register|signup', validate({ required: true }), create) 175 | .post('/[640]{3}/users', validate({ required: true }), create) 176 | .post('/login|signin', validate({ required: true }), login) 177 | .put('/users/:id', validate({ required: true }), update) 178 | .put('/[640]{3}/users/:id', validate({ required: true }), update) 179 | .patch('/users/:id', validate({ required: false }), update) 180 | .patch('/[640]{3}/users/:id', validate({ required: false }), update) 181 | .use(errorHandler) 182 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { "skipLibCheck": true }, 4 | // https://github.com/Microsoft/TypeScript/pull/5980 5 | "exclude": ["**/__tests__", "**/*.test.ts", "**/*.spec.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "lib": ["es2017"], 7 | // "allowJs": true, 8 | // "checkJs": true, 9 | // "jsx": "preserve", 10 | "declaration": true, 11 | // "declarationMap": true, 12 | // "sourceMap": true, 13 | // "outFile": "./", 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | // "composite": true, 17 | // "removeComments": true, 18 | // "noEmit": true, 19 | // "importHelpers": true, 20 | // "downlevelIteration": true, 21 | // "isolatedModules": true, 22 | "newLine": "lf", 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true, 26 | "noImplicitAny": false, 27 | // "strictNullChecks": true, 28 | // "strictFunctionTypes": true, 29 | // "strictPropertyInitialization": true, 30 | // "noImplicitThis": true, 31 | // "alwaysStrict": true, 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, 35 | // "noUnusedParameters": true, 36 | // "noImplicitReturns": true, 37 | // "noFallthroughCasesInSwitch": true, 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node", 41 | // "baseUrl": "./", 42 | // "paths": {}, 43 | // "rootDirs": [], 44 | // "typeRoots": [], 45 | // "types": [], 46 | // "allowSyntheticDefaultImports": true, 47 | // "esModuleInterop": true, 48 | // "preserveSymlinks": true, 49 | "resolveJsonModule": true, 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "", 53 | // "mapRoot": "", 54 | // "inlineSourceMap": true, 55 | // "inlineSources": true, 56 | 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, 59 | // "emitDecoratorMetadata": true, 60 | 61 | /* Compiler Plugins */ 62 | "plugins": [ 63 | { 64 | // https://github.com/Microsoft/typescript-tslint-plugin 65 | "name": "typescript-tslint-plugin", 66 | "configFile": "tslint.json" 67 | } 68 | ] 69 | }, 70 | "include": ["src/**/*.ts"] 71 | } 72 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "rulesDirectory": [], 5 | "rules": { 6 | "interface-name": false, 7 | "no-console": false, 8 | "no-submodule-imports": false, 9 | "no-string-literal": false, 10 | "interface-over-type-literal": false, 11 | "no-angle-bracket-type-assertion": false, 12 | "ordered-imports": false, 13 | "object-literal-sort-keys": false, 14 | "curly": false, 15 | "no-implicit-dependencies": false, 16 | "no-object-literal-type-assertion": false 17 | } 18 | } 19 | --------------------------------------------------------------------------------