├── .gitignore ├── Dockerfile ├── README.md ├── config ├── custom-environment-variables.json ├── default.json ├── prod.json └── test.json ├── emails ├── build.js ├── dist │ ├── admin-invite.html │ ├── admin-invite.text │ ├── request-password-unknown.html │ ├── request-password-unknown.text │ ├── request-password.html │ ├── request-password.text │ ├── reset-password-unknown.html │ ├── reset-password-unknown.text │ ├── reset-password.html │ ├── reset-password.text │ ├── welcome-known.html │ ├── welcome-known.text │ ├── welcome.html │ └── welcome.text └── src │ ├── admin-invite.html │ ├── admin-invite.text │ ├── reset-password-unknown.html │ ├── reset-password-unknown.text │ ├── reset-password.html │ ├── reset-password.text │ ├── style.css │ ├── welcome-known.html │ ├── welcome-known.text │ ├── welcome.html │ └── welcome.text ├── package-lock.json ├── package.json ├── scripts ├── ensure-config.js └── setup-fixtures.js └── src ├── __tests__ └── app.test.js ├── app.js ├── database.js ├── index.js ├── lib ├── __tests__ │ └── emails.js ├── emails.js ├── errors.js ├── mailer.js ├── tokens.js └── utils.js ├── middlewares ├── __tests__ │ ├── authenticate.js │ └── validate.js ├── authenticate.js ├── error-handler.js └── validate.js ├── models └── user.js ├── test-helpers ├── context.js ├── index.js ├── request.js └── setup-tests.js └── v1 ├── __tests__ ├── auth.js └── users.js ├── auth.js ├── index.js └── users.js /.gitignore: -------------------------------------------------------------------------------- 1 | /logs 2 | /npm-debug.log 3 | /node_modules 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node/10.0-alpine 2 | 3 | ENV NODE_ENV production 4 | 5 | # Update & install required packages 6 | RUN apk add --update bash git make python g++ 7 | 8 | # Install app dependencies 9 | COPY package.json /api/package.json 10 | RUN cd /api; npm ci 11 | 12 | # Fix bcrypt 13 | RUN cd /api; npm rebuild bcrypt --build-from-source 14 | 15 | # Copy app source 16 | COPY . /api 17 | 18 | # Set work directory to /api 19 | WORKDIR /api 20 | 21 | # set your port 22 | ENV PORT 8080 23 | 24 | # expose the port to outside world 25 | EXPOSE 8080 26 | 27 | RUN apk del python make g++ 28 | 29 | # start command as per package.json 30 | CMD ["npm", "start"] 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Note: This has been moved to https://github.com/bedrockio/bedrock-core 3 | 4 | ## Directory Structure 5 | 6 | * `package.json` - Configure dependencies 7 | * `config/defaults.json` - Default configuration, all values can be controlled via env vars 8 | * `config/custom-environment-variables.json` - Overwrite configuration with defined environment variables 9 | * `src/*/__tests__` - Unit tests 10 | * `src/index.js` - Entrypoint for running and binding API 11 | * `src/lib` - Library files like utils etc 12 | * `src/v1` - Routes 13 | * `src/middlewares` - Middleware libs 14 | * `src/models` - Models for ORM (Mongoose) 15 | * `src/app.js` - Entrypoint into API (does not bind, so can be used in unit tests) 16 | * `src/index.js` - Launch script for the API) 17 | * `emails/dist` - Prebuild emails templates (dont modify => modify emails/src and run `npm run emails`) 18 | * `emails/src` - Emails templates 19 | 20 | ## Install Dependencies 21 | 22 | ``` 23 | npm install 24 | ``` 25 | 26 | ## Testing & Linting 27 | 28 | ``` 29 | npm test 30 | ``` 31 | 32 | ## Running in Development 33 | 34 | Code reload using nodemon: 35 | 36 | ``` 37 | npm run dev 38 | ``` 39 | 40 | ## Configuration 41 | 42 | All values in `config/defaults.json` can be overwritten using environment variables by updating 43 | custom-environnment-variables.json see 44 | [node-config](https://github.com/lorenwest/node-config/wiki/Environment-Variables#custom-environment-variables) 45 | 46 | * `API_BIND_HOST` - Host to bind to, defaults to `"0.0.0.0"` 47 | * `API_BIND_PORT` - Port to bind to, defaults to `3005` 48 | * `API_MONGO_URI` - MongoDB URI to connect to, defaults to `mongodb://localhost/skeleton_prod` 49 | * `API_JWT_SECRET` - JWT secret for authentication, defaults to `[change me]` 50 | * `API_ADMIN_EMAIL` - Default root admin user `admin@skeleton.ai` 51 | * `API_ADMIN_PASSWORD` - Default root admin password `[change me]` 52 | * `API_APP_NAME` - Default `Skeleton` to used in emails 53 | * `API_APP_URL` - URL for app defaults to `http://localhost:3001` 54 | * `API_APP_ADMIN_URL` - URL for admin app defaults to `http://localhost:3002` 55 | * `API_POSTMARK_FROM` - Reply email address `no-reply@skeleton.ai` 56 | * `API_POSTMARK_APIKEY` - APIKey for Postmark `[change me]` 57 | 58 | ## Building the Container 59 | 60 | ``` 61 | docker build -t node-api-skeleton . 62 | ``` 63 | 64 | ## Todo 65 | 66 | * [ ] Email template improvements 67 | * [ ] Emails tests 68 | * [ ] Admin API 69 | -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "bind" : { 3 | "host" : "API_BIND_HOST", 4 | "port": "API_BIND_PORT" 5 | }, 6 | "mongo": { 7 | "uri": "API_MONGO_URI" 8 | }, 9 | "admin": { 10 | "email": "API_ADMIN_EMAIL", 11 | "password": "API_ADMIN_PASSWORD" 12 | }, 13 | "app": { 14 | "appName": "API_APP_NAME", 15 | "url": "API_APP_URL", 16 | "adminUrl": "API_APP_ADMIN_URL" 17 | }, 18 | "postmark": { 19 | "from": "API_POSTMARK_FROM", 20 | "apiKey": "API_POSTMARK_APIKEY" 21 | }, 22 | "jwt": { 23 | "secret": "API_JWT_SECRET" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "cors": { 3 | "exposeHeaders": ["content-length"], 4 | "maxAge": 600 5 | }, 6 | "bind": { 7 | "port": 3005, 8 | "host": "0.0.0.0" 9 | }, 10 | "mongo": { 11 | "uri": "mongodb://localhost/skeleton_dev", 12 | "debug": false, 13 | "options": {} 14 | }, 15 | "admin": { 16 | "name": "admin", 17 | "email": "admin@skeleton.ai", 18 | "password": "password1" 19 | }, 20 | "jwt": { 21 | "secret": "jwt.secret", 22 | "expiresIn": { 23 | "temporary": "1d", 24 | "regular": "30d", 25 | "invite": "1d" 26 | } 27 | }, 28 | "app": { 29 | "appName": "Skeleton", 30 | "url": "http://localhost:3001", 31 | "adminUrl": "http://localhost:3002" 32 | }, 33 | "postmark": { 34 | "from": "no-reply@skeleton.ai", 35 | "apiKey": "postmark.apikey" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin": { 3 | "password": "[change me]" 4 | }, 5 | "jwt": { 6 | "secret": "[change me]" 7 | }, 8 | "postmark": { 9 | "apikey": "[change me]" 10 | }, 11 | "mongo": { 12 | "uri": "mongodb://localhost/skeleton_prod" 13 | }, 14 | "app": { 15 | "appName": "Skeleton", 16 | "url": "[change me]", 17 | "adminUrl": "[change me]" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongo": { 3 | "uri": "mongodb://localhost/skeleton_dev" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /emails/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const juice = require('juice'); //eslint-disable-line 4 | 5 | const templateFolder = path.join(__dirname, './src'); 6 | const distFolder = path.join(__dirname, './dist'); 7 | 8 | fs.readFile(path.join(__dirname, './src/style.css'), (styleErr, style) => { 9 | if (styleErr) throw styleErr; 10 | fs.readdir(templateFolder, (readDirErr, files) => { 11 | if (readDirErr) throw readDirErr; 12 | files.forEach((file) => { 13 | if (file === 'style.css') return; 14 | fs.readFile(path.join(templateFolder, file), (readFileErr, template) => { 15 | if (readFileErr) throw readFileErr; 16 | const inlined = juice.inlineContent(template.toString(), style.toString()); 17 | fs.writeFile(path.join(distFolder, file), inlined, (err) => { 18 | if (err) throw err; 19 | }); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /emails/dist/admin-invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ${appName} Admin Invite 7 | 8 | 9 | 10 | 11 | 12 | 74 | 75 | 76 |
  13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 48 | 49 | 50 | 51 |
22 | 23 | 24 | 45 | 46 |
25 |

Hi there,

26 |

Sometimes you just want to send a simple HTML email with a simple design and clear call to action. This is it.

27 | 28 | 29 | 30 | 39 | 40 | 41 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
Call To Action
38 |
42 |

This is a really simple email template. Its sole purpose is to get the recipient to click the button with no distractions.

43 |

Good luck! Hope it works.

