├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── deploy.sh ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.test.yml ├── docker-compose.yml ├── package.json ├── src ├── api │ ├── middlewares │ │ ├── auth.js │ │ ├── error.js │ │ └── rateLimiter.js │ ├── routes │ │ └── v1 │ │ │ └── index.js │ ├── services │ │ ├── auth │ │ │ ├── auth.controller.js │ │ │ ├── auth.route.js │ │ │ ├── auth.service.js │ │ │ ├── auth.test.js │ │ │ ├── auth.validation.js │ │ │ └── refreshToken.model.js │ │ └── user │ │ │ ├── user.controller.js │ │ │ ├── user.model.js │ │ │ ├── user.route.js │ │ │ ├── user.service.js │ │ │ ├── user.test.js │ │ │ └── user.validation.js │ └── utils │ │ ├── APIError.js │ │ └── authProviders.js ├── config │ ├── express.js │ ├── mongoose.js │ ├── passport.js │ └── vars.js └── index.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # node-waf configuration 17 | .lock-wscript 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 24 | node_modules 25 | 26 | kubernetes 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | # trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3000 3 | JWT_SECRET=bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4 4 | JWT_EXPIRATION_MINUTES=15 5 | MONGO_URI=mongodb://mongodb:27017/express-rest-es2017-boilerplate 6 | MONGO_URI_TESTS=mongodb://mongodb:27017/express-rest-es2017-boilerplate 7 | RATE_LIMIT_TIME=15 8 | RATE_LIMIT_REQUEST=15 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": 0, 4 | "no-underscore-dangle": 0, 5 | "no-unused-vars": ["error", { "argsIgnorePattern": "next" }], 6 | "no-use-before-define": ["error", { "variables": false }], 7 | "no-multi-str": 0 8 | }, 9 | "env": { 10 | "node": true, 11 | "mocha": true 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 8 15 | }, 16 | "extends": [ 17 | "airbnb-base" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Convert text file line endings to lf 2 | * text=auto 3 | *.js text 4 | # Denote all files that are truly binary and should not be modified. 5 | *.mp4 binary 6 | *.jpg binary 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Use yarn.lock instead 2 | package-lock.json 3 | 4 | # Environment variables 5 | .env 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | 12 | # Documentation 13 | docs 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules 40 | jspm_packages 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional REPL history 46 | .node_repl_history 47 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '8' 3 | 4 | git: 5 | depth: 3 6 | 7 | branches: 8 | only: 9 | - master 10 | - /^greenkeeper/.*$/ 11 | 12 | services: 13 | - mongodb 14 | 15 | env: 16 | global: 17 | - NODE_ENV=test 18 | - PORT=3000 19 | - JWT_SECRET=bA2xcjpf8y5aSUFsNB2qN5yymUBSs6es3qHoFpGkec75RCeBb8cpKauGefw5qy4 20 | - JWT_EXPIRATION_MINUTES=15 21 | - MONGO_URI=mongodb://travis:test@localhost:27017/express-es8-rest-boilerplate 22 | - MONGO_URI_TESTS=mongodb://travis:test@localhost:27017/express-es8-rest-boilerplate 23 | - RATE_LIMIT_TIME=1 24 | - RATE_LIMIT_REQUEST=2000 25 | 26 | script: yarn validate 27 | 28 | before_install: yarn global add greenkeeper-lockfile@1 29 | before_script: 30 | - greenkeeper-lockfile-update 31 | - sleep 10 32 | - mongo express-es8-rest-boilerplate --eval 'db.createUser({user:"travis",pwd:"test",roles:["readWrite"]});' 33 | after_script: greenkeeper-lockfile-upload 34 | 35 | # deploy: 36 | # - provider: script 37 | # script: yarn deploy 38 | 39 | after_success: yarn coverage 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 1.2.1 / 2017-10-04 3 | ================== 4 | 5 | * Fix linter 6 | * Upgrade dependencies 7 | * Fix tests 8 | * Merge pull request #16 from gkatsanos/patch-1 9 | 10 | 1.2.0 / 2017-09-23 11 | ================== 12 | 13 | * Finish social authentication 14 | * WIP: Add Bearer auth strategy and create tests 15 | * WIP: Implementing social login 16 | * Merge pull request #7 from danielfsousa/greenkeeper/passport-jwt-3.0.0 17 | * fix(package): update passport-jwt to version 3.0.0 18 | 19 | 1.1.0 / 2017-08-12 20 | ================== 21 | 22 | * Fix jwtStrategy 23 | * Remove winston in favor of pm2 logs 24 | * Add docs route 25 | * Refactoring configuration and error handling 26 | * fix(package): update passport to version 0.4.0 27 | * chore(package): update sinon to version 3.0.0 28 | 29 | 1.0.2 / 2017-07-24 30 | ================== 31 | 32 | * Update README 33 | 34 | 1.0.1 / 2017-07-23 35 | ================== 36 | 37 | * Update docs 38 | 39 | 1.0.0 / 2017-07-23 40 | ================== 41 | 42 | * First release 43 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! 4 | 5 | - Before spending lots of time on something, ask for feedback on your idea first. 6 | - Please search issues and pull requests before adding something new to avoid duplicating efforts and conversations. 7 | - Fork the repository to your own account 8 | - Clone the repository 9 | - Make changes 10 | - Submit a pull request with tests on develop branch 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | 3 | EXPOSE 3000 4 | 5 | ARG NODE_ENV 6 | ENV NODE_ENV $NODE_ENV 7 | 8 | RUN mkdir /app 9 | WORKDIR /app 10 | ADD package.json yarn.lock /app/ 11 | RUN yarn --pure-lockfile 12 | ADD . /app 13 | 14 | CMD ["yarn", "docker:start"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daniel Sousa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js - Express, MongoDB, ES2017 REST API Boilerplate 2 | 3 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) [![Build Status](https://travis-ci.org/ridhamtarpara/express-es8-rest-boilerplate.svg?branch=master)](https://travis-ci.org/ridhamtarpara/express-es8-rest-boilerplate) [![Coverage Status](https://coveralls.io/repos/github/ridhamtarpara/express-es8-rest-boilerplate/badge.svg?branch=master)](https://coveralls.io/github/ridhamtarpara/express-es8-rest-boilerplate?branch=master) [![Greenkeeper badge](https://badges.greenkeeper.io/ridhamtarpara/express-es8-rest-boilerplate.svg)](https://greenkeeper.io/) 4 | 5 | 6 | ## Features 7 | - Uses [yarn](https://yarnpkg.com) 8 | - No transpilers, just vanilla javascript with ES2017 latest features like Async/Await 9 | - Express + MongoDB ([Mongoose](http://mongoosejs.com/)) 10 | - CORS enabled and uses [helmet](https://github.com/helmetjs/helmet) to set some HTTP headers for security 11 | - Load environment variables from .env files with [dotenv](https://github.com/rolodato/dotenv-safe) 12 | - Request validation with [joi](https://github.com/hapijs/joi) 13 | - Consistent coding styles with [editorconfig](http://editorconfig.org) 14 | - Gzip compression with [compression](https://github.com/expressjs/compression) 15 | - Linting with [eslint](http://eslint.org) 16 | - Tests with [mocha](https://mochajs.org), [chai](http://chaijs.com) and [sinon](http://sinonjs.org) 17 | - Code coverage with [istanbul](https://istanbul.js.org) and [coveralls](https://coveralls.io) 18 | - Git hooks with [husky](https://github.com/typicode/husky) 19 | - Logging with [morgan](https://github.com/expressjs/morgan) 20 | - Authentication and Authorization with [passport](http://passportjs.org) 21 | - Rate limiting with [express-rate-limit](https://www.npmjs.com/package/express-rate-limit) 22 | - API documentation generation with [apidoc](http://apidocjs.com) 23 | - [Docker](https://www.docker.com/) support 24 | - Continuous integration support with [travisCI](https://travis-ci.org) 25 | - Monitoring with [pm2](https://github.com/Unitech/pm2) 26 | 27 | > Take a demo at http://13.58.200.57:3000/docs/ 28 | 29 | ## Prerequisites 30 | - [Node v7.6+](https://nodejs.org/en/download/current/) or [Docker](https://www.docker.com/) 31 | - [Yarn](https://yarnpkg.com/en/docs/install) 32 | 33 | ## Getting Started 34 | 35 | 1. Clone the repo and make it yours: 36 | 37 | ```bash 38 | git clone https://github.com/ridhamtarpara/express-es8-rest-boilerplate node-api 39 | cd node-api 40 | rm -rf .git 41 | ``` 42 | 43 | 2. Install dependencies: 44 | 45 | ```bash 46 | yarn 47 | ``` 48 | 49 | 3. Set environment variables: 50 | 51 | ```bash 52 | cp .env.example .env 53 | ``` 54 | 55 | ## Running Locally 56 | 57 | ```bash 58 | yarn dev 59 | ``` 60 | 61 | ## Running in Production 62 | 63 | ```bash 64 | yarn start 65 | ``` 66 | 67 | ## Lint 68 | 69 | ```bash 70 | # lint code with ESLint 71 | yarn lint 72 | 73 | # try to fix ESLint errors 74 | yarn lint:fix 75 | 76 | # lint and watch for changes 77 | yarn lint:watch 78 | ``` 79 | 80 | ## Test 81 | 82 | ```bash 83 | # run all tests with Mocha 84 | yarn test 85 | 86 | # run unit tests 87 | yarn test:unit 88 | 89 | # run integration tests 90 | yarn test:integration 91 | 92 | # run all tests and watch for changes 93 | yarn test:watch 94 | 95 | # open nyc test coverage reports 96 | yarn coverage 97 | ``` 98 | 99 | ## Validate 100 | 101 | ```bash 102 | # run lint and tests 103 | yarn validate 104 | ``` 105 | 106 | ## Logs 107 | 108 | ```bash 109 | # show logs in production 110 | pm2 logs 111 | ``` 112 | 113 | ## Documentation 114 | 115 | ```bash 116 | # generate and open api documentation 117 | yarn docs 118 | ``` 119 | 120 | ## Docker 121 | 122 | ```bash 123 | # run container locally 124 | yarn docker:dev 125 | or 126 | docker-compose -f docker-compose.yml -f docker-compose.dev.yml up 127 | 128 | # run container in production 129 | yarn docker:prod 130 | or 131 | docker-compose -f docker-compose.yml -f docker-compose.prod.yml up 132 | 133 | # run tests 134 | yarn docker:test 135 | or 136 | docker-compose -f docker-compose.yml -f docker-compose.test.yml up 137 | ``` 138 | 139 | ## Deploy 140 | 141 | Set your server ip: 142 | 143 | ```bash 144 | DEPLOY_SERVER=127.0.0.1 145 | ``` 146 | 147 | Replace my Docker username with yours: 148 | 149 | ```bash 150 | nano deploy.sh 151 | ``` 152 | 153 | Run deploy script: 154 | 155 | ```bash 156 | yarn deploy 157 | or 158 | sh ./deploy.sh 159 | ``` 160 | 161 | ## Rate Limit Configuration 162 | Change configuration in `.env` file 163 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker build -t ridhamtarpara/express-rest-es2017-boilerplate . 3 | docker push ridhamtarpara/express-rest-es2017-boilerplate 4 | 5 | ssh deploy@$DEPLOY_SERVER << EOF 6 | docker pull ridhamtarpara/express-rest-es2017-boilerplate 7 | docker stop api-boilerplate || true 8 | docker rm api-boilerplate || true 9 | docker rmi ridhamtarpara/express-rest-es2017-boilerplate:current || true 10 | docker tag ridhamtarpara/express-rest-es2017-boilerplate:latest ridham/express-rest-es2017-boilerplate:current 11 | docker run -d --restart always --name api-boilerplate -p 3000:3000 ridham/express-rest-es2017-boilerplate:current 12 | EOF 13 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | boilerplate-api: 4 | command: yarn dev -- -L 5 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | boilerplate-api: 4 | command: yarn start 5 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | boilerplate-api: 4 | command: yarn test 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | boilerplate-api: 4 | build: . 5 | environment: 6 | - MONGO_URI=mongodb://mongodb:27017/express-rest-es2017-boilerplate 7 | volumes: 8 | - .:/app 9 | ports: 10 | - "3000:3000" 11 | depends_on: 12 | - mongodb 13 | 14 | mongodb: 15 | image: mongo 16 | ports: 17 | - "27017:27017" 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-es8-rest-boilerplate", 3 | "version": "0.0.1", 4 | "description": "Express boilerplate for building RESTful APIs using mongodb", 5 | "author": "Ridham Tarpara ", 6 | "main": "src/index.js", 7 | "private": false, 8 | "license": "MIT", 9 | "engines": { 10 | "node": ">=8", 11 | "yarn": "*" 12 | }, 13 | "scripts": { 14 | "precommit": "yarn lint", 15 | "prestart": "yarn docs", 16 | "start": "cross-env NODE_ENV=production pm2 start ./src/index.js", 17 | "dev": "nodemon ./src/index.js", 18 | "lint": "eslint **/*.js --ignore-path .gitignore --ignore-pattern internals/scripts", 19 | "lint:fix": "yarn lint -- --fix", 20 | "lint:watch": "yarn lint -- --watch", 21 | "test": "cross-env NODE_ENV=test nyc --reporter=html --reporter=text -x '**/authProviders.js' -x '**/mongoose.js' mocha --timeout 20000 --recursive $(find src -name '*test.js') --exit", 22 | "test:unit": "cross-env NODE_ENV=test mocha src/api/tests/unit", 23 | "test:integration": "cross-env NODE_ENV=test mocha --timeout 20000 src/api/tests/integration", 24 | "test:watch": "cross-env NODE_ENV=test mocha --watch src/api/tests/unit", 25 | "coverage": "nyc report --reporter=text-lcov | coveralls", 26 | "postcoverage": "opn coverage/lcov-report/index.html", 27 | "validate": "yarn lint && yarn test", 28 | "postpublish": "git push --tags", 29 | "deploy": "sh ./deploy.sh", 30 | "docs": "apidoc -i src -o docs", 31 | "postdocs": "opn docs/index.html", 32 | "docker:start": "cross-env NODE_ENV=production pm2-docker start ./src/index.js", 33 | "docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up", 34 | "docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up", 35 | "docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://gitlab.com/ridhamtarpara/twithero-api.git" 40 | }, 41 | "keywords": [ 42 | "express", 43 | "node", 44 | "node.js", 45 | "mongodb", 46 | "mongoose", 47 | "passport", 48 | "es6", 49 | "es7", 50 | "es8", 51 | "es2017", 52 | "mocha", 53 | "istanbul", 54 | "nyc", 55 | "eslint", 56 | "Travis CI", 57 | "coveralls", 58 | "REST", 59 | "API", 60 | "boilerplate", 61 | "generator", 62 | "starter project" 63 | ], 64 | "dependencies": { 65 | "axios": "^0.18.0", 66 | "bcryptjs": "2.4.3", 67 | "bluebird": "^3.5.0", 68 | "body-parser": "^1.17.0", 69 | "compression": "^1.6.2", 70 | "cors": "^2.8.3", 71 | "cross-env": "^5.0.1", 72 | "dotenv-safe": "^6.0.0", 73 | "express": "^4.15.2", 74 | "express-rate-limit": "^3.0.0", 75 | "express-validation": "^1.0.2", 76 | "helmet": "^3.5.0", 77 | "http-status": "^1.0.1", 78 | "joi": "^14.0.0", 79 | "jwt-simple": "0.5.1", 80 | "lodash": "^4.17.4", 81 | "method-override": "^3.0.0", 82 | "moment-timezone": "^0.5.13", 83 | "mongoose": "^5.0.11", 84 | "morgan": "^1.8.1", 85 | "passport": "^0.4.0", 86 | "passport-http-bearer": "^1.0.1", 87 | "passport-jwt": "4.0.0", 88 | "uuid": "^3.1.0" 89 | }, 90 | "devDependencies": { 91 | "apidoc": "^0.17.5", 92 | "chai": "^4.1.0", 93 | "chai-as-promised": "^7.1.1", 94 | "coveralls": "^3.0.0", 95 | "eslint": "^4.2.0", 96 | "eslint-config-airbnb-base": "^12.0.1", 97 | "eslint-plugin-import": "^2.2.0", 98 | "husky": "^1.0.0", 99 | "mocha": "^5.0.4", 100 | "nodemon": "^1.11.0", 101 | "nyc": "^12.0.1", 102 | "opn-cli": "^3.1.0", 103 | "sinon": "^6.0.0", 104 | "sinon-chai": "^3.0.0", 105 | "supertest": "^3.0.0" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/api/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const passport = require('passport'); 3 | const User = require('../services/user/user.model'); 4 | const APIError = require('../utils/APIError'); 5 | 6 | const ADMIN = 'admin'; 7 | const LOGGED_USER = '_loggedUser'; 8 | 9 | const handleJWT = (req, res, next, roles) => async (err, user, info) => { 10 | const error = err || info; 11 | const logIn = Promise.promisify(req.logIn); 12 | const apiError = new APIError({ 13 | message: error ? error.message : 'Unauthorized', 14 | status: httpStatus.UNAUTHORIZED, 15 | stack: error ? error.stack : undefined, 16 | }); 17 | 18 | try { 19 | if (error || !user) throw error; 20 | await logIn(user, { session: false }); 21 | } catch (e) { 22 | return next(apiError); 23 | } 24 | 25 | if (roles === LOGGED_USER) { 26 | if (user.role !== 'admin' && req.params.userId !== user._id.toString()) { 27 | apiError.status = httpStatus.FORBIDDEN; 28 | apiError.message = 'Forbidden'; 29 | return next(apiError); 30 | } 31 | } else if (!roles.includes(user.role)) { 32 | apiError.status = httpStatus.FORBIDDEN; 33 | apiError.message = 'Forbidden'; 34 | return next(apiError); 35 | } else if (err || !user) { 36 | return next(apiError); 37 | } 38 | 39 | req.user = user; 40 | 41 | return next(); 42 | }; 43 | 44 | exports.ADMIN = ADMIN; 45 | exports.LOGGED_USER = LOGGED_USER; 46 | 47 | exports.authorize = (roles = User.roles) => (req, res, next) => 48 | passport.authenticate( 49 | 'jwt', { session: false }, 50 | handleJWT(req, res, next, roles), 51 | )(req, res, next); 52 | 53 | exports.oAuth = service => 54 | passport.authenticate(service, { session: false }); 55 | -------------------------------------------------------------------------------- /src/api/middlewares/error.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const expressValidation = require('express-validation'); 3 | const APIError = require('../utils/APIError'); 4 | const { env } = require('../../config/vars'); 5 | 6 | /** 7 | * Error handler. Send stacktrace only during development 8 | * @public 9 | */ 10 | const handler = (err, req, res, next) => { 11 | const response = { 12 | code: err.status, 13 | message: err.message || httpStatus[err.status], 14 | errors: err.errors, 15 | stack: err.stack, 16 | }; 17 | if (env !== 'development') { 18 | delete response.stack; 19 | } 20 | res.status(err.status); 21 | res.json(response); 22 | res.end(); 23 | }; 24 | exports.handler = handler; 25 | 26 | /** 27 | * If error is not an instanceOf APIError, convert it. 28 | * @public 29 | */ 30 | exports.converter = (err, req, res, next) => { 31 | let convertedError = err; 32 | 33 | if (err instanceof expressValidation.ValidationError) { 34 | const errors = err.errors.map(e => ({ 35 | location: e.location, 36 | messages: e.messages, 37 | field: e.field[0], 38 | })); 39 | convertedError = new APIError({ 40 | message: 'Validation Error', 41 | errors, 42 | status: err.status, 43 | stack: err.stack, 44 | }); 45 | } else if (!(err instanceof APIError)) { 46 | convertedError = new APIError({ 47 | message: err.message, 48 | status: err.status, 49 | stack: err.stack, 50 | }); 51 | } 52 | return handler(convertedError, req, res); 53 | }; 54 | 55 | /** 56 | * Catch 404 and forward to error handler 57 | * @public 58 | */ 59 | exports.notFound = (req, res, next) => { 60 | const err = new APIError({ 61 | message: 'Not found', 62 | status: httpStatus.NOT_FOUND, 63 | }); 64 | return handler(err, req, res); 65 | }; 66 | 67 | /** 68 | * Catch 429 ratelimit exceeded 69 | * @public 70 | */ 71 | exports.rateLimitHandler = (req, res, next) => { 72 | const err = new APIError({ 73 | message: 'Rate limt exceeded, please try again later some time.', 74 | status: httpStatus.TOO_MANY_REQUESTS, 75 | }); 76 | return handler(err, req, res); 77 | }; 78 | -------------------------------------------------------------------------------- /src/api/middlewares/rateLimiter.js: -------------------------------------------------------------------------------- 1 | const RateLimit = require('express-rate-limit'); 2 | const error = require('../middlewares/error'); 3 | const { env, rateLimitTime, rateLimitRequest } = require('../../config/vars'); 4 | 5 | module.exports = () => { 6 | if (env === 'production') { 7 | return new RateLimit({ 8 | windowMs: rateLimitTime * 60 * 1000, // 15 minutes 9 | max: rateLimitRequest, // limit each IP to 30 requests per windowMs 10 | delayMs: 0, 11 | handler: error.rateLimitHandler, 12 | }); 13 | } 14 | return new RateLimit({ 15 | windowMs: 5 * 60 * 1000, // 5 minutes 16 | max: 3000, // limit each IP to 3000 requests per windowMs 17 | delayMs: 0, 18 | handler: error.rateLimitHandler, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/api/routes/v1/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const userRoutes = require('../../services/user/user.route'); 3 | const authRoutes = require('../../services/auth/auth.route'); 4 | 5 | const router = express.Router(); 6 | 7 | /** 8 | * GET v1/status 9 | */ 10 | router.get('/status', (req, res) => res.send('OK')); 11 | 12 | /** 13 | * GET v1/docs 14 | */ 15 | router.use('/docs', express.static('docs')); 16 | 17 | router.use('/users', userRoutes); 18 | router.use('/auth', authRoutes); 19 | 20 | module.exports = router; 21 | -------------------------------------------------------------------------------- /src/api/services/auth/auth.controller.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const service = require('./auth.service'); 3 | 4 | /** 5 | * Returns jwt token if registration was successful 6 | * @public 7 | */ 8 | exports.register = async (req, res, next) => { 9 | try { 10 | const response = await service.register(req.body); 11 | return res.status(httpStatus.CREATED).json(response); 12 | } catch (error) { 13 | return next(error); 14 | } 15 | }; 16 | 17 | /** 18 | * Returns jwt token if valid username and password is provided 19 | * @public 20 | */ 21 | exports.login = async (req, res, next) => { 22 | try { 23 | const response = await service.login(req.body); 24 | return res.json(response); 25 | } catch (error) { 26 | return next(error); 27 | } 28 | }; 29 | 30 | /** 31 | * login with an existing user or creates a new one if valid accessToken token 32 | * Returns jwt token 33 | * @public 34 | */ 35 | exports.oAuth = async (req, res, next) => { 36 | try { 37 | const { user } = req; 38 | const response = await service.oAuth(user); 39 | return res.json(response); 40 | } catch (error) { 41 | return next(error); 42 | } 43 | }; 44 | 45 | /** 46 | * Returns a new jwt when given a valid refresh token 47 | * @public 48 | */ 49 | exports.refresh = async (req, res, next) => { 50 | try { 51 | const response = await service.refresh(req.body); 52 | return res.json(response); 53 | } catch (error) { 54 | return next(error); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/api/services/auth/auth.route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const validate = require('express-validation'); 3 | const controller = require('./auth.controller'); 4 | const oAuthLogin = require('../../middlewares/auth').oAuth; 5 | const { 6 | login, 7 | register, 8 | oAuth, 9 | refresh, 10 | } = require('./auth.validation'); 11 | 12 | const router = express.Router(); 13 | 14 | /** 15 | * @api {post} v1/auth/register Register 16 | * @apiDescription Register a new user 17 | * @apiVersion 1.0.0 18 | * @apiName Register 19 | * @apiGroup Auth 20 | * @apiPermission public 21 | * 22 | * @apiParam {String} email User's email 23 | * @apiParam {String{6..128}} password User's password 24 | * 25 | * @apiSuccess (Created 201) {String} token.tokenType Access Token's type 26 | * @apiSuccess (Created 201) {String} token.accessToken Authorization Token 27 | * @apiSuccess (Created 201) {String} token.refreshToken Token to get a new accessToken 28 | * after expiration time 29 | * @apiSuccess (Created 201) {Number} token.expiresIn Access Token's expiration time 30 | * in miliseconds 31 | * @apiSuccess (Created 201) {String} token.timezone The server's Timezone 32 | * 33 | * @apiSuccess (Created 201) {String} user.id User's id 34 | * @apiSuccess (Created 201) {String} user.name User's name 35 | * @apiSuccess (Created 201) {String} user.email User's email 36 | * @apiSuccess (Created 201) {String} user.role User's role 37 | * @apiSuccess (Created 201) {Date} user.createdAt Timestamp 38 | * 39 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 40 | */ 41 | router.route('/register') 42 | .post(validate(register), controller.register); 43 | 44 | 45 | /** 46 | * @api {post} v1/auth/login Login 47 | * @apiDescription Get an accessToken 48 | * @apiVersion 1.0.0 49 | * @apiName Login 50 | * @apiGroup Auth 51 | * @apiPermission public 52 | * 53 | * @apiParam {String} email User's email 54 | * @apiParam {String{..128}} password User's password 55 | * 56 | * @apiSuccess {String} token.tokenType Access Token's type 57 | * @apiSuccess {String} token.accessToken Authorization Token 58 | * @apiSuccess {String} token.refreshToken Token to get a new accessToken 59 | * after expiration time 60 | * @apiSuccess {Number} token.expiresIn Access Token's expiration time 61 | * in miliseconds 62 | * 63 | * @apiSuccess {String} user.id User's id 64 | * @apiSuccess {String} user.name User's name 65 | * @apiSuccess {String} user.email User's email 66 | * @apiSuccess {String} user.role User's role 67 | * @apiSuccess {Date} user.createdAt Timestamp 68 | * 69 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 70 | * @apiError (Unauthorized 401) Unauthorized Incorrect email or password 71 | */ 72 | router.route('/login') 73 | .post(validate(login), controller.login); 74 | 75 | 76 | /** 77 | * @api {post} v1/auth/refresh-token Refresh Token 78 | * @apiDescription Refresh expired accessToken 79 | * @apiVersion 1.0.0 80 | * @apiName RefreshToken 81 | * @apiGroup Auth 82 | * @apiPermission public 83 | * 84 | * @apiParam {String} email User's email 85 | * @apiParam {String} refreshToken Refresh token aquired when user logged in 86 | * 87 | * @apiSuccess {String} tokenType Access Token's type 88 | * @apiSuccess {String} accessToken Authorization Token 89 | * @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time 90 | * @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds 91 | * 92 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 93 | * @apiError (Unauthorized 401) Unauthorized Incorrect email or refreshToken 94 | */ 95 | router.route('/refresh-token') 96 | .post(validate(refresh), controller.refresh); 97 | 98 | 99 | /** 100 | * TODO: POST /v1/auth/reset-password 101 | */ 102 | 103 | 104 | /** 105 | * @api {post} v1/auth/facebook Facebook Login 106 | * @apiDescription Login with facebook. Creates a new user if it does not exist 107 | * @apiVersion 1.0.0 108 | * @apiName FacebookLogin 109 | * @apiGroup Auth 110 | * @apiPermission public 111 | * 112 | * @apiParam {String} access_token Facebook's access_token 113 | * 114 | * @apiSuccess {String} tokenType Access Token's type 115 | * @apiSuccess {String} accessToken Authorization Token 116 | * @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time 117 | * @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds 118 | * 119 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 120 | * @apiError (Unauthorized 401) Unauthorized Incorrect access_token 121 | */ 122 | router.route('/facebook') 123 | .post(validate(oAuth), oAuthLogin('facebook'), controller.oAuth); 124 | 125 | /** 126 | * @api {post} v1/auth/google Google Login 127 | * @apiDescription Login with google. Creates a new user if it does not exist 128 | * @apiVersion 1.0.0 129 | * @apiName GoogleLogin 130 | * @apiGroup Auth 131 | * @apiPermission public 132 | * 133 | * @apiParam {String} access_token Google's access_token 134 | * 135 | * @apiSuccess {String} tokenType Access Token's type 136 | * @apiSuccess {String} accessToken Authorization Token 137 | * @apiSuccess {String} refreshToken Token to get a new accpessToken after expiration time 138 | * @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds 139 | * 140 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 141 | * @apiError (Unauthorized 401) Unauthorized Incorrect access_token 142 | */ 143 | router.route('/google') 144 | .post(validate(oAuth), oAuthLogin('google'), controller.oAuth); 145 | 146 | 147 | module.exports = router; 148 | -------------------------------------------------------------------------------- /src/api/services/auth/auth.service.js: -------------------------------------------------------------------------------- 1 | const User = require('../user/user.model'); 2 | const RefreshToken = require('./refreshToken.model'); 3 | const moment = require('moment-timezone'); 4 | const { jwtExpirationInterval } = require('../../../config/vars'); 5 | 6 | /** 7 | * Returns a formated object with tokens 8 | * @private 9 | */ 10 | function generateTokenResponse(user, accessToken) { 11 | const tokenType = 'Bearer'; 12 | const refreshToken = RefreshToken.generate(user).token; 13 | const expiresIn = moment().add(jwtExpirationInterval, 'minutes'); 14 | return { 15 | tokenType, accessToken, refreshToken, expiresIn, 16 | }; 17 | } 18 | 19 | 20 | /** 21 | * Returns jwt token if registration was successful 22 | * @public 23 | */ 24 | exports.register = async (userData) => { 25 | try { 26 | const user = await new User(userData).save(); 27 | const userTransformed = user.transform(); 28 | const token = generateTokenResponse(user, user.token()); 29 | return { token, user: userTransformed }; 30 | } catch (error) { 31 | throw User.checkDuplicateEmail(error); 32 | } 33 | }; 34 | 35 | /** 36 | * Returns jwt token if valid username and password is provided 37 | * @public 38 | */ 39 | exports.login = async (userData) => { 40 | try { 41 | const { user, accessToken } = await User.findAndGenerateToken(userData); 42 | const token = generateTokenResponse(user, accessToken); 43 | const userTransformed = user.transform(); 44 | return { token, user: userTransformed }; 45 | } catch (error) { 46 | throw error; 47 | } 48 | }; 49 | 50 | /** 51 | * login with an existing user or creates a new one if valid accessToken token 52 | * Returns jwt token 53 | * @public 54 | */ 55 | exports.oAuth = async (user) => { 56 | try { 57 | const accessToken = user.token(); 58 | const token = generateTokenResponse(user, accessToken); 59 | const userTransformed = user.transform(); 60 | return { token, user: userTransformed }; 61 | } catch (error) { 62 | throw error; 63 | } 64 | }; 65 | 66 | /** 67 | * Returns a new jwt when given a valid refresh token 68 | * @public 69 | */ 70 | exports.refresh = async ({ email, refreshToken }) => { 71 | try { 72 | const refreshObject = await RefreshToken.findOneAndRemove({ 73 | userEmail: email, 74 | token: refreshToken, 75 | }); 76 | const { user, accessToken } = await User.findAndGenerateToken({ email, refreshObject }); 77 | return generateTokenResponse(user, accessToken); 78 | } catch (error) { 79 | throw error; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /src/api/services/auth/auth.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | const request = require('supertest'); 3 | const httpStatus = require('http-status'); 4 | const { expect } = require('chai'); 5 | const sinon = require('sinon'); 6 | const app = require('../../../index'); 7 | const User = require('../user/user.model'); 8 | const RefreshToken = require('./refreshToken.model'); 9 | const authProviders = require('../../utils/authProviders'); 10 | 11 | const sandbox = sinon.createSandbox(); 12 | 13 | const fakeOAuthRequest = () => Promise.resolve({ 14 | service: 'facebook', 15 | id: '123', 16 | name: 'user', 17 | email: 'test@test.com', 18 | picture: 'test.jpg', 19 | }); 20 | 21 | describe('Authentication API', () => { 22 | let dbUser; 23 | let user; 24 | let refreshToken; 25 | 26 | beforeEach(async () => { 27 | dbUser = { 28 | email: 'branstark@gmail.com', 29 | password: 'mypassword', 30 | name: 'Bran Stark', 31 | role: 'admin', 32 | }; 33 | 34 | user = { 35 | email: 'sousa.dfs@gmail.com', 36 | password: '123456', 37 | name: 'Daniel Sousa', 38 | }; 39 | 40 | refreshToken = { 41 | token: '5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d', 42 | userId: '5947397b323ae82d8c3a333b', 43 | userEmail: dbUser.email, 44 | expires: new Date(), 45 | }; 46 | 47 | await User.remove({}); 48 | await User.create(dbUser); 49 | await RefreshToken.remove({}); 50 | }); 51 | 52 | afterEach(() => sandbox.restore()); 53 | 54 | describe('POST /v1/auth/register', () => { 55 | it('should register a new user when request is ok', () => { 56 | return request(app) 57 | .post('/v1/auth/register') 58 | .send(user) 59 | .expect(httpStatus.CREATED) 60 | .then((res) => { 61 | delete user.password; 62 | expect(res.body.token).to.have.a.property('accessToken'); 63 | expect(res.body.token).to.have.a.property('refreshToken'); 64 | expect(res.body.token).to.have.a.property('expiresIn'); 65 | expect(res.body.user).to.include(user); 66 | }); 67 | }); 68 | 69 | it('should report error when email already exists', () => { 70 | return request(app) 71 | .post('/v1/auth/register') 72 | .send(dbUser) 73 | .expect(httpStatus.CONFLICT) 74 | .then((res) => { 75 | const { field } = res.body.errors[0]; 76 | const { location } = res.body.errors[0]; 77 | const { messages } = res.body.errors[0]; 78 | expect(field).to.be.equal('email'); 79 | expect(location).to.be.equal('body'); 80 | expect(messages).to.include('"email" already exists'); 81 | }); 82 | }); 83 | 84 | it('should report error when the email provided is not valid', () => { 85 | user.email = 'this_is_not_an_email'; 86 | return request(app) 87 | .post('/v1/auth/register') 88 | .send(user) 89 | .expect(httpStatus.BAD_REQUEST) 90 | .then((res) => { 91 | const { field } = res.body.errors[0]; 92 | const { location } = res.body.errors[0]; 93 | const { messages } = res.body.errors[0]; 94 | expect(field).to.be.equal('email'); 95 | expect(location).to.be.equal('body'); 96 | expect(messages).to.include('"email" must be a valid email'); 97 | }); 98 | }); 99 | 100 | it('should report error when email and password are not provided', () => { 101 | return request(app) 102 | .post('/v1/auth/register') 103 | .send({}) 104 | .expect(httpStatus.BAD_REQUEST) 105 | .then((res) => { 106 | const { field } = res.body.errors[0]; 107 | const { location } = res.body.errors[0]; 108 | const { messages } = res.body.errors[0]; 109 | expect(field).to.be.equal('email'); 110 | expect(location).to.be.equal('body'); 111 | expect(messages).to.include('"email" is required'); 112 | }); 113 | }); 114 | }); 115 | 116 | describe('POST /v1/auth/login', () => { 117 | it('should return an accessToken and a refreshToken when email and password matches', () => { 118 | return request(app) 119 | .post('/v1/auth/login') 120 | .send(dbUser) 121 | .expect(httpStatus.OK) 122 | .then((res) => { 123 | delete dbUser.password; 124 | expect(res.body.token).to.have.a.property('accessToken'); 125 | expect(res.body.token).to.have.a.property('refreshToken'); 126 | expect(res.body.token).to.have.a.property('expiresIn'); 127 | expect(res.body.user).to.include(dbUser); 128 | }); 129 | }); 130 | 131 | it('should report error when email and password are not provided', () => { 132 | return request(app) 133 | .post('/v1/auth/login') 134 | .send({}) 135 | .expect(httpStatus.BAD_REQUEST) 136 | .then((res) => { 137 | const { field } = res.body.errors[0]; 138 | const { location } = res.body.errors[0]; 139 | const { messages } = res.body.errors[0]; 140 | expect(field).to.be.equal('email'); 141 | expect(location).to.be.equal('body'); 142 | expect(messages).to.include('"email" is required'); 143 | }); 144 | }); 145 | 146 | it('should report error when the email provided is not valid', () => { 147 | user.email = 'this_is_not_an_email'; 148 | return request(app) 149 | .post('/v1/auth/login') 150 | .send(user) 151 | .expect(httpStatus.BAD_REQUEST) 152 | .then((res) => { 153 | const { field } = res.body.errors[0]; 154 | const { location } = res.body.errors[0]; 155 | const { messages } = res.body.errors[0]; 156 | expect(field).to.be.equal('email'); 157 | expect(location).to.be.equal('body'); 158 | expect(messages).to.include('"email" must be a valid email'); 159 | }); 160 | }); 161 | 162 | it('should report error when email and password don\'t match', () => { 163 | dbUser.password = 'xxx'; 164 | return request(app) 165 | .post('/v1/auth/login') 166 | .send(dbUser) 167 | .expect(httpStatus.UNAUTHORIZED) 168 | .then((res) => { 169 | const { code } = res.body; 170 | const { message } = res.body; 171 | expect(code).to.be.equal(401); 172 | expect(message).to.be.equal('Incorrect email or password'); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('POST /v1/auth/facebook', () => { 178 | it('should create a new user and return an accessToken when user does not exist', () => { 179 | sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest); 180 | return request(app) 181 | .post('/v1/auth/facebook') 182 | .send({ access_token: '123' }) 183 | .expect(httpStatus.OK) 184 | .then((res) => { 185 | expect(res.body.token).to.have.a.property('accessToken'); 186 | expect(res.body.token).to.have.a.property('refreshToken'); 187 | expect(res.body.token).to.have.a.property('expiresIn'); 188 | expect(res.body.user).to.be.an('object'); 189 | }); 190 | }); 191 | 192 | it('should return an accessToken when user already exists', async () => { 193 | dbUser.email = 'test@test.com'; 194 | await User.create(dbUser); 195 | sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest); 196 | return request(app) 197 | .post('/v1/auth/facebook') 198 | .send({ access_token: '123' }) 199 | .expect(httpStatus.OK) 200 | .then((res) => { 201 | expect(res.body.token).to.have.a.property('accessToken'); 202 | expect(res.body.token).to.have.a.property('refreshToken'); 203 | expect(res.body.token).to.have.a.property('expiresIn'); 204 | expect(res.body.user).to.be.an('object'); 205 | }); 206 | }); 207 | 208 | it('should return error when access_token is not provided', async () => { 209 | return request(app) 210 | .post('/v1/auth/facebook') 211 | .expect(httpStatus.BAD_REQUEST) 212 | .then((res) => { 213 | const { field } = res.body.errors[0]; 214 | const { location } = res.body.errors[0]; 215 | const { messages } = res.body.errors[0]; 216 | expect(field).to.be.equal('access_token'); 217 | expect(location).to.be.equal('body'); 218 | expect(messages).to.include('"access_token" is required'); 219 | }); 220 | }); 221 | }); 222 | 223 | describe('POST /v1/auth/google', () => { 224 | it('should create a new user and return an accessToken when user does not exist', () => { 225 | sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest); 226 | return request(app) 227 | .post('/v1/auth/google') 228 | .send({ access_token: '123' }) 229 | .expect(httpStatus.OK) 230 | .then((res) => { 231 | expect(res.body.token).to.have.a.property('accessToken'); 232 | expect(res.body.token).to.have.a.property('refreshToken'); 233 | expect(res.body.token).to.have.a.property('expiresIn'); 234 | expect(res.body.user).to.be.an('object'); 235 | }); 236 | }); 237 | 238 | it('should return an accessToken when user already exists', async () => { 239 | dbUser.email = 'test@test.com'; 240 | await User.create(dbUser); 241 | sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest); 242 | return request(app) 243 | .post('/v1/auth/google') 244 | .send({ access_token: '123' }) 245 | .expect(httpStatus.OK) 246 | .then((res) => { 247 | expect(res.body.token).to.have.a.property('accessToken'); 248 | expect(res.body.token).to.have.a.property('refreshToken'); 249 | expect(res.body.token).to.have.a.property('expiresIn'); 250 | expect(res.body.user).to.be.an('object'); 251 | }); 252 | }); 253 | 254 | it('should return error when access_token is not provided', async () => { 255 | return request(app) 256 | .post('/v1/auth/google') 257 | .expect(httpStatus.BAD_REQUEST) 258 | .then((res) => { 259 | const { field } = res.body.errors[0]; 260 | const { location } = res.body.errors[0]; 261 | const { messages } = res.body.errors[0]; 262 | expect(field).to.be.equal('access_token'); 263 | expect(location).to.be.equal('body'); 264 | expect(messages).to.include('"access_token" is required'); 265 | }); 266 | }); 267 | }); 268 | 269 | describe('POST /v1/auth/refresh-token', () => { 270 | it('should return a new accessToken when refreshToken and email match', async () => { 271 | await RefreshToken.create(refreshToken); 272 | return request(app) 273 | .post('/v1/auth/refresh-token') 274 | .send({ email: dbUser.email, refreshToken: refreshToken.token }) 275 | .expect(httpStatus.OK) 276 | .then((res) => { 277 | expect(res.body).to.have.a.property('accessToken'); 278 | expect(res.body).to.have.a.property('refreshToken'); 279 | expect(res.body).to.have.a.property('expiresIn'); 280 | }); 281 | }); 282 | 283 | it('should report error when email and refreshToken don\'t match', async () => { 284 | await RefreshToken.create(refreshToken); 285 | return request(app) 286 | .post('/v1/auth/refresh-token') 287 | .send({ email: user.email, refreshToken: refreshToken.token }) 288 | .expect(httpStatus.UNAUTHORIZED) 289 | .then((res) => { 290 | const { code } = res.body; 291 | const { message } = res.body; 292 | expect(code).to.be.equal(401); 293 | expect(message).to.be.equal('Incorrect email or refreshToken'); 294 | }); 295 | }); 296 | 297 | it('should report error when email and refreshToken are not provided', () => { 298 | return request(app) 299 | .post('/v1/auth/refresh-token') 300 | .send({}) 301 | .expect(httpStatus.BAD_REQUEST) 302 | .then((res) => { 303 | const field1 = res.body.errors[0].field; 304 | const location1 = res.body.errors[0].location; 305 | const messages1 = res.body.errors[0].messages; 306 | const field2 = res.body.errors[1].field; 307 | const location2 = res.body.errors[1].location; 308 | const messages2 = res.body.errors[1].messages; 309 | expect(field1).to.be.equal('email'); 310 | expect(location1).to.be.equal('body'); 311 | expect(messages1).to.include('"email" is required'); 312 | expect(field2).to.be.equal('refreshToken'); 313 | expect(location2).to.be.equal('body'); 314 | expect(messages2).to.include('"refreshToken" is required'); 315 | }); 316 | }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /src/api/services/auth/auth.validation.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports = { 4 | // POST /v1/auth/register 5 | register: { 6 | body: { 7 | email: Joi.string().email().required(), 8 | password: Joi.string().required().min(6).max(128), 9 | }, 10 | }, 11 | 12 | // POST /v1/auth/login 13 | login: { 14 | body: { 15 | email: Joi.string().email().required(), 16 | password: Joi.string().required().max(128), 17 | }, 18 | }, 19 | 20 | // POST /v1/auth/facebook 21 | // POST /v1/auth/google 22 | oAuth: { 23 | body: { 24 | access_token: Joi.string().required(), 25 | }, 26 | }, 27 | 28 | // POST /v1/auth/refresh 29 | refresh: { 30 | body: { 31 | email: Joi.string().email().required(), 32 | refreshToken: Joi.string().required(), 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/api/services/auth/refreshToken.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const crypto = require('crypto'); 3 | const moment = require('moment-timezone'); 4 | 5 | /** 6 | * Refresh Token Schema 7 | * @private 8 | */ 9 | const refreshTokenSchema = new mongoose.Schema({ 10 | token: { 11 | type: String, 12 | required: true, 13 | index: true, 14 | }, 15 | userId: { 16 | type: mongoose.Schema.Types.ObjectId, 17 | ref: 'User', 18 | required: true, 19 | }, 20 | userEmail: { 21 | type: 'String', 22 | ref: 'User', 23 | required: true, 24 | }, 25 | expires: { type: Date }, 26 | }); 27 | 28 | refreshTokenSchema.statics = { 29 | 30 | /** 31 | * Generate a refresh token object and saves it into the database 32 | * 33 | * @param {User} user 34 | * @returns {RefreshToken} 35 | */ 36 | generate(user) { 37 | const userId = user._id; 38 | const userEmail = user.email; 39 | const token = `${userId}.${crypto.randomBytes(40).toString('hex')}`; 40 | const expires = moment().add(30, 'days').toDate(); 41 | const tokenObject = new RefreshToken({ 42 | token, userId, userEmail, expires, 43 | }); 44 | tokenObject.save(); 45 | return tokenObject; 46 | }, 47 | 48 | }; 49 | 50 | /** 51 | * @typedef RefreshToken 52 | */ 53 | const RefreshToken = mongoose.model('RefreshToken', refreshTokenSchema); 54 | module.exports = RefreshToken; 55 | -------------------------------------------------------------------------------- /src/api/services/user/user.controller.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const service = require('./user.service'); 3 | const { handler: errorHandler } = require('../../middlewares/error'); 4 | 5 | /** 6 | * Load user and append to req. 7 | * @public 8 | */ 9 | exports.load = async (req, res, next, id) => { 10 | try { 11 | const user = await service.get(id); 12 | req.locals = { user }; 13 | return next(); 14 | } catch (error) { 15 | return errorHandler(error, req, res); 16 | } 17 | }; 18 | 19 | /** 20 | * Get user 21 | * @public 22 | */ 23 | exports.get = (req, res) => res.json(req.locals.user.transform()); 24 | 25 | /** 26 | * Get logged in user info 27 | * @public 28 | */ 29 | exports.loggedIn = (req, res) => res.json(req.user.transform()); 30 | 31 | /** 32 | * Create new user 33 | * @public 34 | */ 35 | exports.create = async (req, res, next) => { 36 | try { 37 | const response = await service.create(req.body); 38 | return res.status(httpStatus.CREATED).json(response); 39 | } catch (error) { 40 | return next(error); 41 | } 42 | }; 43 | 44 | /** 45 | * Replace existing user 46 | * @public 47 | */ 48 | exports.replace = async (req, res, next) => { 49 | try { 50 | const { user } = req.locals; 51 | const response = await service.replace(user, req.body); 52 | return res.json(response); 53 | } catch (error) { 54 | return next(error); 55 | } 56 | }; 57 | 58 | /** 59 | * Update existing user 60 | * @public 61 | */ 62 | exports.update = async (req, res, next) => { 63 | try { 64 | const { user } = req.locals; 65 | const response = await service.update(user, req.body); 66 | return res.json(response); 67 | } catch (error) { 68 | return next(error); 69 | } 70 | }; 71 | 72 | /** 73 | * Get user list 74 | * @public 75 | */ 76 | exports.list = async (req, res, next) => { 77 | try { 78 | const response = await service.list(req.query); 79 | res.json(response); 80 | } catch (error) { 81 | next(error); 82 | } 83 | }; 84 | 85 | /** 86 | * Delete user 87 | * @public 88 | */ 89 | exports.remove = async (req, res, next) => { 90 | try { 91 | const { user } = req.locals; 92 | await service.remove(user); 93 | res.status(httpStatus.NO_CONTENT).end(); 94 | } catch (error) { 95 | next(error); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /src/api/services/user/user.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const httpStatus = require('http-status'); 3 | const { omitBy, isNil } = require('lodash'); 4 | const bcrypt = require('bcryptjs'); 5 | const moment = require('moment-timezone'); 6 | const jwt = require('jwt-simple'); 7 | const uuidv4 = require('uuid/v4'); 8 | const APIError = require('../../utils/APIError'); 9 | const { env, jwtSecret, jwtExpirationInterval } = require('../../../config/vars'); 10 | 11 | /** 12 | * User Roles 13 | */ 14 | const roles = ['user', 'admin']; 15 | 16 | /** 17 | * User Schema 18 | * @private 19 | */ 20 | const userSchema = new mongoose.Schema({ 21 | email: { 22 | type: String, 23 | match: /^\S+@\S+\.\S+$/, 24 | required: true, 25 | unique: true, 26 | trim: true, 27 | lowercase: true, 28 | }, 29 | password: { 30 | type: String, 31 | required: true, 32 | minlength: 6, 33 | maxlength: 128, 34 | }, 35 | name: { 36 | type: String, 37 | maxlength: 128, 38 | index: true, 39 | trim: true, 40 | }, 41 | services: { 42 | facebook: String, 43 | google: String, 44 | }, 45 | role: { 46 | type: String, 47 | enum: roles, 48 | default: 'user', 49 | }, 50 | picture: { 51 | type: String, 52 | trim: true, 53 | }, 54 | }, { 55 | timestamps: true, 56 | }); 57 | 58 | /** 59 | * Add your 60 | * - pre-save hooks 61 | * - validations 62 | * - virtuals 63 | */ 64 | userSchema.pre('save', async function save(next) { 65 | try { 66 | if (!this.isModified('password')) return next(); 67 | 68 | const rounds = env === 'test' ? 1 : 10; 69 | 70 | const hash = await bcrypt.hash(this.password, rounds); 71 | this.password = hash; 72 | 73 | return next(); 74 | } catch (error) { 75 | return next(error); 76 | } 77 | }); 78 | 79 | /** 80 | * Methods 81 | */ 82 | userSchema.method({ 83 | transform() { 84 | const transformed = {}; 85 | const fields = ['id', 'name', 'email', 'picture', 'role', 'createdAt']; 86 | 87 | fields.forEach((field) => { 88 | transformed[field] = this[field]; 89 | }); 90 | 91 | return transformed; 92 | }, 93 | 94 | token() { 95 | const playload = { 96 | exp: moment().add(jwtExpirationInterval, 'minutes').unix(), 97 | iat: moment().unix(), 98 | sub: this._id, 99 | }; 100 | return jwt.encode(playload, jwtSecret); 101 | }, 102 | 103 | async passwordMatches(password) { 104 | return bcrypt.compare(password, this.password); 105 | }, 106 | }); 107 | 108 | /** 109 | * Statics 110 | */ 111 | userSchema.statics = { 112 | 113 | roles, 114 | 115 | /** 116 | * Get user 117 | * 118 | * @param {ObjectId} id - The objectId of user. 119 | * @returns {Promise} 120 | */ 121 | async get(id) { 122 | try { 123 | let user; 124 | 125 | if (mongoose.Types.ObjectId.isValid(id)) { 126 | user = await this.findById(id).exec(); 127 | } 128 | if (user) { 129 | return user; 130 | } 131 | 132 | throw new APIError({ 133 | message: 'User does not exist', 134 | status: httpStatus.NOT_FOUND, 135 | }); 136 | } catch (error) { 137 | throw error; 138 | } 139 | }, 140 | 141 | /** 142 | * Find user by email and tries to generate a JWT token 143 | * 144 | * @param {ObjectId} id - The objectId of user. 145 | * @returns {Promise} 146 | */ 147 | async findAndGenerateToken(options) { 148 | const { email, password, refreshObject } = options; 149 | if (!email) throw new APIError({ message: 'An email is required to generate a token' }); 150 | 151 | const user = await this.findOne({ email }).exec(); 152 | const err = { 153 | status: httpStatus.UNAUTHORIZED, 154 | isPublic: true, 155 | }; 156 | if (password) { 157 | if (user && await user.passwordMatches(password)) { 158 | return { user, accessToken: user.token() }; 159 | } 160 | err.message = 'Incorrect email or password'; 161 | } else if (refreshObject && refreshObject.userEmail === email) { 162 | return { user, accessToken: user.token() }; 163 | } else { 164 | err.message = 'Incorrect email or refreshToken'; 165 | } 166 | throw new APIError(err); 167 | }, 168 | 169 | /** 170 | * List users in descending order of 'createdAt' timestamp. 171 | * 172 | * @param {number} skip - Number of users to be skipped. 173 | * @param {number} limit - Limit number of users to be returned. 174 | * @returns {Promise} 175 | */ 176 | list({ 177 | page = 1, perPage = 30, name, email, role, 178 | }) { 179 | const options = omitBy({ name, email, role }, isNil); 180 | 181 | return this.find(options) 182 | .sort({ createdAt: -1 }) 183 | .skip(perPage * (page - 1)) 184 | .limit(perPage) 185 | .exec(); 186 | }, 187 | 188 | /** 189 | * Return new validation error 190 | * if error is a mongoose duplicate key error 191 | * 192 | * @param {Error} error 193 | * @returns {Error|APIError} 194 | */ 195 | checkDuplicateEmail(error) { 196 | if (error.code === 11000 && (error.name === 'BulkWriteError' || error.name === 'MongoError')) { 197 | return new APIError({ 198 | message: 'Validation Error', 199 | errors: [{ 200 | field: 'email', 201 | location: 'body', 202 | messages: ['"email" already exists'], 203 | }], 204 | status: httpStatus.CONFLICT, 205 | isPublic: true, 206 | stack: error.stack, 207 | }); 208 | } 209 | return error; 210 | }, 211 | 212 | async oAuthLogin({ 213 | service, id, email, name, picture, 214 | }) { 215 | const user = await this.findOne({ $or: [{ [`services.${service}`]: id }, { email }] }); 216 | if (user) { 217 | user.services[service] = id; 218 | if (!user.name) user.name = name; 219 | if (!user.picture) user.picture = picture; 220 | return user.save(); 221 | } 222 | const password = uuidv4(); 223 | return this.create({ 224 | services: { [service]: id }, email, password, name, picture, 225 | }); 226 | }, 227 | }; 228 | 229 | /** 230 | * @typedef User 231 | */ 232 | module.exports = mongoose.model('User', userSchema); 233 | -------------------------------------------------------------------------------- /src/api/services/user/user.route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const validate = require('express-validation'); 3 | const controller = require('./user.controller'); 4 | const { authorize, ADMIN, LOGGED_USER } = require('../../middlewares/auth'); 5 | const { 6 | listUsers, 7 | createUser, 8 | replaceUser, 9 | updateUser, 10 | } = require('./user.validation'); 11 | 12 | const router = express.Router(); 13 | 14 | /** 15 | * Load user when API with userId route parameter is hit 16 | */ 17 | router.param('userId', controller.load); 18 | 19 | 20 | router 21 | .route('/') 22 | /** 23 | * @api {get} v1/users List Users 24 | * @apiDescription Get a list of users 25 | * @apiVersion 1.0.0 26 | * @apiName ListUsers 27 | * @apiGroup User 28 | * @apiPermission admin 29 | * 30 | * @apiHeader {String} Authorization User's access token 31 | * 32 | * @apiParam {Number{1-}} [page=1] List page 33 | * @apiParam {Number{1-100}} [perPage=1] Users per page 34 | * @apiParam {String} [name] User's name 35 | * @apiParam {String} [email] User's email 36 | * @apiParam {String=user,admin} [role] User's role 37 | * 38 | * @apiSuccess {Object[]} users List of users. 39 | * 40 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data 41 | * @apiError (Forbidden 403) Forbidden Only admins can access the data 42 | */ 43 | .get(authorize(ADMIN), validate(listUsers), controller.list) 44 | /** 45 | * @api {post} v1/users Create User 46 | * @apiDescription Create a new user 47 | * @apiVersion 1.0.0 48 | * @apiName CreateUser 49 | * @apiGroup User 50 | * @apiPermission admin 51 | * 52 | * @apiHeader {String} Authorization User's access token 53 | * 54 | * @apiParam {String} email User's email 55 | * @apiParam {String{6..128}} password User's password 56 | * @apiParam {String{..128}} [name] User's name 57 | * @apiParam {String=user,admin} [role] User's role 58 | * 59 | * @apiSuccess (Created 201) {String} id User's id 60 | * @apiSuccess (Created 201) {String} name User's name 61 | * @apiSuccess (Created 201) {String} email User's email 62 | * @apiSuccess (Created 201) {String} role User's role 63 | * @apiSuccess (Created 201) {Date} createdAt Timestamp 64 | * 65 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 66 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can create the data 67 | * @apiError (Forbidden 403) Forbidden Only admins can create the data 68 | */ 69 | .post(authorize(ADMIN), validate(createUser), controller.create); 70 | 71 | 72 | router 73 | .route('/profile') 74 | /** 75 | * @api {get} v1/users/profile User Profile 76 | * @apiDescription Get logged in user profile information 77 | * @apiVersion 1.0.0 78 | * @apiName UserProfile 79 | * @apiGroup User 80 | * @apiPermission user 81 | * 82 | * @apiHeader {String} Authorization User's access token 83 | * 84 | * @apiSuccess {String} id User's id 85 | * @apiSuccess {String} name User's name 86 | * @apiSuccess {String} email User's email 87 | * @apiSuccess {String} role User's role 88 | * @apiSuccess {Date} createdAt Timestamp 89 | * 90 | * @apiError (Unauthorized 401) Unauthorized Only authenticated Users can access the data 91 | */ 92 | .get(authorize(), controller.loggedIn); 93 | 94 | 95 | router 96 | .route('/:userId') 97 | /** 98 | * @api {get} v1/users/:id Get User 99 | * @apiDescription Get user information 100 | * @apiVersion 1.0.0 101 | * @apiName GetUser 102 | * @apiGroup User 103 | * @apiPermission user 104 | * 105 | * @apiHeader {String} Authorization User's access token 106 | * 107 | * @apiSuccess {String} id User's id 108 | * @apiSuccess {String} name User's name 109 | * @apiSuccess {String} email User's email 110 | * @apiSuccess {String} role User's role 111 | * @apiSuccess {Date} createdAt Timestamp 112 | * 113 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data 114 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can access the data 115 | * @apiError (Not Found 404) NotFound User does not exist 116 | */ 117 | .get(authorize(LOGGED_USER), controller.get) 118 | /** 119 | * @api {put} v1/users/:id Replace User 120 | * @apiDescription Replace the whole user document with a new one 121 | * @apiVersion 1.0.0 122 | * @apiName ReplaceUser 123 | * @apiGroup User 124 | * @apiPermission user 125 | * 126 | * @apiHeader {String} Authorization User's access token 127 | * 128 | * @apiParam {String} email User's email 129 | * @apiParam {String{6..128}} password User's password 130 | * @apiParam {String{..128}} [name] User's name 131 | * @apiParam {String=user,admin} [role] User's role 132 | * (You must be an admin to change the user's role) 133 | * 134 | * @apiSuccess {String} id User's id 135 | * @apiSuccess {String} name User's name 136 | * @apiSuccess {String} email User's email 137 | * @apiSuccess {String} role User's role 138 | * @apiSuccess {Date} createdAt Timestamp 139 | * 140 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 141 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data 142 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data 143 | * @apiError (Not Found 404) NotFound User does not exist 144 | */ 145 | .put(authorize(LOGGED_USER), validate(replaceUser), controller.replace) 146 | /** 147 | * @api {patch} v1/users/:id Update User 148 | * @apiDescription Update some fields of a user document 149 | * @apiVersion 1.0.0 150 | * @apiName UpdateUser 151 | * @apiGroup User 152 | * @apiPermission user 153 | * 154 | * @apiHeader {String} Authorization User's access token 155 | * 156 | * @apiParam {String} email User's email 157 | * @apiParam {String{6..128}} password User's password 158 | * @apiParam {String{..128}} [name] User's name 159 | * @apiParam {String=user,admin} [role] User's role 160 | * (You must be an admin to change the user's role) 161 | * 162 | * @apiSuccess {String} id User's id 163 | * @apiSuccess {String} name User's name 164 | * @apiSuccess {String} email User's email 165 | * @apiSuccess {String} role User's role 166 | * @apiSuccess {Date} createdAt Timestamp 167 | * 168 | * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values 169 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data 170 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data 171 | * @apiError (Not Found 404) NotFound User does not exist 172 | */ 173 | .patch(authorize(LOGGED_USER), validate(updateUser), controller.update) 174 | /** 175 | * @api {patch} v1/users/:id Delete User 176 | * @apiDescription Delete a user 177 | * @apiVersion 1.0.0 178 | * @apiName DeleteUser 179 | * @apiGroup User 180 | * @apiPermission user 181 | * 182 | * @apiHeader {String} Authorization User's access token 183 | * 184 | * @apiSuccess (No Content 204) Successfully deleted 185 | * 186 | * @apiError (Unauthorized 401) Unauthorized Only authenticated users can delete the data 187 | * @apiError (Forbidden 403) Forbidden Only user with same id or admins can delete the data 188 | * @apiError (Not Found 404) NotFound User does not exist 189 | */ 190 | .delete(authorize(LOGGED_USER), controller.remove); 191 | 192 | 193 | module.exports = router; 194 | -------------------------------------------------------------------------------- /src/api/services/user/user.service.js: -------------------------------------------------------------------------------- 1 | const { omit } = require('lodash'); 2 | const User = require('./user.model'); 3 | // const { handler: errorHandler } = require('../../middlewares/error'); 4 | 5 | /** 6 | * Load user and append to req. 7 | * @public 8 | */ 9 | // exports.load = async (req, res, next, id) => { 10 | // try { 11 | // console.log(12); 12 | // const user = await User.get(id); 13 | // req.locals = { user }; 14 | // return next(); 15 | // } catch (error) { 16 | // return errorHandler(error, req, res); 17 | // } 18 | // }; 19 | 20 | /** 21 | * Get user 22 | * @public 23 | */ 24 | exports.get = async id => User.get(id); 25 | 26 | /** 27 | * Get logged in user info 28 | * @public 29 | */ 30 | exports.loggedIn = (req, res) => res.json(req.user.transform()); 31 | 32 | /** 33 | * Create new user 34 | * @public 35 | */ 36 | exports.create = async (userData) => { 37 | try { 38 | const user = new User(userData); 39 | const savedUser = await user.save(); 40 | return savedUser.transform(); 41 | } catch (error) { 42 | throw User.checkDuplicateEmail(error); 43 | } 44 | }; 45 | 46 | /** 47 | * Replace existing user 48 | * @public 49 | */ 50 | exports.replace = async (user, newUserData) => { 51 | try { 52 | const newUser = new User(newUserData); 53 | const ommitRole = user.role !== 'admin' ? 'role' : ''; 54 | const newUserObject = omit(newUser.toObject(), '_id', ommitRole); 55 | 56 | await user.update(newUserObject, { override: true, upsert: true }); 57 | const savedUser = await User.findById(user._id); 58 | 59 | return savedUser.transform(); 60 | } catch (error) { 61 | throw User.checkDuplicateEmail(error); 62 | } 63 | }; 64 | 65 | /** 66 | * Update existing user 67 | * @public 68 | */ 69 | exports.update = async (user, updatedData) => { 70 | try { 71 | const ommitRole = user.role !== 'admin' ? 'role' : ''; 72 | const userTobeUpdated = omit(updatedData, ommitRole); 73 | const updatedUser = Object.assign(user, userTobeUpdated); 74 | const savedUser = await updatedUser.save(); 75 | return savedUser.transform(); 76 | } catch (error) { 77 | throw User.checkDuplicateEmail(error); 78 | } 79 | }; 80 | 81 | /** 82 | * Get user list 83 | * @public 84 | */ 85 | exports.list = async (params) => { 86 | try { 87 | const users = await User.list(params); 88 | const transformedUsers = users.map(user => user.transform()); 89 | return transformedUsers; 90 | } catch (error) { 91 | throw error; 92 | } 93 | }; 94 | 95 | /** 96 | * Delete user 97 | * @public 98 | */ 99 | exports.remove = async user => user.remove(); 100 | -------------------------------------------------------------------------------- /src/api/services/user/user.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | /* eslint-disable no-unused-expressions */ 3 | const request = require('supertest'); 4 | const httpStatus = require('http-status'); 5 | const { expect } = require('chai'); 6 | const sinon = require('sinon'); 7 | const bcrypt = require('bcryptjs'); 8 | const { some, omitBy, isNil } = require('lodash'); 9 | const app = require('../../../index'); 10 | const User = require('./user.model'); 11 | const JWT_EXPIRATION = require('../../../config/vars').jwtExpirationInterval; 12 | 13 | /** 14 | * root level hooks 15 | */ 16 | 17 | async function format(user) { 18 | const formated = user; 19 | 20 | // delete password 21 | delete formated.password; 22 | 23 | // get users from database 24 | const dbUser = (await User.findOne({ email: user.email })).transform(); 25 | 26 | // remove null and undefined properties 27 | return omitBy(dbUser, isNil); 28 | } 29 | 30 | describe('Users API', async () => { 31 | let adminAccessToken; 32 | let userAccessToken; 33 | let dbUsers; 34 | let user; 35 | let admin; 36 | 37 | const password = '123456'; 38 | const passwordHashed = await bcrypt.hash(password, 1); 39 | 40 | beforeEach(async () => { 41 | dbUsers = { 42 | branStark: { 43 | email: 'branstark@gmail.com', 44 | password: passwordHashed, 45 | name: 'Bran Stark', 46 | role: 'admin', 47 | }, 48 | jonSnow: { 49 | email: 'jonsnow@gmail.com', 50 | password: passwordHashed, 51 | name: 'Jon Snow', 52 | }, 53 | }; 54 | 55 | user = { 56 | email: 'sousa.dfs@gmail.com', 57 | password, 58 | name: 'Daniel Sousa', 59 | }; 60 | 61 | admin = { 62 | email: 'sousa.dfs@gmail.com', 63 | password, 64 | name: 'Daniel Sousa', 65 | role: 'admin', 66 | }; 67 | 68 | await User.remove({}); 69 | await User.insertMany([dbUsers.branStark, dbUsers.jonSnow]); 70 | dbUsers.branStark.password = password; 71 | dbUsers.jonSnow.password = password; 72 | adminAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken; 73 | userAccessToken = (await User.findAndGenerateToken(dbUsers.jonSnow)).accessToken; 74 | }); 75 | 76 | describe('POST /v1/users', () => { 77 | it('should create a new user when request is ok', () => { 78 | return request(app) 79 | .post('/v1/users') 80 | .set('Authorization', `Bearer ${adminAccessToken}`) 81 | .send(admin) 82 | .expect(httpStatus.CREATED) 83 | .then((res) => { 84 | delete admin.password; 85 | expect(res.body).to.include(admin); 86 | }); 87 | }); 88 | 89 | it('should create a new user and set default role to "user"', () => { 90 | return request(app) 91 | .post('/v1/users') 92 | .set('Authorization', `Bearer ${adminAccessToken}`) 93 | .send(user) 94 | .expect(httpStatus.CREATED) 95 | .then((res) => { 96 | expect(res.body.role).to.be.equal('user'); 97 | }); 98 | }); 99 | 100 | it('should report error when email already exists', () => { 101 | user.email = dbUsers.branStark.email; 102 | 103 | return request(app) 104 | .post('/v1/users') 105 | .set('Authorization', `Bearer ${adminAccessToken}`) 106 | .send(user) 107 | .expect(httpStatus.CONFLICT) 108 | .then((res) => { 109 | const { field } = res.body.errors[0]; 110 | const { location } = res.body.errors[0]; 111 | const { messages } = res.body.errors[0]; 112 | expect(field).to.be.equal('email'); 113 | expect(location).to.be.equal('body'); 114 | expect(messages).to.include('"email" already exists'); 115 | }); 116 | }); 117 | 118 | it('should report error when email is not provided', () => { 119 | delete user.email; 120 | 121 | return request(app) 122 | .post('/v1/users') 123 | .set('Authorization', `Bearer ${adminAccessToken}`) 124 | .send(user) 125 | .expect(httpStatus.BAD_REQUEST) 126 | .then((res) => { 127 | const { field } = res.body.errors[0]; 128 | const { location } = res.body.errors[0]; 129 | const { messages } = res.body.errors[0]; 130 | expect(field).to.be.equal('email'); 131 | expect(location).to.be.equal('body'); 132 | expect(messages).to.include('"email" is required'); 133 | }); 134 | }); 135 | 136 | it('should report error when password length is less than 6', () => { 137 | user.password = '12345'; 138 | 139 | return request(app) 140 | .post('/v1/users') 141 | .set('Authorization', `Bearer ${adminAccessToken}`) 142 | .send(user) 143 | .expect(httpStatus.BAD_REQUEST) 144 | .then((res) => { 145 | const { field } = res.body.errors[0]; 146 | const { location } = res.body.errors[0]; 147 | const { messages } = res.body.errors[0]; 148 | expect(field).to.be.equal('password'); 149 | expect(location).to.be.equal('body'); 150 | expect(messages).to.include('"password" length must be at least 6 characters long'); 151 | }); 152 | }); 153 | 154 | it('should report error when logged user is not an admin', () => { 155 | return request(app) 156 | .post('/v1/users') 157 | .set('Authorization', `Bearer ${userAccessToken}`) 158 | .send(user) 159 | .expect(httpStatus.FORBIDDEN) 160 | .then((res) => { 161 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 162 | expect(res.body.message).to.be.equal('Forbidden'); 163 | }); 164 | }); 165 | }); 166 | 167 | describe('GET /v1/users', () => { 168 | it('should get all users', () => { 169 | return request(app) 170 | .get('/v1/users') 171 | .set('Authorization', `Bearer ${adminAccessToken}`) 172 | .expect(httpStatus.OK) 173 | .then(async (res) => { 174 | const bran = format(dbUsers.branStark); 175 | const john = format(dbUsers.jonSnow); 176 | 177 | const includesBranStark = some(res.body, bran); 178 | const includesjonSnow = some(res.body, john); 179 | 180 | // before comparing it is necessary to convert String to Date 181 | res.body[0].createdAt = new Date(res.body[0].createdAt); 182 | res.body[1].createdAt = new Date(res.body[1].createdAt); 183 | 184 | expect(res.body).to.be.an('array'); 185 | expect(res.body).to.have.lengthOf(2); 186 | expect(includesBranStark).to.be.true; 187 | expect(includesjonSnow).to.be.true; 188 | }); 189 | }); 190 | 191 | it('should get all users with pagination', () => { 192 | return request(app) 193 | .get('/v1/users') 194 | .set('Authorization', `Bearer ${adminAccessToken}`) 195 | .query({ page: 2, perPage: 1 }) 196 | .expect(httpStatus.OK) 197 | .then((res) => { 198 | delete dbUsers.jonSnow.password; 199 | const john = format(dbUsers.jonSnow); 200 | const includesjonSnow = some(res.body, john); 201 | 202 | // before comparing it is necessary to convert String to Date 203 | res.body[0].createdAt = new Date(res.body[0].createdAt); 204 | 205 | expect(res.body).to.be.an('array'); 206 | expect(res.body).to.have.lengthOf(1); 207 | expect(includesjonSnow).to.be.true; 208 | }); 209 | }); 210 | 211 | it('should filter users', () => { 212 | return request(app) 213 | .get('/v1/users') 214 | .set('Authorization', `Bearer ${adminAccessToken}`) 215 | .query({ email: dbUsers.jonSnow.email }) 216 | .expect(httpStatus.OK) 217 | .then((res) => { 218 | delete dbUsers.jonSnow.password; 219 | const john = format(dbUsers.jonSnow); 220 | const includesjonSnow = some(res.body, john); 221 | 222 | // before comparing it is necessary to convert String to Date 223 | res.body[0].createdAt = new Date(res.body[0].createdAt); 224 | 225 | expect(res.body).to.be.an('array'); 226 | expect(res.body).to.have.lengthOf(1); 227 | expect(includesjonSnow).to.be.true; 228 | }); 229 | }); 230 | 231 | it('should report error when pagination\'s parameters are not a number', () => { 232 | return request(app) 233 | .get('/v1/users') 234 | .set('Authorization', `Bearer ${adminAccessToken}`) 235 | .query({ page: '?', perPage: 'whaat' }) 236 | .expect(httpStatus.BAD_REQUEST) 237 | .then((res) => { 238 | const { field } = res.body.errors[0]; 239 | const { location } = res.body.errors[0]; 240 | const { messages } = res.body.errors[0]; 241 | expect(field).to.be.equal('page'); 242 | expect(location).to.be.equal('query'); 243 | expect(messages).to.include('"page" must be a number'); 244 | return Promise.resolve(res); 245 | }) 246 | .then((res) => { 247 | const { field } = res.body.errors[1]; 248 | const { location } = res.body.errors[1]; 249 | const { messages } = res.body.errors[1]; 250 | expect(field).to.be.equal('perPage'); 251 | expect(location).to.be.equal('query'); 252 | expect(messages).to.include('"perPage" must be a number'); 253 | }); 254 | }); 255 | 256 | it('should report error if logged user is not an admin', () => { 257 | return request(app) 258 | .get('/v1/users') 259 | .set('Authorization', `Bearer ${userAccessToken}`) 260 | .expect(httpStatus.FORBIDDEN) 261 | .then((res) => { 262 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 263 | expect(res.body.message).to.be.equal('Forbidden'); 264 | }); 265 | }); 266 | }); 267 | 268 | describe('GET /v1/users/:userId', () => { 269 | it('should get user', async () => { 270 | const id = (await User.findOne({}))._id; 271 | delete dbUsers.branStark.password; 272 | 273 | return request(app) 274 | .get(`/v1/users/${id}`) 275 | .set('Authorization', `Bearer ${adminAccessToken}`) 276 | .expect(httpStatus.OK) 277 | .then((res) => { 278 | expect(res.body).to.include(dbUsers.branStark); 279 | }); 280 | }); 281 | 282 | it('should report error "User does not exist" when user does not exists', () => { 283 | return request(app) 284 | .get('/v1/users/56c787ccc67fc16ccc1a5e92') 285 | .set('Authorization', `Bearer ${adminAccessToken}`) 286 | .expect(httpStatus.NOT_FOUND) 287 | .then((res) => { 288 | expect(res.body.code).to.be.equal(404); 289 | expect(res.body.message).to.be.equal('User does not exist'); 290 | }); 291 | }); 292 | 293 | it('should report error "User does not exist" when id is not a valid ObjectID', () => { 294 | return request(app) 295 | .get('/v1/users/palmeiras1914') 296 | .set('Authorization', `Bearer ${adminAccessToken}`) 297 | .expect(httpStatus.NOT_FOUND) 298 | .then((res) => { 299 | expect(res.body.code).to.be.equal(404); 300 | expect(res.body.message).to.equal('User does not exist'); 301 | }); 302 | }); 303 | 304 | it('should report error when logged user is not the same as the requested one', async () => { 305 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 306 | 307 | return request(app) 308 | .get(`/v1/users/${id}`) 309 | .set('Authorization', `Bearer ${userAccessToken}`) 310 | .expect(httpStatus.FORBIDDEN) 311 | .then((res) => { 312 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 313 | expect(res.body.message).to.be.equal('Forbidden'); 314 | }); 315 | }); 316 | }); 317 | 318 | describe('PUT /v1/users/:userId', () => { 319 | it('should replace user', async () => { 320 | delete dbUsers.branStark.password; 321 | const id = (await User.findOne(dbUsers.branStark))._id; 322 | 323 | return request(app) 324 | .put(`/v1/users/${id}`) 325 | .set('Authorization', `Bearer ${adminAccessToken}`) 326 | .send(user) 327 | .expect(httpStatus.OK) 328 | .then((res) => { 329 | delete user.password; 330 | expect(res.body).to.include(user); 331 | expect(res.body.role).to.be.equal('user'); 332 | }); 333 | }); 334 | 335 | it('should report error when email is not provided', async () => { 336 | const id = (await User.findOne({}))._id; 337 | delete user.email; 338 | 339 | return request(app) 340 | .put(`/v1/users/${id}`) 341 | .set('Authorization', `Bearer ${adminAccessToken}`) 342 | .send(user) 343 | .expect(httpStatus.BAD_REQUEST) 344 | .then((res) => { 345 | const { field } = res.body.errors[0]; 346 | const { location } = res.body.errors[0]; 347 | const { messages } = res.body.errors[0]; 348 | expect(field).to.be.equal('email'); 349 | expect(location).to.be.equal('body'); 350 | expect(messages).to.include('"email" is required'); 351 | }); 352 | }); 353 | 354 | it('should report error user when password length is less than 6', async () => { 355 | const id = (await User.findOne({}))._id; 356 | user.password = '12345'; 357 | 358 | return request(app) 359 | .put(`/v1/users/${id}`) 360 | .set('Authorization', `Bearer ${adminAccessToken}`) 361 | .send(user) 362 | .expect(httpStatus.BAD_REQUEST) 363 | .then((res) => { 364 | const { field } = res.body.errors[0]; 365 | const { location } = res.body.errors[0]; 366 | const { messages } = res.body.errors[0]; 367 | expect(field).to.be.equal('password'); 368 | expect(location).to.be.equal('body'); 369 | expect(messages).to.include('"password" length must be at least 6 characters long'); 370 | }); 371 | }); 372 | 373 | it('should report error "User does not exist" when user does not exists', () => { 374 | return request(app) 375 | .put('/v1/users/palmeiras1914') 376 | .set('Authorization', `Bearer ${adminAccessToken}`) 377 | .expect(httpStatus.NOT_FOUND) 378 | .then((res) => { 379 | expect(res.body.code).to.be.equal(404); 380 | expect(res.body.message).to.be.equal('User does not exist'); 381 | }); 382 | }); 383 | 384 | it('should report error when logged user is not the same as the requested one', async () => { 385 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 386 | 387 | return request(app) 388 | .put(`/v1/users/${id}`) 389 | .set('Authorization', `Bearer ${userAccessToken}`) 390 | .expect(httpStatus.FORBIDDEN) 391 | .then((res) => { 392 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 393 | expect(res.body.message).to.be.equal('Forbidden'); 394 | }); 395 | }); 396 | 397 | it('should not replace the role of the user (not admin)', async () => { 398 | const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id; 399 | const role = 'admin'; 400 | 401 | return request(app) 402 | .put(`/v1/users/${id}`) 403 | .set('Authorization', `Bearer ${userAccessToken}`) 404 | .send(admin) 405 | .expect(httpStatus.OK) 406 | .then((res) => { 407 | expect(res.body.role).to.not.be.equal(role); 408 | }); 409 | }); 410 | 411 | it('should not assign the already existing email', async () => { 412 | delete dbUsers.branStark.password; 413 | const id = (await User.findOne(dbUsers.branStark))._id; 414 | user.email = dbUsers.jonSnow.email; 415 | return request(app) 416 | .put(`/v1/users/${id}`) 417 | .set('Authorization', `Bearer ${adminAccessToken}`) 418 | .send(user) 419 | .expect(httpStatus.CONFLICT) 420 | .then((res) => { 421 | const { field } = res.body.errors[0]; 422 | const { location } = res.body.errors[0]; 423 | const { messages } = res.body.errors[0]; 424 | expect(field).to.be.equal('email'); 425 | expect(location).to.be.equal('body'); 426 | expect(messages).to.include('"email" already exists'); 427 | }); 428 | }); 429 | }); 430 | 431 | describe('PATCH /v1/users/:userId', () => { 432 | it('should update user', async () => { 433 | delete dbUsers.branStark.password; 434 | const id = (await User.findOne(dbUsers.branStark))._id; 435 | const { name } = user; 436 | 437 | return request(app) 438 | .patch(`/v1/users/${id}`) 439 | .set('Authorization', `Bearer ${adminAccessToken}`) 440 | .send({ name }) 441 | .expect(httpStatus.OK) 442 | .then((res) => { 443 | expect(res.body.name).to.be.equal(name); 444 | expect(res.body.email).to.be.equal(dbUsers.branStark.email); 445 | }); 446 | }); 447 | 448 | it('should not update user when no parameters were given', async () => { 449 | delete dbUsers.branStark.password; 450 | const id = (await User.findOne(dbUsers.branStark))._id; 451 | 452 | return request(app) 453 | .patch(`/v1/users/${id}`) 454 | .set('Authorization', `Bearer ${adminAccessToken}`) 455 | .send() 456 | .expect(httpStatus.OK) 457 | .then((res) => { 458 | expect(res.body).to.include(dbUsers.branStark); 459 | }); 460 | }); 461 | 462 | it('should report error "User does not exist" when user does not exists', () => { 463 | return request(app) 464 | .patch('/v1/users/palmeiras1914') 465 | .set('Authorization', `Bearer ${adminAccessToken}`) 466 | .expect(httpStatus.NOT_FOUND) 467 | .then((res) => { 468 | expect(res.body.code).to.be.equal(404); 469 | expect(res.body.message).to.be.equal('User does not exist'); 470 | }); 471 | }); 472 | 473 | it('should report error when logged user is not the same as the requested one', async () => { 474 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 475 | 476 | return request(app) 477 | .patch(`/v1/users/${id}`) 478 | .set('Authorization', `Bearer ${userAccessToken}`) 479 | .expect(httpStatus.FORBIDDEN) 480 | .then((res) => { 481 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 482 | expect(res.body.message).to.be.equal('Forbidden'); 483 | }); 484 | }); 485 | 486 | it('should not update the role of the user (not admin)', async () => { 487 | const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id; 488 | const role = 'admin'; 489 | 490 | return request(app) 491 | .patch(`/v1/users/${id}`) 492 | .set('Authorization', `Bearer ${userAccessToken}`) 493 | .send({ role }) 494 | .expect(httpStatus.OK) 495 | .then((res) => { 496 | expect(res.body.role).to.not.be.equal(role); 497 | }); 498 | }); 499 | 500 | it('should not assign the already existing email', async () => { 501 | delete dbUsers.branStark.password; 502 | const id = (await User.findOne(dbUsers.branStark))._id; 503 | user.email = dbUsers.jonSnow.email; 504 | return request(app) 505 | .patch(`/v1/users/${id}`) 506 | .set('Authorization', `Bearer ${adminAccessToken}`) 507 | .send(user) 508 | .expect(httpStatus.CONFLICT) 509 | .then((res) => { 510 | const { field } = res.body.errors[0]; 511 | const { location } = res.body.errors[0]; 512 | const { messages } = res.body.errors[0]; 513 | expect(field).to.be.equal('email'); 514 | expect(location).to.be.equal('body'); 515 | expect(messages).to.include('"email" already exists'); 516 | }); 517 | }); 518 | }); 519 | 520 | describe('DELETE /v1/users', () => { 521 | it('should delete user', async () => { 522 | const id = (await User.findOne({}))._id; 523 | 524 | return request(app) 525 | .delete(`/v1/users/${id}`) 526 | .set('Authorization', `Bearer ${adminAccessToken}`) 527 | .expect(httpStatus.NO_CONTENT) 528 | .then(() => request(app).get('/v1/users')) 529 | .then(async () => { 530 | const users = await User.find({}); 531 | expect(users).to.have.lengthOf(1); 532 | }); 533 | }); 534 | 535 | it('should report error "User does not exist" when user does not exists', () => { 536 | return request(app) 537 | .delete('/v1/users/palmeiras1914') 538 | .set('Authorization', `Bearer ${adminAccessToken}`) 539 | .expect(httpStatus.NOT_FOUND) 540 | .then((res) => { 541 | expect(res.body.code).to.be.equal(404); 542 | expect(res.body.message).to.be.equal('User does not exist'); 543 | }); 544 | }); 545 | 546 | it('should report error when logged user is not the same as the requested one', async () => { 547 | const id = (await User.findOne({ email: dbUsers.branStark.email }))._id; 548 | 549 | return request(app) 550 | .delete(`/v1/users/${id}`) 551 | .set('Authorization', `Bearer ${userAccessToken}`) 552 | .expect(httpStatus.FORBIDDEN) 553 | .then((res) => { 554 | expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN); 555 | expect(res.body.message).to.be.equal('Forbidden'); 556 | }); 557 | }); 558 | }); 559 | 560 | describe('GET /v1/users/profile', () => { 561 | it('should get the logged user\'s info', () => { 562 | delete dbUsers.jonSnow.password; 563 | 564 | return request(app) 565 | .get('/v1/users/profile') 566 | .set('Authorization', `Bearer ${userAccessToken}`) 567 | .expect(httpStatus.OK) 568 | .then((res) => { 569 | expect(res.body).to.include(dbUsers.jonSnow); 570 | }); 571 | }); 572 | 573 | it('should report error without stacktrace when accessToken is expired', async () => { 574 | // fake time 575 | const clock = sinon.useFakeTimers(); 576 | const expiredAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken; 577 | 578 | // move clock forward by minutes set in config + 1 minute 579 | clock.tick((JWT_EXPIRATION * 60000) + 60000); 580 | 581 | return request(app) 582 | .get('/v1/users/profile') 583 | .set('Authorization', `Bearer ${expiredAccessToken}`) 584 | .expect(httpStatus.UNAUTHORIZED) 585 | .then((res) => { 586 | expect(res.body.code).to.be.equal(httpStatus.UNAUTHORIZED); 587 | expect(res.body.message).to.be.equal('jwt expired'); 588 | expect(res.body).to.not.have.a.property('stack'); 589 | }); 590 | }); 591 | }); 592 | 593 | describe('GET /v1/not-found', () => { 594 | it('should return 404', () => { 595 | return request(app) 596 | .get('/v1/not-found') 597 | .expect(httpStatus.NOT_FOUND); 598 | }); 599 | }); 600 | }); 601 | -------------------------------------------------------------------------------- /src/api/services/user/user.validation.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const User = require('./user.model'); 3 | 4 | module.exports = { 5 | 6 | // GET /v1/users 7 | listUsers: { 8 | query: { 9 | page: Joi.number().min(1), 10 | perPage: Joi.number().min(1).max(100), 11 | name: Joi.string(), 12 | email: Joi.string(), 13 | role: Joi.string().valid(User.roles), 14 | }, 15 | }, 16 | 17 | // POST /v1/users 18 | createUser: { 19 | body: { 20 | email: Joi.string().email().required(), 21 | password: Joi.string().min(6).max(128).required(), 22 | name: Joi.string().max(128), 23 | role: Joi.string().valid(User.roles), 24 | }, 25 | }, 26 | 27 | // PUT /v1/users/:userId 28 | replaceUser: { 29 | body: { 30 | email: Joi.string().email().required(), 31 | password: Joi.string().min(6).max(128).required(), 32 | name: Joi.string().max(128), 33 | role: Joi.string().valid(User.roles), 34 | }, 35 | params: { 36 | userId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(), 37 | }, 38 | }, 39 | 40 | // PATCH /v1/users/:userId 41 | updateUser: { 42 | body: { 43 | email: Joi.string().email(), 44 | password: Joi.string().min(6).max(128), 45 | name: Joi.string().max(128), 46 | role: Joi.string().valid(User.roles), 47 | }, 48 | params: { 49 | userId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(), 50 | }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /src/api/utils/APIError.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | 3 | /** 4 | * @extends Error 5 | */ 6 | class ExtendableError extends Error { 7 | constructor({ 8 | message, errors, status, isPublic, stack, 9 | }) { 10 | super(message); 11 | this.name = this.constructor.name; 12 | this.message = message; 13 | this.errors = errors; 14 | this.status = status; 15 | this.isPublic = isPublic; 16 | this.isOperational = true; // This is required since bluebird 4 doesn't append it anymore. 17 | this.stack = stack; 18 | // Error.captureStackTrace(this, this.constructor.name); 19 | } 20 | } 21 | 22 | /** 23 | * Class representing an API error. 24 | * @extends ExtendableError 25 | */ 26 | class APIError extends ExtendableError { 27 | /** 28 | * Creates an API error. 29 | * @param {string} message - Error message. 30 | * @param {number} status - HTTP status code of error. 31 | * @param {boolean} isPublic - Whether the message should be visible to user or not. 32 | */ 33 | constructor({ 34 | message, 35 | errors, 36 | stack, 37 | status = httpStatus.INTERNAL_SERVER_ERROR, 38 | isPublic = false, 39 | }) { 40 | super({ 41 | message, errors, status, isPublic, stack, 42 | }); 43 | } 44 | } 45 | 46 | module.exports = APIError; 47 | -------------------------------------------------------------------------------- /src/api/utils/authProviders.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | const axios = require('axios'); 3 | 4 | exports.facebook = async (access_token) => { 5 | const fields = 'id, name, email, picture'; 6 | const url = 'https://graph.facebook.com/me'; 7 | const params = { access_token, fields }; 8 | const response = await axios.get(url, { params }); 9 | const { 10 | id, name, email, picture, 11 | } = response.data; 12 | return { 13 | service: 'facebook', 14 | picture: picture.data.url, 15 | id, 16 | name, 17 | email, 18 | }; 19 | }; 20 | 21 | exports.google = async (access_token) => { 22 | const url = 'https://www.googleapis.com/oauth2/v3/userinfo'; 23 | const params = { access_token }; 24 | const response = await axios.get(url, { params }); 25 | const { 26 | sub, name, email, picture, 27 | } = response.data; 28 | return { 29 | service: 'google', 30 | picture, 31 | id: sub, 32 | name, 33 | email, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/config/express.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const morgan = require('morgan'); 3 | const bodyParser = require('body-parser'); 4 | const compress = require('compression'); 5 | const methodOverride = require('method-override'); 6 | const cors = require('cors'); 7 | const helmet = require('helmet'); 8 | const passport = require('passport'); 9 | const routes = require('../api/routes/v1'); 10 | const { logs } = require('./vars'); 11 | const strategies = require('./passport'); 12 | const error = require('../api/middlewares/error'); 13 | const rateLimiter = require('../api/middlewares/rateLimiter'); 14 | 15 | /** 16 | * Express instance 17 | * @public 18 | */ 19 | const app = express(); 20 | 21 | // request logging. dev: console | production: file 22 | app.use(morgan(logs)); 23 | 24 | // parse body params and attache them to req.body 25 | app.use(bodyParser.json()); 26 | app.use(bodyParser.urlencoded({ extended: true })); 27 | 28 | // gzip compression 29 | app.use(compress()); 30 | 31 | // lets you use HTTP verbs such as PUT or DELETE 32 | // in places where the client doesn't support it 33 | app.use(methodOverride()); 34 | 35 | // secure apps by setting various HTTP headers 36 | app.use(helmet()); 37 | 38 | // enable CORS - Cross Origin Resource Sharing 39 | app.use(cors()); 40 | 41 | // enable authentication 42 | app.use(passport.initialize()); 43 | passport.use('jwt', strategies.jwt); 44 | passport.use('facebook', strategies.facebook); 45 | passport.use('google', strategies.google); 46 | 47 | app.use('/docs', express.static('docs')); 48 | 49 | // enable rate limit 50 | app.use(rateLimiter()); 51 | 52 | // mount api v1 routes 53 | app.use('/v1', routes); 54 | 55 | // if error is not an instanceOf APIError, convert it. 56 | app.use(error.converter); 57 | 58 | // catch 404 and forward to error handler 59 | app.use(error.notFound); 60 | 61 | // error handler, send stacktrace only during development 62 | app.use(error.handler); 63 | 64 | module.exports = app; 65 | -------------------------------------------------------------------------------- /src/config/mongoose.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { mongo, env } = require('./vars'); 3 | 4 | // set mongoose Promise to Bluebird 5 | mongoose.Promise = Promise; 6 | 7 | // Exit application on error 8 | mongoose.connection.on('error', (err) => { 9 | console.error(`MongoDB connection error: ${err}`); 10 | process.exit(-1); 11 | }); 12 | 13 | // print mongoose logs in dev env 14 | if (env === 'development') { 15 | mongoose.set('debug', true); 16 | } 17 | 18 | /** 19 | * Connect to mongo db 20 | * 21 | * @returns {object} Mongoose connection 22 | * @public 23 | */ 24 | exports.connect = () => { 25 | mongoose.connect(mongo.uri, { 26 | keepAlive: 1, 27 | }); 28 | return mongoose.connection; 29 | }; 30 | -------------------------------------------------------------------------------- /src/config/passport.js: -------------------------------------------------------------------------------- 1 | const JwtStrategy = require('passport-jwt').Strategy; 2 | const BearerStrategy = require('passport-http-bearer'); 3 | const { ExtractJwt } = require('passport-jwt'); 4 | const { jwtSecret } = require('./vars'); 5 | const authProviders = require('../api/utils/authProviders'); 6 | const User = require('../api/services/user/user.model'); 7 | 8 | const jwtOptions = { 9 | secretOrKey: jwtSecret, 10 | jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Bearer'), 11 | }; 12 | 13 | const jwt = async (payload, done) => { 14 | try { 15 | const user = await User.findById(payload.sub); 16 | if (user) return done(null, user); 17 | return done(null, false); 18 | } catch (error) { 19 | return done(error, false); 20 | } 21 | }; 22 | 23 | const oAuth = service => async (token, done) => { 24 | try { 25 | const userData = await authProviders[service](token); 26 | const user = await User.oAuthLogin(userData); 27 | return done(null, user); 28 | } catch (err) { 29 | return done(err); 30 | } 31 | }; 32 | 33 | exports.jwt = new JwtStrategy(jwtOptions, jwt); 34 | exports.facebook = new BearerStrategy(oAuth('facebook')); 35 | exports.google = new BearerStrategy(oAuth('google')); 36 | -------------------------------------------------------------------------------- /src/config/vars.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // import .env variables 4 | require('dotenv-safe').load({ 5 | path: path.join(__dirname, '../../.env'), 6 | sample: path.join(__dirname, '../../.env.example'), 7 | }); 8 | 9 | module.exports = { 10 | env: process.env.NODE_ENV, 11 | port: process.env.PORT, 12 | jwtSecret: process.env.JWT_SECRET, 13 | jwtExpirationInterval: process.env.JWT_EXPIRATION_MINUTES, 14 | mongo: { 15 | uri: process.env.NODE_ENV === 'test' ? process.env.MONGO_URI_TESTS : process.env.MONGO_URI, 16 | }, 17 | logs: process.env.NODE_ENV === 'production' ? 'combined' : 'dev', 18 | rateLimitTime: process.env.RATE_LIMIT_TIME, 19 | rateLimitRequest: process.env.RATE_LIMIT_REQUEST, 20 | }; 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // make bluebird default Promise 2 | Promise = require('bluebird'); // eslint-disable-line no-global-assign 3 | const { port, env } = require('./config/vars'); 4 | const app = require('./config/express'); 5 | const mongoose = require('./config/mongoose'); 6 | 7 | // open mongoose connection 8 | mongoose.connect(); 9 | 10 | // listen to requests 11 | app.listen(port, () => console.info(`server started on port ${port} (${env})`)); 12 | 13 | /** 14 | * Exports express 15 | * @public 16 | */ 17 | module.exports = app; 18 | --------------------------------------------------------------------------------