├── .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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
31 |
38 | |
39 |
40 |
41 |
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 | |
45 |
46 |
47 | |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
69 |
70 |
71 |
72 |
73 | |
74 | |
75 |
76 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | |
32 |
33 |
34 | |
35 |
36 |
37 |
38 |
39 |
40 |
57 |
58 |
59 |
60 | |
61 | |
62 |
63 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
37 | |
38 |
39 |
40 |
41 | Best,
42 | The ${appName} Team`
43 | |
44 |
45 |
46 | |
47 |
48 |
49 |
50 |
51 |
52 |
69 |
70 |
71 |
72 | |
73 | |
74 |
75 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | |
32 |
33 |
34 | |
35 |
36 |
37 |
38 |
39 |
40 |
57 |
58 |
59 |
60 | |
61 | |
62 |
63 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
37 | |
38 |
39 |
40 |
41 | Best,
42 | The ${appName} Team`
43 | |
44 |
45 |
46 | |
47 |
48 |
49 |
50 |
51 |
52 |
69 |
70 |
71 |
72 | |
73 | |
74 |
75 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | |
32 |
33 |
34 | |
35 |
36 |
37 |
38 |
39 |
40 |
57 |
58 |
59 |
60 | |
61 | |
62 |
63 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
36 | |
37 |
38 |
39 |
40 | Best,
41 | The ${appName} Team`
42 | |
43 |
44 |
45 | |
46 |
47 |
48 |
49 |
50 |
51 |
68 |
69 |
70 |
71 | |
72 | |
73 |
74 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
31 |
38 | |
39 |
40 |
41 |
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 | |
45 |
46 |
47 | |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
69 |
70 |
71 |
72 |
73 | |
74 | |
75 |
76 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | |
32 |
33 |
34 | |
35 |
36 |
37 |
38 |
39 |
40 |
57 |
58 |
59 |
60 | |
61 | |
62 |
63 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
37 | |
38 |
39 |
40 |
41 | Best,
42 | The ${appName} Team`
43 | |
44 |
45 |
46 | |
47 |
48 |
49 |
50 |
51 |
52 |
69 |
70 |
71 |
72 | |
73 | |
74 |
75 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | |
32 |
33 |
34 | |
35 |
36 |
37 |
38 |
39 |
40 |
57 |
58 |
59 |
60 | |
61 | |
62 |
63 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
36 | |
37 |
38 |
39 |
40 | Best,
41 | The ${appName} Team`
42 | |
43 |
44 |
45 | |
46 |
47 |
48 |
49 |
50 |
51 |
68 |
69 |
70 |
71 | |
72 | |
73 |
74 |
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 |
--------------------------------------------------------------------------------