44 |
47 |
52 | 53 | 54 | 69 | 70 | 71 | 72 |
73 |
 
77 | 78 | -------------------------------------------------------------------------------- /emails/dist/admin-invite.text: -------------------------------------------------------------------------------- 1 | Welcome to ${appName} Registration 2 | --- 3 | 4 | To proceed with your registration, use the following link: 5 | ${adminUrl}/register?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /emails/dist/request-password-unknown.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 61 | 62 | 63 |
  14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 35 | 36 | 37 | 38 |
23 | 24 | 25 | 32 | 33 |
26 |

You asked us to send you a password reset link. It turns out the email address ${email} doesn't have an account yet.

27 |

If you’re pretty sure you do have an account, you can try with a different email address.

28 |

Please let us know if you have any other questions or feedback.

29 |

Best,

30 |

The ${appName} Team`

31 |
34 |
39 | 40 | 57 | 58 | 59 |
60 |
 
64 | 65 | -------------------------------------------------------------------------------- /emails/dist/request-password-unknown.text: -------------------------------------------------------------------------------- 1 | Reset Password Request 2 | --- 3 | 4 | You asked us to send you a password reset link. It turns out the email address {email} doesn't have an account yet. 5 | 6 | If you’re pretty sure you do have an account, you can try with a different email address. 7 | 8 | Please let us know if you have any other questions or feedback. 9 | 10 | --- 11 | Cheers, 12 | The ${appName} Team 13 | 14 | -------------------------------------------------------------------------------- /emails/dist/request-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 73 | 74 | 75 |
  14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 47 | 48 | 49 | 50 |
23 | 24 | 25 | 44 | 45 |
26 | 27 | 28 | 29 | 38 | 39 | 40 |
30 | 31 | 32 | 33 | 34 | 35 | 36 |
Reset Password
37 |
41 |

Best,

42 |

The ${appName} Team`

43 |
46 |
51 | 52 | 69 | 70 | 71 |
72 |
 
76 | 77 | -------------------------------------------------------------------------------- /emails/dist/request-password.text: -------------------------------------------------------------------------------- 1 | Reset Password Request 2 | --- 3 | 4 | To proceed with your reset your password, use the following link: 5 | ${url}/reset-password?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /emails/dist/reset-password-unknown.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 61 | 62 | 63 |
  14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 35 | 36 | 37 | 38 |
23 | 24 | 25 | 32 | 33 |
26 |

You asked us to send you a password reset link. It turns out the email address ${email} doesn't have an account yet.

27 |

If you’re pretty sure you do have an account, you can try with a different email address.

28 |

Please let us know if you have any other questions or feedback.

29 |

Best,

30 |

The ${appName} Team`

31 |
34 |
39 | 40 | 57 | 58 | 59 |
60 |
 
64 | 65 | -------------------------------------------------------------------------------- /emails/dist/reset-password-unknown.text: -------------------------------------------------------------------------------- 1 | Reset Password Request 2 | --- 3 | 4 | You asked us to send you a password reset link. It turns out the email address {email} doesn't have an account yet. 5 | 6 | If you’re pretty sure you do have an account, you can try with a different email address. 7 | 8 | Please let us know if you have any other questions or feedback. 9 | 10 | --- 11 | Cheers, 12 | The ${appName} Team 13 | 14 | -------------------------------------------------------------------------------- /emails/dist/reset-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 73 | 74 | 75 |
  14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 47 | 48 | 49 | 50 |
23 | 24 | 25 | 44 | 45 |
26 | 27 | 28 | 29 | 38 | 39 | 40 |
30 | 31 | 32 | 33 | 34 | 35 | 36 |
Reset Password
37 |
41 |

Best,

42 |

The ${appName} Team`

43 |
46 |
51 | 52 | 69 | 70 | 71 |
72 |
 
76 | 77 | -------------------------------------------------------------------------------- /emails/dist/reset-password.text: -------------------------------------------------------------------------------- 1 | Reset Password Request 2 | --- 3 | 4 | To proceed with your reset your password, use the following link: 5 | ${url}/reset-password?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /emails/dist/welcome-known.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 61 | 62 | 63 |
  14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 35 | 36 | 37 | 38 |
23 | 24 | 25 | 32 | 33 |
26 |

Looks like you just tried to signup with ${email}, but you already have an account with us.

27 |

You can access your account by logging in

28 |

Please let us know if you have any other questions or feedback.

29 |

Best,

30 |

The ${appName} Team`

31 |
34 |
39 | 40 | 57 | 58 | 59 |
60 |
 
64 | 65 | -------------------------------------------------------------------------------- /emails/dist/welcome-known.text: -------------------------------------------------------------------------------- 1 | Welcome back! 2 | --- 3 | 4 | Looks like you just tried to signup with ${email}, but you already have an account with us. 5 | You can access your account by clicking ${url}/login?email=${email} 6 | Please let us know if you have any other questions or feedback. 7 | 8 | --- 9 | Best, 10 | The ${appName} Team 11 | -------------------------------------------------------------------------------- /emails/dist/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ${appName} 7 | 8 | 9 | 10 | 11 | 12 | 72 | 73 | 74 |
  13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 46 | 47 | 48 | 49 |
22 | 23 | 24 | 43 | 44 |
25 | 26 | 27 | 28 | 37 | 38 | 39 |
29 | 30 | 31 | 32 | 33 | 34 | 35 |
Register
36 |
40 |

Best,

41 |

The ${appName} Team`

42 |
45 |
50 | 51 | 68 | 69 | 70 |
71 |
 
75 | 76 | -------------------------------------------------------------------------------- /emails/dist/welcome.text: -------------------------------------------------------------------------------- 1 | Welcome to ${appName} Registration 2 | --- 3 | 4 | To proceed with your registration, use the following link: 5 | ${url}/register?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /emails/src/admin-invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ${appName} Admin Invite 7 | 8 | 9 | 10 | 11 | 12 | 74 | 75 | 76 |
  13 |
14 | 15 | 16 | Admin invite. 17 | 18 | 19 | 20 | 21 | 48 | 49 | 50 | 51 |
22 | 23 | 24 | 45 | 46 |
25 |

Hi there,

26 |

Sometimes you just want to send a simple HTML email with a simple design and clear call to action. This is it.

27 | 28 | 29 | 30 | 39 | 40 | 41 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
Call To Action
38 |
42 |

This is a really simple email template. Its sole purpose is to get the recipient to click the button with no distractions.

43 |

Good luck! Hope it works.

44 |
47 |
52 | 53 | 54 | 69 | 70 | 71 | 72 |
73 |
 
77 | 78 | -------------------------------------------------------------------------------- /emails/src/admin-invite.text: -------------------------------------------------------------------------------- 1 | Welcome to ${appName} Registration 2 | --- 3 | 4 | To proceed with your registration, use the following link: 5 | ${adminUrl}/register?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /emails/src/reset-password-unknown.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 61 | 62 | 63 |
  14 |
15 | 16 | 17 | Reset Password Request 18 | 19 | 20 | 21 | 22 | 35 | 36 | 37 | 38 |
23 | 24 | 25 | 32 | 33 |
26 |

You asked us to send you a password reset link. It turns out the email address ${email} doesn't have an account yet.

27 |

If you’re pretty sure you do have an account, you can try with a different email address.

28 |

Please let us know if you have any other questions or feedback.

29 |

Best,

30 |

The ${appName} Team`

31 |
34 |
39 | 40 | 57 | 58 | 59 |
60 |
 
64 | 65 | -------------------------------------------------------------------------------- /emails/src/reset-password-unknown.text: -------------------------------------------------------------------------------- 1 | Reset Password Request 2 | --- 3 | 4 | You asked us to send you a password reset link. It turns out the email address {email} doesn't have an account yet. 5 | 6 | If you’re pretty sure you do have an account, you can try with a different email address. 7 | 8 | Please let us know if you have any other questions or feedback. 9 | 10 | --- 11 | Cheers, 12 | The ${appName} Team 13 | 14 | -------------------------------------------------------------------------------- /emails/src/reset-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 73 | 74 | 75 |
  14 |
15 | 16 | 17 | Reset Password Request 18 | 19 | 20 | 21 | 22 | 47 | 48 | 49 | 50 |
23 | 24 | 25 | 44 | 45 |
26 | 27 | 28 | 29 | 38 | 39 | 40 |
30 | 31 | 32 | 33 | 34 | 35 | 36 |
Reset Password
37 |
41 |

Best,

42 |

The ${appName} Team`

43 |
46 |
51 | 52 | 69 | 70 | 71 |
72 |
 
