├── .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 |
--------------------------------------------------------------------------------