76 | 77 | -------------------------------------------------------------------------------- /emails/src/reset-password.text: -------------------------------------------------------------------------------- 1 | Reset Password Request 2 | --- 3 | 4 | To proceed with your reset your password, use the following link: 5 | ${url}/reset-password?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /emails/src/style.css: -------------------------------------------------------------------------------- 1 | /* ------------------------------------- 2 | GLOBAL RESETS 3 | ------------------------------------- */ 4 | img { 5 | border: none; 6 | -ms-interpolation-mode: bicubic; 7 | max-width: 100%; 8 | } 9 | 10 | body { 11 | background-color: #000005; 12 | font-family: sans-serif; 13 | -webkit-font-smoothing: antialiased; 14 | font-size: 16px; 15 | line-height: 1.5; 16 | margin: 0; 17 | padding: 0; 18 | -ms-text-size-adjust: 100%; 19 | -webkit-text-size-adjust: 100%; 20 | } 21 | 22 | table { 23 | border-collapse: separate; 24 | mso-table-lspace: 0pt; 25 | mso-table-rspace: 0pt; 26 | width: 100%; 27 | } 28 | table td { 29 | font-family: sans-serif; 30 | font-size: 14px; 31 | vertical-align: top; 32 | } 33 | 34 | /* ------------------------------------- 35 | BODY & CONTAINER 36 | ------------------------------------- */ 37 | 38 | .body { 39 | background-color: #000005; 40 | width: 100%; 41 | } 42 | 43 | /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */ 44 | .container { 45 | display: block; 46 | margin: 0 auto !important; 47 | /* makes it centered */ 48 | max-width: 580px; 49 | padding: 10px; 50 | width: 580px; 51 | } 52 | 53 | /* This should also be a block element, so that it will fill 100% of the .container */ 54 | .content { 55 | box-sizing: border-box; 56 | display: block; 57 | margin: 0 auto; 58 | max-width: 580px; 59 | padding: 10px; 60 | } 61 | 62 | /* ------------------------------------- 63 | HEADER, FOOTER, MAIN 64 | ------------------------------------- */ 65 | .logo { 66 | display: block; 67 | margin: 0 auto 10px auto; 68 | } 69 | 70 | .main { 71 | background: #f2f6fc; 72 | border-radius: 3px; 73 | width: 100%; 74 | } 75 | 76 | .wrapper { 77 | box-sizing: border-box; 78 | padding: 20px; 79 | } 80 | 81 | .content-block { 82 | padding-bottom: 10px; 83 | padding-top: 10px; 84 | } 85 | 86 | .phrase { 87 | padding-left: 12px; 88 | padding-right: 12px; 89 | background-color: #ffffff; 90 | border: 1px solid #fbce0e; 91 | margin-bottom: 20px; 92 | } 93 | 94 | .footer { 95 | clear: both; 96 | margin-top: 10px; 97 | text-align: center; 98 | width: 100%; 99 | } 100 | .footer td, 101 | .footer p, 102 | .footer span, 103 | .footer a { 104 | color: #999999; 105 | font-size: 12px; 106 | text-align: center; 107 | } 108 | 109 | /* ------------------------------------- 110 | TYPOGRAPHY 111 | ------------------------------------- */ 112 | h1, 113 | h2, 114 | h3, 115 | h4 { 116 | color: #000000; 117 | font-family: sans-serif; 118 | font-weight: bold; 119 | line-height: 1.4; 120 | margin: 0; 121 | margin-bottom: 20px; 122 | } 123 | 124 | h1 { 125 | font-size: 35px; 126 | font-weight: 300; 127 | text-align: center; 128 | text-transform: capitalize; 129 | } 130 | 131 | p, 132 | ul, 133 | ol { 134 | font-family: sans-serif; 135 | font-size: 14px; 136 | font-weight: normal; 137 | margin: 0; 138 | margin-bottom: 15px; 139 | } 140 | p li, 141 | ul li, 142 | ol li { 143 | list-style-position: inside; 144 | margin-left: 5px; 145 | } 146 | 147 | a { 148 | color: #3498db; 149 | text-decoration: underline; 150 | } 151 | 152 | /* ------------------------------------- 153 | BUTTONS 154 | ------------------------------------- */ 155 | .btn { 156 | box-sizing: border-box; 157 | width: 100%; 158 | } 159 | .btn > tbody > tr > td { 160 | padding-bottom: 15px; 161 | } 162 | .btn table { 163 | width: auto; 164 | } 165 | .btn table td { 166 | background-color: #ffffff; 167 | border-radius: 5px; 168 | text-align: center; 169 | } 170 | .btn a { 171 | background-color: #050520; 172 | border: solid 1px #050520; 173 | border-radius: 3px; 174 | box-sizing: border-box; 175 | color: #fff; 176 | cursor: pointer; 177 | display: inline-block; 178 | font-size: 14px; 179 | font-weight: bold; 180 | margin: 0; 181 | padding: 12px 40px; 182 | text-decoration: none; 183 | text-transform: capitalize; 184 | } 185 | 186 | .btn-primary table td { 187 | background-color: #050520; 188 | } 189 | 190 | .btn-primary a { 191 | background-color: #050520; 192 | border-color: #050520; 193 | color: #ffffff; 194 | } 195 | 196 | /* ------------------------------------- 197 | OTHER STYLES THAT MIGHT BE USEFUL 198 | ------------------------------------- */ 199 | .last { 200 | margin-bottom: 0; 201 | } 202 | 203 | .first { 204 | margin-top: 0; 205 | } 206 | 207 | .align-center { 208 | text-align: center; 209 | } 210 | 211 | .align-right { 212 | text-align: right; 213 | } 214 | 215 | .align-left { 216 | text-align: left; 217 | } 218 | 219 | .clear { 220 | clear: both; 221 | } 222 | 223 | .mt0 { 224 | margin-top: 0; 225 | } 226 | 227 | .mb0 { 228 | margin-bottom: 0; 229 | } 230 | 231 | .preheader { 232 | color: transparent; 233 | display: none; 234 | height: 0; 235 | max-height: 0; 236 | max-width: 0; 237 | opacity: 0; 238 | overflow: hidden; 239 | mso-hide: all; 240 | visibility: hidden; 241 | width: 0; 242 | } 243 | 244 | .powered-by a { 245 | text-decoration: none; 246 | } 247 | 248 | hr { 249 | border: 0; 250 | border-bottom: 1px solid #f6f6f6; 251 | margin: 20px 0; 252 | } 253 | 254 | /* ------------------------------------- 255 | RESPONSIVE AND MOBILE FRIENDLY STYLES 256 | ------------------------------------- */ 257 | @media only screen and (max-width: 620px) { 258 | table[class="body"] h1 { 259 | font-size: 28px !important; 260 | margin-bottom: 10px !important; 261 | } 262 | table[class="body"] p, 263 | table[class="body"] ul, 264 | table[class="body"] ol, 265 | table[class="body"] td, 266 | table[class="body"] span, 267 | table[class="body"] a { 268 | font-size: 16px !important; 269 | } 270 | table[class="body"] .wrapper, 271 | table[class="body"] .article { 272 | padding: 10px !important; 273 | } 274 | table[class="body"] .content { 275 | padding: 0 !important; 276 | } 277 | table[class="body"] .container { 278 | padding: 0 !important; 279 | width: 100% !important; 280 | } 281 | table[class="body"] .main { 282 | border-left-width: 0 !important; 283 | border-radius: 0 !important; 284 | border-right-width: 0 !important; 285 | } 286 | table[class="body"] .btn table { 287 | width: 100% !important; 288 | } 289 | table[class="body"] .btn a { 290 | width: 100% !important; 291 | } 292 | table[class="body"] .img-responsive { 293 | height: auto !important; 294 | max-width: 100% !important; 295 | width: auto !important; 296 | } 297 | } 298 | 299 | /* ------------------------------------- 300 | PRESERVE THESE STYLES IN THE HEAD 301 | ------------------------------------- */ 302 | @media all { 303 | .ExternalClass { 304 | width: 100%; 305 | } 306 | .ExternalClass, 307 | .ExternalClass p, 308 | .ExternalClass span, 309 | .ExternalClass font, 310 | .ExternalClass td, 311 | .ExternalClass div { 312 | line-height: 100%; 313 | } 314 | .apple-link a { 315 | color: inherit !important; 316 | font-family: inherit !important; 317 | font-size: inherit !important; 318 | font-weight: inherit !important; 319 | line-height: inherit !important; 320 | text-decoration: none !important; 321 | } 322 | .btn-primary table td:hover { 323 | background-color: #34495e !important; 324 | } 325 | .btn-primary a:hover { 326 | background-color: #34495e !important; 327 | border-color: #34495e !important; 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /emails/src/welcome-known.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${appName} 8 | 9 | 10 | 11 | 12 | 13 | 61 | 62 | 63 |
  14 |
15 | 16 | 17 | Welcome back! 18 | 19 | 20 | 21 | 22 | 35 | 36 | 37 | 38 |
23 | 24 | 25 | 32 | 33 |
26 |

Looks like you just tried to signup with ${email}, but you already have an account with us.

27 |

You can access your account by logging in

28 |

Please let us know if you have any other questions or feedback.

29 |

Best,

30 |

The ${appName} Team`

31 |
34 |
39 | 40 | 57 | 58 | 59 |
60 |
 
64 | 65 | -------------------------------------------------------------------------------- /emails/src/welcome-known.text: -------------------------------------------------------------------------------- 1 | Welcome back! 2 | --- 3 | 4 | Looks like you just tried to signup with ${email}, but you already have an account with us. 5 | You can access your account by clicking ${url}/login?email=${email} 6 | Please let us know if you have any other questions or feedback. 7 | 8 | --- 9 | Best, 10 | The ${appName} Team 11 | -------------------------------------------------------------------------------- /emails/src/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ${appName} 7 | 8 | 9 | 10 | 11 | 12 | 72 | 73 | 74 |
  13 |
14 | 15 | 16 | Welcome to ${appName} Registration 17 | 18 | 19 | 20 | 21 | 46 | 47 | 48 | 49 |
22 | 23 | 24 | 43 | 44 |
25 | 26 | 27 | 28 | 37 | 38 | 39 |
29 | 30 | 31 | 32 | 33 | 34 | 35 |
Register
36 |
40 |

Best,

41 |

The ${appName} Team`

42 |
45 |
50 | 51 | 68 | 69 | 70 |
71 |
 
75 | 76 | -------------------------------------------------------------------------------- /emails/src/welcome.text: -------------------------------------------------------------------------------- 1 | Welcome to ${appName} Registration 2 | --- 3 | 4 | To proceed with your registration, use the following link: 5 | ${url}/register?token=${token} 6 | --- 7 | Best, 8 | The ${appName} Team 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boiler-api", 3 | "version": "1.0.0", 4 | "description": "Open Software for Token Distribution", 5 | "main": "dist", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=9.6.1" 9 | }, 10 | "pre-commit": ["lint"], 11 | "scripts": { 12 | "dev": "nodemon -w src --exec \"MOCK_EMAIL=true NODE_ENV=dev node src/index.js\"", 13 | "start": "NODE_ENV=prod node src/index", 14 | "lint": "eslint src", 15 | "jest": "jest", 16 | "test": "jest -i src", 17 | "test:watch": "jest --watch -i src", 18 | "emails": "node emails/build" 19 | }, 20 | "dependencies": { 21 | "@koa/cors": "2.2.1", 22 | "bcrypt": "2.0.0", 23 | "config": "1.30.0", 24 | "joi": "13.2.0", 25 | "jsonwebtoken": "8.2.1", 26 | "koa": "2.5.0", 27 | "koa-bodyparser": "4.2.0", 28 | "koa-logger": "3.2.0", 29 | "koa-router": "7.4.0", 30 | "lodash": "4.17.5", 31 | "mockgoose": "^7.3.5", 32 | "mongoose": "5.0.15", 33 | "postmark": "1.6.1" 34 | }, 35 | "devDependencies": { 36 | "eslint": "4.19.1", 37 | "eslint-config-prettier": "^2.9.0", 38 | "eslint-plugin-jest": "^21.15.1", 39 | "jest": "22.4.3", 40 | "juice": "4.2.3", 41 | "nodemon": "1.17.3", 42 | "pre-commit": "1.2.2", 43 | "prettier": "1.12.1", 44 | "prettier-eslint": "8.8.1", 45 | "supertest": "3.0.0" 46 | }, 47 | "jest": { 48 | "setupFiles": ["/src/test-helpers/setup-tests.js"] 49 | }, 50 | "eslintConfig": { 51 | "parserOptions": { 52 | "ecmaVersion": 2017, 53 | "ecmaFeatures": { 54 | "experimentalObjectRestSpread": true 55 | } 56 | }, 57 | "env": { 58 | "es6": true, 59 | "node": true 60 | }, 61 | "extends": ["eslint:recommended", "plugin:jest/recommended", "prettier"], 62 | "rules": { 63 | "no-console": 0 64 | } 65 | }, 66 | "prettier": { 67 | "singleQuote": true, 68 | "arrowParens": "always", 69 | "printWidth": 120 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scripts/ensure-config.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | 3 | function getChangeMeKeys(data, k = '') { 4 | const result = []; 5 | for (var i in data) { 6 | var rest = k.length ? '.' + i : i; 7 | 8 | if (typeof data[i] == 'object') { 9 | if (!Array.isArray(data[i])) { 10 | result.push(...getChangeMeKeys(data[i], k + rest)); 11 | } 12 | } else if (data[i] === '[change me]') { 13 | result.push(k + rest); 14 | } 15 | } 16 | return result; 17 | } 18 | 19 | const ensureConfig = () => { 20 | const keys = getChangeMeKeys(config.util.toObject(config)); 21 | 22 | if (keys.length) { 23 | console.error('The following configuration is required to be set before starting server in production environment'); 24 | console.error(''); 25 | console.error(keys); 26 | console.error(''); 27 | console.error('Either updated config/prod.json or configure enviroments variables with valid values'); 28 | console.error(''); 29 | console.error('Shutting down'); 30 | console.error(''); 31 | process.exit(1); 32 | } 33 | }; 34 | 35 | module.exports = ensureConfig; 36 | -------------------------------------------------------------------------------- /scripts/setup-fixtures.js: -------------------------------------------------------------------------------- 1 | const User = require('../src/models/user'); 2 | const config = require('config'); 3 | 4 | const adminConfig = config.get('admin'); 5 | 6 | const createUsers = async () => { 7 | if (await User.findOne({ email: adminConfig.email })) { 8 | return false; 9 | } 10 | const adminUser = await User.create(adminConfig); 11 | console.log(`Added admin user ${adminUser.email} to database`); 12 | return true; 13 | }; 14 | 15 | module.exports = createUsers; 16 | -------------------------------------------------------------------------------- /src/__tests__/app.test.js: -------------------------------------------------------------------------------- 1 | const { request } = require('../test-helpers'); 2 | 3 | describe('Test App Index', () => { 4 | test('It should have a valid index response', async () => { 5 | const response = await request('GET', '/'); 6 | expect(response.statusCode).toBe(200); 7 | expect(typeof response.body.version).toBe('string'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const Koa = require('koa'); 3 | const cors = require('@koa/cors'); 4 | const logger = require('koa-logger'); 5 | const bodyParser = require('koa-bodyparser'); 6 | const errorHandler = require('./middlewares/error-handler'); 7 | const config = require('config'); 8 | const { version } = require('../package.json'); 9 | const v1 = require('./v1'); 10 | 11 | const app = new Koa(); 12 | 13 | app 14 | .use(errorHandler) 15 | .use(logger()) 16 | .use( 17 | cors({ 18 | ...config.get('cors') 19 | }) 20 | ) 21 | .use(bodyParser()); 22 | 23 | app.on('error', (err) => { 24 | // dont output stacktraces of errors that is throw with status as they are known 25 | if (!err.status || err.status === 500) { 26 | console.error(err.stack); 27 | } 28 | }); 29 | 30 | const router = new Router(); 31 | router.get('/', (ctx) => { 32 | ctx.body = { version }; 33 | }); 34 | router.use('/1', v1.routes()); 35 | 36 | app.use(router.routes()); 37 | app.use(router.allowedMethods()); 38 | 39 | module.exports = app; 40 | -------------------------------------------------------------------------------- /src/database.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const mongoose = require('mongoose'); 3 | 4 | mongoose.Promise = Promise; 5 | if (config.get('mongo.debug', false)) { 6 | mongoose.set('debug', true); 7 | } 8 | 9 | module.exports = async () => { 10 | await mongoose.connect(config.get('mongo.uri'), config.get('mongo.options')); 11 | const db = mongoose.connection; 12 | db.on('error', console.error.bind(console, 'connection error:')); 13 | db.once('open', () => { 14 | console.log('mongodb connected'); 15 | }); 16 | return db; 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const database = require('./database'); 2 | const setupFixtures = require('../scripts/setup-fixtures'); 3 | const ensureConfig = require('../scripts/ensure-config'); 4 | const { initialize: initializeEmails } = require('./lib/emails'); 5 | const app = require('./app'); 6 | const config = require('config'); 7 | 8 | const PORT = config.get('bind.port'); 9 | const HOST = config.get('bind.host'); 10 | 11 | module.exports = (async () => { 12 | ensureConfig(); 13 | 14 | await initializeEmails(); 15 | await database(); 16 | await setupFixtures(); 17 | 18 | app.listen(PORT, HOST, () => { 19 | console.log(`Started on port //${HOST}:${PORT}`); 20 | }); 21 | 22 | return app; 23 | })(); 24 | -------------------------------------------------------------------------------- /src/lib/__tests__/emails.js: -------------------------------------------------------------------------------- 1 | const { initialize: initializeEmails } = require('../../lib/emails'); 2 | 3 | jest.mock('../mailer'); 4 | 5 | const { sendMail } = require('../mailer'); 6 | 7 | const { sendWelcome } = require('../emails'); 8 | 9 | beforeAll(async () => { 10 | await initializeEmails(); 11 | }); 12 | 13 | describe('Emails', () => { 14 | it.skip('sendWelcome', async () => { 15 | sendWelcome('foo@bar.com', { token: '$token' }); 16 | const optionsArgs = sendMail.mock.calls[0][0]; 17 | expect(optionsArgs.to).toBe('foo@bar.com'); 18 | const templateArgs = sendMail.mock.calls[0][1]; 19 | expect(templateArgs.html.includes('$token')).toBe(true); 20 | expect(templateArgs.text.includes('$token')).toBe(true); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/lib/emails.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { sendMail } = require('./mailer'); 4 | const config = require('config'); 5 | const { template: templateFn } = require('./utils'); 6 | const { promisify } = require('util'); 7 | 8 | const templatesDist = path.join(__dirname, '../../emails/dist'); 9 | const templates = {}; 10 | 11 | const defaultOptions = config.get('app'); 12 | 13 | const readdir = promisify(fs.readdir); 14 | const readFile = promisify(fs.readFile); 15 | 16 | exports.initialize = async () => { 17 | const files = await readdir(templatesDist); 18 | await Promise.all( 19 | files.map((file) => { 20 | return readFile(path.join(templatesDist, file)).then((str) => { 21 | templates[file] = str.toString(); 22 | }); 23 | }) 24 | ); 25 | }; 26 | 27 | function template(templateName, map) { 28 | const templateStr = templates[templateName]; 29 | if (!templateStr) 30 | throw Error(`Cant find template by ${templateName}. Available templates: ${Object.keys(templates)}`); 31 | return templateFn(templateStr, map); 32 | } 33 | 34 | exports.sendWelcome = ({ to, token }) => { 35 | const options = { 36 | ...defaultOptions, 37 | token 38 | }; 39 | 40 | sendMail( 41 | { 42 | to, 43 | subject: 'Welcome Registration' 44 | }, 45 | { 46 | html: template('welcome.html', options), 47 | text: template('welcome.text', options) 48 | } 49 | ); 50 | }; 51 | 52 | exports.sendWelcomeKnown = ({ to, name }) => { 53 | const options = { 54 | ...defaultOptions, 55 | name, 56 | email: to 57 | }; 58 | 59 | sendMail( 60 | { 61 | to, 62 | subject: 'Welcome Back' 63 | }, 64 | { 65 | html: template('welcome-known.html', options), 66 | text: template('welcome-known.text', options) 67 | } 68 | ); 69 | }; 70 | 71 | exports.sendResetPasswordUnknown = ({ to }) => { 72 | const options = { 73 | ...defaultOptions, 74 | email: to 75 | }; 76 | 77 | sendMail( 78 | { 79 | to, 80 | subject: `Password Reset Request` 81 | }, 82 | { 83 | html: template('reset-password-unknown.html', options), 84 | text: template('reset-password-unknown.text', options) 85 | } 86 | ); 87 | }; 88 | 89 | exports.sendResetPassword = ({ to, token }) => { 90 | const options = { 91 | ...defaultOptions, 92 | email: to, 93 | token 94 | }; 95 | 96 | sendMail( 97 | { 98 | to, 99 | subject: `Password Reset Request` 100 | }, 101 | { 102 | html: template('reset-password.html', options), 103 | text: template('reset-password.text', options) 104 | } 105 | ); 106 | }; 107 | 108 | exports.sendAdminInvite = ({ to, token }) => { 109 | const options = { 110 | ...defaultOptions, 111 | token 112 | }; 113 | 114 | sendMail( 115 | { 116 | to, 117 | subject: `Admin Invitation for ${options.name}` 118 | }, 119 | { 120 | html: template('admin-invite.html', options), 121 | text: template('admin-invite.text', options) 122 | } 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/lib/errors.js: -------------------------------------------------------------------------------- 1 | exports.ValidationError = class ValidationError extends Error { 2 | constructor(message, fields) { 3 | super(message); 4 | this.name = this.constructor.name; 5 | Error.captureStackTrace(this, this.constructor); 6 | this.status = 400; 7 | if (fields) { 8 | this.fields = Array.isArray(fields) ? fields : [fields]; 9 | } 10 | } 11 | }; 12 | 13 | exports.AccessError = class ValidationError extends Error { 14 | constructor(message) { 15 | super(message); 16 | this.name = this.constructor.name; 17 | Error.captureStackTrace(this, this.constructor); 18 | this.status = 401; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/mailer.js: -------------------------------------------------------------------------------- 1 | const postmark = require('postmark'); 2 | const config = require('config'); 3 | 4 | const apiKey = config.get('postmark.apiKey'); 5 | const from = config.get('postmark.from'); 6 | 7 | exports.sendMail = ({ to, subject }, { html, text }) => { 8 | if (process.env.MOCK_EMAIL) { 9 | console.log(`Sending email to ${to}`); 10 | console.log(`Subject: ${subject}`); 11 | console.log('Body:'); 12 | console.log(html); 13 | console.log(text); 14 | } else { 15 | const client = new postmark.Client(apiKey); 16 | const env = process.env.NODE_ENV; 17 | if (env !== 'test') { 18 | client 19 | .sendEmail({ 20 | From: from, 21 | To: to, 22 | Subject: subject, 23 | TextBody: text, 24 | HtmlBody: html 25 | }) 26 | .catch((error) => { 27 | console.error(`Error happened while sending email to ${to} (${error.message})`); 28 | console.error(error); 29 | }); 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/lib/tokens.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const config = require('config'); 3 | 4 | const expiresIn = config.get('jwt.expiresIn'); 5 | const secrets = { 6 | user: config.get('jwt.secret') 7 | }; 8 | 9 | exports.createUserTemporaryToken = (claims, type) => { 10 | return jwt.sign( 11 | { 12 | ...claims, 13 | type, 14 | kid: 'user' 15 | }, 16 | secrets.user, 17 | { 18 | expiresIn: expiresIn.temporary 19 | } 20 | ); 21 | }; 22 | 23 | exports.createUserToken = (user) => { 24 | return jwt.sign( 25 | { 26 | userId: user._id, 27 | type: 'user', 28 | kid: 'user' 29 | }, 30 | secrets.user, 31 | { 32 | expiresIn: expiresIn.regular 33 | } 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | function templateGet(p, obj) { 2 | return p.split('.').reduce((res, key) => { 3 | const r = res[key]; 4 | if (typeof r === 'undefined') { 5 | throw Error(`template: was not provided a value for attribute $${key}`); 6 | } 7 | return r; 8 | }, obj); 9 | } 10 | 11 | exports.template = (template, map) => { 12 | return template.replace(/\$\{.+?}/g, (match) => { 13 | const p = match.substr(2, match.length - 3).trim(); 14 | return templateGet(p, map); 15 | }); 16 | }; 17 | 18 | exports.sleep = (ms) => { 19 | return new Promise((r) => setTimeout(r, ms)); 20 | }; 21 | -------------------------------------------------------------------------------- /src/middlewares/__tests__/authenticate.js: -------------------------------------------------------------------------------- 1 | const authenticate = require('../authenticate'); 2 | const { context } = require('../../test-helpers'); 3 | const jwt = require('jsonwebtoken'); 4 | const config = require('config'); 5 | 6 | describe('authenticate', () => { 7 | it('should trigger an error if jwt token can not be found', async () => { 8 | const middleware = authenticate(); 9 | let ctx; 10 | ctx = context({ headers: { notAuthorization: 'Bearer $token' } }); 11 | await expect(middleware(ctx)).rejects.toHaveProperty('message', 'no jwt token found'); 12 | 13 | ctx = context({ headers: { authorization: 'not Bearer $token' } }); 14 | await expect(middleware(ctx)).rejects.toHaveProperty('message', 'no jwt token found'); 15 | 16 | const customTokenLocation = authenticate({}, { getToken: () => null }); 17 | ctx = context({ headers: { authorization: 'Bearer $token' } }); 18 | await expect(customTokenLocation(ctx)).rejects.toHaveProperty('message', 'no jwt token found'); 19 | }); 20 | 21 | it('should trigger an error if token is bad', async () => { 22 | const middleware = authenticate(); 23 | let ctx; 24 | ctx = context({ headers: { authorization: 'Bearer badToken' } }); 25 | await expect(middleware(ctx)).rejects.toHaveProperty('message', 'bad jwt token'); 26 | ctx = context({}); 27 | 28 | const customTokenLocation = authenticate({}, { getToken: () => 'bad token' }); 29 | await expect(customTokenLocation(ctx)).rejects.toHaveProperty('message', 'bad jwt token'); 30 | }); 31 | 32 | it('should confirm that token has a valid kid', async () => { 33 | const middleware = authenticate(); 34 | const token = jwt.sign({ kid: 'not valid kid' }, 'verysecret'); 35 | const ctx = context({ headers: { authorization: `Bearer ${token}` } }); 36 | await expect(middleware(ctx)).rejects.toHaveProperty('message', 'jwt token does not match supported kid'); 37 | }); 38 | 39 | it('should confirm that type if specify in middleware', async () => { 40 | const middleware = authenticate({ 41 | type: 'sometype' 42 | }); 43 | 44 | const token = jwt.sign({ kid: 'user', type: 'not same type' }, 'verysecret'); 45 | const ctx = context({ headers: { authorization: `Bearer ${token}` } }); 46 | await expect(middleware(ctx)).rejects.toHaveProperty( 47 | 'message', 48 | 'endpoint requires jwt token payload match type "sometype"' 49 | ); 50 | }); 51 | 52 | it('should fail if token doesnt have rigth signature', async () => { 53 | const middleware = authenticate(); 54 | const token = jwt.sign({ kid: 'user' }, 'verysecret'); 55 | const ctx = context({ headers: { authorization: `Bearer ${token}` } }); 56 | await expect(middleware(ctx)).rejects.toHaveProperty('message', 'invalid signature'); 57 | }); 58 | 59 | it('should fail if expired', async () => { 60 | const middleware = authenticate(); 61 | const token = jwt.sign({ kid: 'user' }, config.get('jwt.secret'), { expiresIn: 0 }); 62 | const ctx = context({ headers: { authorization: `Bearer ${token}` } }); 63 | await expect(middleware(ctx)).rejects.toHaveProperty('message', 'jwt expired'); 64 | }); 65 | 66 | it('it should work with valid secet and not expired', async () => { 67 | const middleware = authenticate(); 68 | const token = jwt.sign({ kid: 'user', attribute: 'value' }, config.get('jwt.secret')); 69 | const ctx = context({ headers: { authorization: `Bearer ${token}` } }); 70 | await middleware(ctx, () => { 71 | expect(ctx.state.jwt.attribute).toBe('value'); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/middlewares/__tests__/validate.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const validate = require('../validate'); 3 | const { context } = require('../../test-helpers'); 4 | 5 | describe('validate', () => { 6 | it("should throw an error when the request doesn't contain a key as specified in Joi schema", async () => { 7 | const middleware = validate({ 8 | body: {} // Does not exist in given context 9 | }); 10 | const ctx = context(); 11 | 12 | await expect(middleware(ctx)).rejects.toHaveProperty( 13 | 'message', 14 | "Specified schema key 'body' does not exist in 'request' object" 15 | ); 16 | }); 17 | 18 | it('should reject a request with invalid params', async () => { 19 | // Require test param, but don't provide it in ctx object: 20 | const middleware = validate({ 21 | body: Joi.object().keys({ test: Joi.number().required() }) 22 | }); 23 | 24 | const ctx = context(); 25 | ctx.request.body = { fail: 'fail' }; 26 | 27 | await expect(middleware(ctx)).rejects.toHaveProperty('status', 400); 28 | }); 29 | 30 | it('should accept a valid request', async () => { 31 | const middleware = validate({ 32 | body: Joi.object().keys({ test: Joi.string().required() }) 33 | }); 34 | const ctx = context({ url: '/' }); 35 | ctx.request.body = { test: 'something' }; 36 | 37 | await middleware(ctx, () => { 38 | expect(ctx.request.body.test).toBe('something'); 39 | }); 40 | }); 41 | 42 | it('should support the light syntax', async () => { 43 | const middleware = validate({ 44 | body: { test: Joi.string().required() } 45 | }); 46 | const ctx = context({ url: '/' }); 47 | ctx.request.body = { test: 'something' }; 48 | 49 | await middleware(ctx, () => { 50 | expect(ctx.request.body.test).toBe('something'); 51 | }); 52 | }); 53 | 54 | it('should do type conversion for query', async () => { 55 | const middleware = validate({ 56 | query: { convertToNumber: Joi.number().required() } 57 | }); 58 | const ctx = context({ url: '/' }); 59 | ctx.request.query = { 60 | convertToNumber: '1234' 61 | }; 62 | 63 | await middleware(ctx, () => { 64 | expect(ctx.request.query.convertToNumber).toBe(1234); 65 | }); 66 | }); 67 | 68 | it('should not allow attributes that are not defined', async () => { 69 | const middleware = validate({ 70 | query: { somethingExisting: Joi.string() } 71 | }); 72 | 73 | const ctx = context({ url: '/' }); 74 | ctx.request.query = { 75 | somethingExisting: 'yes', 76 | shouldBeRemoved: 'should be been removed from request' 77 | }; 78 | 79 | await expect(middleware(ctx)).rejects.toHaveProperty('status', 400); 80 | await expect(middleware(ctx)).rejects.toHaveProperty('message', '"shouldBeRemoved" is not allowed'); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/middlewares/authenticate.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const config = require('config'); 3 | 4 | const secrets = { 5 | user: config.get('jwt.secret') 6 | }; 7 | 8 | function getToken(ctx) { 9 | let token; 10 | const parts = (ctx.request.get('authorization') || '').split(' '); 11 | if (parts.length === 2) { 12 | const [scheme, credentials] = parts; 13 | if (/^Bearer$/i.test(scheme)) token = credentials; 14 | } 15 | return token; 16 | } 17 | 18 | module.exports = ({ type } = {}, options = {}) => { 19 | return async (ctx, next) => { 20 | const token = options.getToken ? options.getToken(ctx) : getToken(ctx); 21 | if (!token) ctx.throw(400, 'no jwt token found'); 22 | 23 | // ignoring signature for the moment 24 | const decoded = jwt.decode(token, { complete: true }); 25 | if (decoded === null) return ctx.throw(400, 'bad jwt token'); 26 | const { payload } = decoded; 27 | const keyId = payload.kid; 28 | if (!['user'].includes(keyId)) { 29 | ctx.throw(401, 'jwt token does not match supported kid'); 30 | } 31 | 32 | if (type && payload.type !== type) { 33 | ctx.throw(401, `endpoint requires jwt token payload match type "${type}"`); 34 | } 35 | 36 | // confirming signature 37 | try { 38 | jwt.verify(token, secrets[keyId]); // verify will throw 39 | } catch (e) { 40 | ctx.throw(401, e); 41 | } 42 | ctx.state.jwt = payload; 43 | return next(); 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/middlewares/error-handler.js: -------------------------------------------------------------------------------- 1 | module.exports = async (ctx, next) => { 2 | try { 3 | await next(); 4 | } catch (err) { 5 | const errorStatus = Number.isInteger(err.status) ? err.status : 500; 6 | ctx.status = errorStatus; 7 | ctx.body = { 8 | error: { 9 | message: err.message 10 | } 11 | }; 12 | ctx.app.emit('error', err, ctx); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/middlewares/validate.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports = function validate(schemas, options) { 4 | const defaultOptions = { 5 | allowUnknown: false, 6 | abortEarly: false 7 | }; 8 | 9 | return async function Validate(ctx, next) { 10 | Object.keys(schemas).forEach((key) => { 11 | const schema = schemas[key]; 12 | const requestItem = ctx.request[key]; 13 | ctx.request[`_${key}`] = requestItem; 14 | 15 | if (!requestItem) { 16 | const error = new Error(`Specified schema key '${key}' does not exist in 'request' object`); 17 | error.meta = { 18 | request: ctx.request 19 | }; 20 | ctx.throw(500, error); 21 | } 22 | 23 | Joi.validate( 24 | requestItem, 25 | schema, 26 | { 27 | ...defaultOptions, 28 | ...options 29 | }, 30 | (err, validatedItem) => { 31 | if (err) { 32 | err.details = err.details.map((detail) => { 33 | //eslint-disable-line 34 | return { 35 | ...detail, 36 | meta: { 37 | parsedValue: validatedItem[detail.path], 38 | // Explicitly show it was not provided 39 | providedValue: requestItem[detail.path] || null 40 | } 41 | }; 42 | }); 43 | ctx.throw(400, err); 44 | } 45 | 46 | const jSchema = schema.isJoi ? schema : Joi.object(schema); 47 | 48 | const unverifiedParams = Object.keys(validatedItem || {}).filter((param) => !Joi.reach(jSchema, param)); 49 | unverifiedParams.map((param) => delete validatedItem[param]); //eslint-disable-line 50 | 51 | // Koa (and the koa-qs module) uses a setter which causes the 52 | // query object to be stringified into a querystring when setting it 53 | // through `ctx.query =` or `ctx.request.query =`. The getter will then 54 | // parse the string whenever you request `ctx.request.query` or `ctx.query` 55 | // which will destroy Joi's automatic type conversion 56 | // making everything a string as soon as you set it. 57 | // 58 | // To get around this, when we're validating a query, we overwrite the 59 | // default query getter so it returns the converted object instead of the 60 | // idiotic stringified object if nothing about the querystring was changed: 61 | 62 | if (key === 'query') { 63 | const originalQueryGetter = ctx.request.query; 64 | const src = { 65 | get orginalQuery() { 66 | return originalQueryGetter; 67 | }, 68 | get query() { 69 | // https://github.com/koajs/koa/blob/9cef2db87e3066759afb9f002790cc24677cc913/lib/request.js#L168 70 | if (!this._querycache && Object.keys(validatedItem || {}).length) { 71 | return validatedItem; 72 | } 73 | 74 | return this._querycache && this._querycache[this.querystring] ? validatedItem : originalQueryGetter; 75 | } 76 | }; 77 | 78 | Object.getOwnPropertyNames(src).forEach((name) => { 79 | const descriptor = Object.getOwnPropertyDescriptor(src, name); 80 | Object.defineProperty(ctx.request, name, descriptor); 81 | }); 82 | } else { 83 | ctx.request[key] = validatedItem; 84 | } 85 | } 86 | ); 87 | }); 88 | return next(); 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | const { omit } = require('lodash'); 2 | const mongoose = require('mongoose'); 3 | const bcrypt = require('bcrypt'); 4 | 5 | const schema = new mongoose.Schema( 6 | { 7 | email: { 8 | type: String, 9 | unique: true, 10 | required: true, 11 | lowercase: true, 12 | trim: true 13 | }, 14 | name: { type: String, trim: true, required: true }, 15 | hashedPassword: { type: String } 16 | }, 17 | { 18 | timestamps: true 19 | } 20 | ); 21 | 22 | schema.methods.verifyPassword = function verifyPassword(password) { 23 | if (!this.hashedPassword) return false; 24 | return bcrypt.compare(password, this.hashedPassword); 25 | }; 26 | 27 | schema.virtual('password').set(function setPassword(password) { 28 | this._password = password; 29 | }); 30 | 31 | schema.pre('save', async function preSave(next) { 32 | if (this._password) { 33 | const salt = await bcrypt.genSalt(12); 34 | this.hashedPassword = await bcrypt.hash(this._password, salt); 35 | delete this._password; 36 | } 37 | return next(); 38 | }); 39 | 40 | schema.methods.toResource = function toResource() { 41 | return { 42 | id: this._id, 43 | ...omit(this.toObject(), ['_id', 'hashedPassword', '_password']) 44 | }; 45 | }; 46 | 47 | module.exports = mongoose.models.User || mongoose.model('User', schema); 48 | -------------------------------------------------------------------------------- /src/test-helpers/context.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-return-assign */ 2 | 3 | const Stream = require('stream'); 4 | const Koa = require('koa'); 5 | 6 | module.exports = (req, res, app) => { 7 | const socket = new Stream.Duplex(); 8 | req = Object.assign({ headers: {}, socket }, Stream.Readable.prototype, req); 9 | res = Object.assign({ _headers: {}, socket }, Stream.Writable.prototype, res); 10 | req.socket.remoteAddress = req.socket.remoteAddress || '127.0.0.1'; 11 | app = app || new Koa(); 12 | res.getHeader = (k) => res._headers[k.toLowerCase()]; 13 | res.setHeader = (k, v) => (res._headers[k.toLowerCase()] = v); 14 | res.removeHeader = (k) => delete res._headers[k.toLowerCase()]; 15 | return app.createContext(req, res); 16 | }; 17 | 18 | module.exports.request = (req, res, app) => module.exports(req, res, app).request; 19 | 20 | module.exports.response = (req, res, app) => module.exports(req, res, app).response; 21 | -------------------------------------------------------------------------------- /src/test-helpers/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const config = require('config'); 3 | const Mockgoose = require('mockgoose').Mockgoose; 4 | const mockgoose = new Mockgoose(mongoose); 5 | 6 | exports.context = require('./context'); 7 | exports.request = require('./request'); 8 | 9 | exports.setupDb = () => 10 | new Promise((resolve, reject) => { 11 | mockgoose.prepareStorage().then(function() { 12 | mongoose.connect(config.get('mongo.uri'), function(err) { 13 | if (err) return reject(err); 14 | resolve(err); 15 | }); 16 | }); 17 | }); 18 | 19 | exports.resetDb = () => mockgoose.helper.reset(); 20 | 21 | exports.teardownDb = async () => { 22 | await mockgoose.helper.reset(); 23 | await mongoose.disconnect(); 24 | 25 | // https://github.com/Mockgoose/Mockgoose/pull/72 26 | const promise = new Promise((resolve) => { 27 | mockgoose.mongodHelper.mongoBin.childProcess.on('exit', resolve); 28 | }); 29 | mockgoose.mongodHelper.mongoBin.childProcess.kill('SIGTERM'); 30 | return promise; 31 | }; 32 | -------------------------------------------------------------------------------- /src/test-helpers/request.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); //eslint-disable-line 2 | const app = require('../app'); 3 | const qs = require('querystring'); 4 | const tokens = require('../lib/tokens'); 5 | 6 | module.exports = async function handleRequest(httpMethod, url, bodyOrQuery = {}, options = {}) { 7 | const headers = {}; 8 | if (options.user) { 9 | headers.Authorization = `Bearer ${tokens.createUserToken(options.user)}`; 10 | } 11 | 12 | if (httpMethod === 'POST') { 13 | return request(app.callback()) 14 | .post(url) 15 | .set(headers) 16 | .send({ ...bodyOrQuery }); 17 | } else if (httpMethod === 'PATCH') { 18 | return request(app.callback()) 19 | .patch(url) 20 | .set(headers) 21 | .send({ ...bodyOrQuery }); 22 | } else if (httpMethod === 'DELETE') { 23 | return request(app.callback()) 24 | .del(url) 25 | .set(headers) 26 | .send({ ...bodyOrQuery }); 27 | } else if (httpMethod === 'GET') { 28 | return request(app.callback()) 29 | .get(`${url}?${qs.stringify({ ...bodyOrQuery })}`) 30 | .set(headers); 31 | } 32 | 33 | if (httpMethod === 'PUT') { 34 | throw Error('Use PATCH instead of PUT the api support PATCH not PUT'); 35 | } 36 | throw Error(`Method not support ${httpMethod} by handleRequest`); 37 | }; 38 | -------------------------------------------------------------------------------- /src/test-helpers/setup-tests.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(10000); 2 | -------------------------------------------------------------------------------- /src/v1/__tests__/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | const User = require('../../models/User'); 4 | const tokens = require('../../lib/tokens'); 5 | const emails = require('../../lib/emails'); 6 | 7 | const { initialize: initializeEmails } = require('../../lib/emails'); 8 | 9 | const { setupDb, teardownDb, request } = require('../../test-helpers'); 10 | 11 | jest.mock('../../lib/emails'); 12 | 13 | beforeAll(async () => { 14 | await initializeEmails(); 15 | await setupDb(); 16 | }); 17 | 18 | afterAll(async () => { 19 | await teardownDb(); 20 | }); 21 | 22 | describe('/1/users', () => { 23 | describe('POST login', () => { 24 | it('should log in a user in', async () => { 25 | const password = '123password!'; 26 | const user = await User.create({ 27 | email: 'foo@bar.com', 28 | password, 29 | name: 'test' 30 | }); 31 | 32 | const response = await request('POST', '/1/auth/login', { email: user.email, password }); 33 | expect(response.status).toBe(200); 34 | 35 | const { payload } = jwt.decode(response.body.data.token, { complete: true }); 36 | expect(payload).toHaveProperty('kid', 'user'); 37 | expect(payload).toHaveProperty('type', 'user'); 38 | }); 39 | }); 40 | 41 | describe('POST /apply', () => { 42 | it('should send a welcome email', async () => { 43 | const email = 'some@email.com'; 44 | const response = await request('POST', '/1/auth/apply', { email }); 45 | expect(response.status).toBe(204); 46 | expect(emails.sendWelcome).toHaveBeenCalledTimes(1); 47 | expect(emails.sendWelcome).toBeCalledWith( 48 | expect.objectContaining({ 49 | to: email, 50 | token: expect.any(String) 51 | }) 52 | ); 53 | }); 54 | 55 | it('should handle if user already signup', async () => { 56 | const user = await User.create({ 57 | email: 'someemail@bar.com', 58 | password: 'password1', 59 | name: 'test' 60 | }); 61 | const response = await request('POST', '/1/auth/apply', { email: user.email }); 62 | expect(response.status).toBe(204); 63 | expect(emails.sendWelcomeKnown).toHaveBeenCalledTimes(1); 64 | expect(emails.sendWelcomeKnown).toBeCalledWith( 65 | expect.objectContaining({ 66 | to: user.email, 67 | name: user.name 68 | }) 69 | ); 70 | }); 71 | }); 72 | 73 | describe('POST /register', () => { 74 | it('should create the user', async () => { 75 | const email = 'nao@bau.com'; 76 | const token = tokens.createUserTemporaryToken({ email }, 'user:register'); 77 | 78 | const response = await request('POST', '/1/auth/register', { 79 | token, 80 | name: 'somename', 81 | password: 'new-p$assword-12' 82 | }); 83 | expect(response.status).toBe(200); 84 | 85 | const { payload } = jwt.decode(response.body.data.token, { complete: true }); 86 | expect(payload).toHaveProperty('kid', 'user'); 87 | expect(payload).toHaveProperty('type', 'user'); 88 | 89 | const updatedUser = await User.findOne({ 90 | email 91 | }); 92 | 93 | expect(updatedUser.name).toBe('somename'); 94 | }); 95 | 96 | it('should fail if the user already exists', async () => { 97 | const user = await User.create({ 98 | email: 'boboa@bar.com', 99 | password: 'password1', 100 | name: 'test' 101 | }); 102 | const token = tokens.createUserTemporaryToken({ email: user.email }, 'user:register'); 103 | const response = await request('POST', '/1/auth/register', { 104 | token, 105 | name: 'somename', 106 | password: 'new-p$assword-12' 107 | }); 108 | expect(response.status).toBe(409); 109 | }); 110 | }); 111 | 112 | describe('POST /request-password', async () => { 113 | it('it should send an email to the registered user', async () => { 114 | const user = await User.create({ 115 | email: 'foob1@bar.com', 116 | password: 'password1', 117 | name: 'test' 118 | }); 119 | const response = await request('POST', '/1/auth/request-password', { 120 | email: user.email 121 | }); 122 | expect(response.status).toBe(204); 123 | expect(emails.sendResetPassword).toHaveBeenCalledTimes(1); 124 | expect(emails.sendResetPassword).toBeCalledWith( 125 | expect.objectContaining({ 126 | to: 'foob1@bar.com', 127 | token: expect.any(String) 128 | }) 129 | ); 130 | }); 131 | 132 | it('it should send an email to the unknown user', async () => { 133 | const email = 'email@email.com'; 134 | const response = await request('POST', '/1/auth/request-password', { 135 | email 136 | }); 137 | expect(response.status).toBe(204); 138 | expect(emails.sendResetPasswordUnknown).toHaveBeenCalledTimes(1); 139 | expect(emails.sendResetPasswordUnknown).toBeCalledWith( 140 | expect.objectContaining({ 141 | to: email 142 | }) 143 | ); 144 | }); 145 | }); 146 | 147 | describe('POST /set-password', async () => { 148 | it('it should allow a user to set a password', async () => { 149 | const user = await User.create({ 150 | email: 'something@bo.com', 151 | name: 'something', 152 | password: 'oldpassword' 153 | }); 154 | const password = 'very new password'; 155 | const response = await request('POST', '/1/auth/set-password', { 156 | password, 157 | token: tokens.createUserTemporaryToken({ userId: user._id }, 'user:password') 158 | }); 159 | expect(response.status).toBe(200); 160 | 161 | const { payload } = jwt.decode(response.body.data.token, { complete: true }); 162 | expect(payload).toHaveProperty('kid', 'user'); 163 | expect(payload).toHaveProperty('type', 'user'); 164 | 165 | const updatedUser = await User.findById(user._id); 166 | expect(await updatedUser.verifyPassword(password)).toBe(true); 167 | }); 168 | 169 | it('should handle invalid tokens', async () => { 170 | const password = 'very new password'; 171 | const response = await request('POST', '/1/auth/set-password', { 172 | password, 173 | token: 'some bad token not really a good token' 174 | }); 175 | expect(response.status).toBe(400); 176 | expect(response.body.error.message).toBe('bad jwt token'); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/v1/__tests__/users.js: -------------------------------------------------------------------------------- 1 | const User = require('../../models/User'); 2 | const { initialize: initializeEmails } = require('../../lib/emails'); 3 | const { setupDb, teardownDb, request } = require('../../test-helpers'); 4 | 5 | jest.mock('../../lib/emails'); 6 | 7 | beforeAll(async () => { 8 | await initializeEmails(); 9 | await setupDb(); 10 | }); 11 | 12 | afterAll(async () => { 13 | await teardownDb(); 14 | }); 15 | 16 | describe('/1/users', () => { 17 | describe('GET /me', () => { 18 | it('it should return the logged in user', async () => { 19 | const user = await User.create({ 20 | email: 'foo@bar.com', 21 | name: 'test', 22 | password: 'some1p%assword' 23 | }); 24 | 25 | const response = await request('GET', '/1/users/me', {}, { user }); 26 | expect(response.status).toBe(200); 27 | expect(response.body.data.email).toBe(user.email); 28 | }); 29 | }); 30 | 31 | describe('PATCH /me', () => { 32 | it('it should allow updating the user', async () => { 33 | const user = await User.create({ 34 | email: 'foo1@badr.com', 35 | name: 'test', 36 | password: 'some1p%assword' 37 | }); 38 | 39 | const response = await request('PATCH', '/1/users/me', { name: 'other name' }, { user }); 40 | expect(response.status).toBe(200); 41 | expect(response.body.data.email).toBe(user.email); 42 | const updatedUser = await User.findById(user._id); 43 | expect(updatedUser.name).toBe('other name'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/v1/auth.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const Joi = require('joi'); 3 | const { omit } = require('lodash'); 4 | const validate = require('../middlewares/validate'); 5 | const authenticate = require('../middlewares/authenticate'); 6 | const tokens = require('../lib/tokens'); 7 | const { sendWelcome, sendResetPassword, sendResetPasswordUnknown, sendWelcomeKnown } = require('../lib/emails'); 8 | const User = require('../models/user'); 9 | 10 | const router = new Router(); 11 | 12 | router 13 | .post( 14 | '/apply', 15 | validate({ 16 | body: { 17 | email: Joi.string() 18 | .email() 19 | .required(), 20 | addToMailingList: Joi.boolean().default(false) 21 | } 22 | }), 23 | async (ctx) => { 24 | const { email } = ctx.request.body; 25 | const user = await User.findOne({ email: email }); 26 | if (user) { 27 | await sendWelcomeKnown({ 28 | to: email, 29 | name: user.name 30 | }); 31 | } else { 32 | await sendWelcome({ 33 | to: email, 34 | token: tokens.createUserTemporaryToken({ email }, 'user:register') 35 | }); 36 | } 37 | // todo add email to mailling list 38 | ctx.status = 204; 39 | } 40 | ) 41 | .post( 42 | '/register', 43 | validate({ 44 | body: { 45 | name: Joi.string().required(), 46 | password: Joi.string().required(), 47 | token: Joi.string().required(), 48 | termsAccepted: Joi.boolean().valid(true) 49 | } 50 | }), 51 | authenticate({ type: 'user:register' }, { getToken: (ctx) => ctx.request.body.token }), 52 | async (ctx) => { 53 | const { jwt } = ctx.state; 54 | if (!jwt || !jwt.email) { 55 | ctx.throw(500, 'jwt token doesnt contain email'); 56 | } 57 | 58 | const existingUser = await User.findOne({ 59 | email: jwt.email 60 | }); 61 | 62 | if (existingUser) { 63 | ctx.throw(409, 'user already exists'); 64 | } 65 | 66 | const user = await User.create({ 67 | email: jwt.email, 68 | ...omit(ctx.request.body, ['token']) 69 | }); 70 | ctx.body = { data: { token: tokens.createUserToken(user) } }; 71 | } 72 | ) 73 | .post( 74 | '/login', 75 | validate({ 76 | body: { 77 | email: Joi.string() 78 | .email() 79 | .required(), 80 | password: Joi.string().required() 81 | } 82 | }), 83 | async (ctx) => { 84 | const { email, password } = ctx.request.body; 85 | const user = await User.findOne({ email }); 86 | if (!user) { 87 | ctx.throw(401, 'email password combination does not match'); 88 | } 89 | const isSame = await user.verifyPassword(password); 90 | if (!isSame) { 91 | ctx.throw(401, 'email password combination does not match'); 92 | } 93 | ctx.body = { data: { token: tokens.createUserToken(user) } }; 94 | } 95 | ) 96 | .post( 97 | '/request-password', 98 | validate({ 99 | body: { 100 | email: Joi.string() 101 | .email() 102 | .required() 103 | } 104 | }), 105 | async (ctx) => { 106 | const { email } = ctx.request.body; 107 | const user = await User.findOne({ email }); 108 | if (user) { 109 | await sendResetPassword({ 110 | to: email, 111 | token: tokens.createUserTemporaryToken({ userId: user._id }, 'user:password') 112 | }); 113 | } else { 114 | await sendResetPasswordUnknown({ 115 | to: email 116 | }); 117 | } 118 | ctx.status = 204; 119 | } 120 | ) 121 | .post( 122 | '/set-password', 123 | validate({ 124 | body: { 125 | token: Joi.string().required(), 126 | password: Joi.string().required() 127 | } 128 | }), 129 | authenticate({ type: 'user:password' }, { getToken: (ctx) => ctx.request.body.token }), 130 | async (ctx) => { 131 | const { password } = ctx.request.body; 132 | const user = await User.findById(ctx.state.jwt.userId); 133 | if (!user) { 134 | ctx.throw(500, 'user does not exists'); 135 | } 136 | user.password = password; 137 | await user.save(); 138 | ctx.body = { data: { token: tokens.createUserToken(user) } }; 139 | } 140 | ); 141 | 142 | module.exports = router; 143 | -------------------------------------------------------------------------------- /src/v1/index.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const auth = require('./auth'); 3 | const users = require('./users'); 4 | 5 | const router = new Router(); 6 | 7 | router.use('/auth', auth.routes()); 8 | router.use('/users', users.routes()); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /src/v1/users.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const Joi = require('joi'); 3 | const validate = require('../middlewares/validate'); 4 | const authenticate = require('../middlewares/authenticate'); 5 | const User = require('../models/user'); 6 | 7 | const router = new Router(); 8 | 9 | const fetchUser = async (ctx, next) => { 10 | ctx.state.user = await User.findById(ctx.state.jwt.userId); 11 | if (!ctx.state.user) ctx.throw(500, 'user associsated to token could not not be found'); 12 | await next(); 13 | }; 14 | 15 | router 16 | .use(authenticate({ type: 'user' })) 17 | .use(fetchUser) 18 | .get('/me', (ctx) => { 19 | ctx.body = { data: ctx.state.user.toResource() }; 20 | }) 21 | .patch( 22 | '/me', 23 | validate({ 24 | body: { 25 | name: Joi.string().required() 26 | } 27 | }), 28 | async (ctx) => { 29 | const { user } = ctx.state; 30 | Object.assign(user, ctx.request.body); 31 | await user.save(); 32 | ctx.body = { data: user.toResource() }; 33 | } 34 | ); 35 | 36 | module.exports = router; 37 | --------------------------------------------------------------------------------