├── .babelrc ├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── app ├── app.js ├── controllers │ ├── api │ │ ├── APIController.js │ │ └── UsersController.js │ └── web │ │ └── HomePageController.js ├── facades │ ├── jwt.facade.js │ └── users.js ├── factories │ ├── errors │ │ ├── CustomError.js │ │ └── index.js │ └── responses │ │ ├── api.js │ │ └── web.js ├── models │ ├── DisabledRefreshToken.js │ ├── User.js │ └── index.js ├── policies │ ├── accessToken.policy.js │ └── refreshToken.policy.js ├── routes │ ├── api │ │ ├── index.js │ │ └── v1 │ │ │ ├── privateRoutes.js │ │ │ └── publicRoutes.js │ ├── index.js │ └── web │ │ ├── index.js │ │ └── publicRoutes.js ├── services │ ├── bcrypt.service.js │ ├── db.service.js │ └── jwt.service.js ├── utils │ └── dates.js └── views │ ├── error.pug │ └── home.pug ├── configs ├── api.js ├── database.js ├── envinorments.js ├── jwt.js └── server.js ├── eslint.config.mjs ├── migrator ├── models.js ├── seeder.js ├── seeds │ └── users.js └── start.js ├── nodemon.json ├── package-lock.json ├── package.json └── public └── .gitkeep /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["babel-plugin-module-resolver", { 4 | "root": ["./"], 5 | "alias": { 6 | "#configs": "./configs", 7 | "#facades": "./app/facades", 8 | "#factories": "./app/factories", 9 | "#models":"./app/models", 10 | "#policies":"./app/policies", 11 | "#routes": "./app/routes", 12 | "#services":"./app/services", 13 | "#utils":"./app/utils" 14 | } 15 | }] 16 | ] 17 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | APP_PORT=3001 3 | 4 | DB_DIALECT= 5 | DB_HOST=localhost 6 | DB_NAME= 7 | DB_USER= 8 | DB_PASSWORD= 9 | DB_PORT=3306 10 | 11 | JWT_ACCESS_SECRET= 12 | JWT_REFRESH_SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/node 2 | 3 | ### Node ### 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 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 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # End of https://www.gitignore.io/api/node 64 | 65 | # MacOS File system 66 | .DS_Store 67 | 68 | # Optional notes 69 | TODO.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present Mark Khramko 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 | # nodejs-express-jwt 2 | 3 | > Express REST API Boilerplate with JWT Authentication and support for MySQL and PostgreSQL. 4 | 5 | - Compilation via [Babel](https://babeljs.io/); 6 | - Authentication via [JWT](https://jwt.io/); 7 | - Routes mapping via [express-routes-mapper](https://github.com/aichbauer/express-routes-mapper); 8 | - Environments for `development`, `testing`, and `production`. 9 | 10 | ## Table of Contents 11 | 12 | - [Version notice](#version-notice) 13 | - [Install & Use](#install-and-use) 14 | - [Controllers](#controllers) 15 | - [Create a Controller](#create-a-controller) 16 | - [Models](#models) 17 | - [Create a Model](#create-a-model) 18 | - [Policies](#policies) 19 | - [Services](#services) 20 | - [Configs](#configs) 21 | - [.env](#.env-file) 22 | - [npm scripts](#npm-scripts) 23 | - [License]() 24 | 25 | ## Version notice 26 | 27 | This project came a long way since the initial release in 2018. If you used this boilerplate before 2021, you should check a [v0.x.x branch](https://github.com/MarkKhramko/nodejs-express-jwt/tree/v0.x.x) and [v0 tags](https://github.com/MarkKhramko/nodejs-express-jwt/releases/tag/v0.0.0) for the latest changes of v0. 28 | 29 | ## Install and Use 30 | 31 | Start by cloning this repository 32 | 33 | ```sh 34 | # HTTPS 35 | $ git clone https://github.com/MarkKhramko/nodejs-express-jwt 36 | ``` 37 | 38 | then use [npm](https://www.npmjs.com/) to 39 | 40 | ```sh 41 | # Enter project root 42 | $ cd nodejs-express-jwt 43 | # Install dependencies 44 | $ npm i 45 | # Copy environment file 46 | $ cp .env.example .env 47 | # Fill .env file 48 | # ... 49 | # If you want to use PostgreSQL (optional) 50 | $ npm install -S pg pg-hstore 51 | # Start the application (without code watcher) 52 | $ npm start 53 | # 54 | # OR 55 | # 56 | # start development with nodemon 57 | $ npm run dev 58 | ``` 59 | 60 | MySQL is supported out of the box as it is the default. 61 | 62 | ## Controllers 63 | 64 | Controllers in this boilerplate have a naming convention: `ModelnameController.js` and uses an object factory pattern. 65 | To use a model inside of your controller you have to require it. 66 | We use [Sequelize](http://docs.sequelizejs.com/) as ORM, if you want further information read the [Docs](http://docs.sequelizejs.com/). 67 | 68 | ### Folder structure 69 | 70 | * Controllers for your main API should be placed inside `/api/` directory; 71 | * Controllers for HTTP requests should be placed inside `/web/` directory. 72 | 73 | ### Create a Controller 74 | 75 | Example Controller for all **CRUD** oparations: 76 | 77 | ```js 78 | const Model = require('#models/Model'); 79 | 80 | model.exports = function ModelController() { 81 | const _create = (req, res) => { 82 | // body is part of a form-data 83 | const { value } = req.body; 84 | 85 | Model 86 | .create({ 87 | key: value 88 | }) 89 | .then((model) => { 90 | if(!model) { 91 | return res.status(400).json({ msg: 'Bad Request: Model not found' }); 92 | } 93 | 94 | return res.status(200).json({ model }); 95 | }) 96 | .catch((err) => { 97 | // better save it to log file 98 | console.error(err); 99 | 100 | return res.status(500).json({ msg: 'Internal server error' }); 101 | }); 102 | }; 103 | 104 | const _getAll = (req, res) => { 105 | Model 106 | .findAll() 107 | .then((models) => { 108 | if(!models){ 109 | return res.status(400).json({ msg: 'Bad Request: Models not found' }); 110 | } 111 | 112 | return res.status(200).json({ models }); 113 | }) 114 | .catch((err) => { 115 | // better save it to log file 116 | console.error(err); 117 | 118 | return res.status(500).json({ msg: 'Internal server error' }); 119 | }); 120 | }; 121 | 122 | const _get = (req, res) => { 123 | // params is part of an url 124 | const { id } = req.params; 125 | 126 | Model 127 | .findOne({ 128 | where: { 129 | id, 130 | }, 131 | }) 132 | .then((model) => { 133 | if(!model) { 134 | return res.status(400).json({ msg: 'Bad Request: Model not found' }); 135 | } 136 | 137 | return res.status(200).json({ model }); 138 | }) 139 | .catch((err) => { 140 | // better save it to log file 141 | console.error(err); 142 | 143 | return res.status(500).json({ msg: 'Internal server error' }); 144 | }); 145 | }; 146 | 147 | const _update = (req, res) => { 148 | // params is part of an url 149 | const { id } = req.params; 150 | 151 | // body is part of form-data 152 | const { value } = req.body; 153 | 154 | Model 155 | .findByPk(id) 156 | .then((model) => { 157 | if(!model) { 158 | return res.status(400).json({ msg: 'Bad Request: Model not found' }); 159 | } 160 | 161 | return model 162 | .update({ 163 | key: value, 164 | }).then((updatedModel) => { 165 | return res.status(200).json({ updatedModel }); 166 | }); 167 | }) 168 | .catch((err) => { 169 | // better save it to log file 170 | console.error(err); 171 | 172 | return res.status(500).json({ msg: 'Internal server error' }); 173 | }); 174 | }; 175 | 176 | const _destroy = (req, res) => { 177 | // params is part of an url 178 | const { id } = req.params; 179 | 180 | Model 181 | .findByPk(id) 182 | .then((model) => { 183 | if(!model) { 184 | return res.status(400).json({ msg: 'Bad Request: Model not found' }) 185 | } 186 | 187 | model.destroy().then(() => { 188 | return res.status(200).json({ msg: 'Successfully destroyed model' }); 189 | }).catch((err) => { 190 | // better save it to log file 191 | console.error(err); 192 | 193 | return res.status(500).json({ msg: 'Internal server error' }); 194 | }); 195 | }) 196 | .catch((err) => { 197 | // better save it to log file 198 | console.error(err); 199 | 200 | return res.status(500).json({ msg: 'Internal server error' }); 201 | }); 202 | }; 203 | 204 | // !IMPORTANT! 205 | // don't forget to return the functions: 206 | return { 207 | create:_create, 208 | getAll:_getAll, 209 | get:_get, 210 | update:_update, 211 | destroy:_destroy 212 | }; 213 | }; 214 | ``` 215 | 216 | ## Models 217 | 218 | Models in this boilerplate have a naming convention: `Model.js` and uses [Sequelize](http://docs.sequelizejs.com/) to define Models, if you want further information read the [Docs](http://docs.sequelizejs.com/). 219 | 220 | ### Create a Model 221 | 222 | Example User Model: 223 | 224 | ```js 225 | const { DataTypes } = require('sequelize'); 226 | const database = require('#services/db.service'); 227 | 228 | // Password hasher. 229 | const bcryptSevice = require('#services/bcrypt.service'); 230 | 231 | 232 | const User = database.define( 233 | 'User', 234 | { 235 | email: { 236 | type: DataTypes.STRING(255), 237 | unique: true, 238 | allowNull: false 239 | }, 240 | password: { 241 | type: DataTypes.STRING(255), 242 | allowNull: false 243 | }, 244 | } 245 | ); 246 | 247 | // Hooks: 248 | User.beforeValidate((user, options) => { 249 | // Hash user's password. 250 | user.password = bcryptSevice.hashPassword(user); 251 | }) 252 | // Hooks\ 253 | 254 | module.exports = User; 255 | ``` 256 | 257 | ## Policies 258 | 259 | Policies are middleware functions that can run before hitting a apecific or more specified route(s). 260 | 261 | 262 | ## Services 263 | 264 | Services are little useful snippets, or calls to another API that are not the main focus of your API. 265 | 266 | Example service: 267 | 268 | Get comments from another API: 269 | 270 | ```js 271 | module.exports = { 272 | getComments:_getComments 273 | }; 274 | 275 | async function _getComments() { 276 | try { 277 | const res = await fetch('https://jsonplaceholder.typicode.com/comments', { 278 | method: 'get' 279 | }); 280 | // do some fancy stuff with the response. 281 | } 282 | catch(error) { 283 | // Process error. 284 | } 285 | } 286 | ``` 287 | 288 | ## Configs 289 | 290 | ### .env file 291 | 292 | #### Database 293 | 294 | Configure the keys with your credentials in `.env` file. 295 | 296 | ``` 297 | DB_DIALECT=mysql 298 | DB_HOST=localhost 299 | DB_NAME=name 300 | DB_USER=root 301 | DB_PASSWORD=root 302 | DB_PORT=3609 303 | ``` 304 | 305 | Default dialect for the application is MySQL. To switch for PostgreSQL, type `DB_DIALECT=postgres` in `.env` file. 306 | 307 | > Note: if you use `mysql` make sure MySQL server is running on the machine 308 | 309 | > Note: to use a postgres run : `npm i -S pg pg-hstore` or `yarn add pg pg-hstore` 310 | 311 | #### JWT 312 | 313 | Set random `secret access keys` for your access and refresh tokens. 314 | 315 | ``` 316 | JWT_ACCESS_SECRET= 317 | JWT_REFRESH_SECRET= 318 | ``` 319 | 320 | ## npm scripts 321 | 322 | ### `npm run dev` 323 | 324 | This is the entry for a developer. This command: 325 | 326 | - runs **nodemon watch task** for the all files conected to `.app/app.js`, except `./public` directory; 327 | - Reads **environment variable** `NODE_ENV` from `.env`; 328 | - Opens the db connection for `development`; 329 | - Starts the server on 127.0.0.1:APP_PORT, 330 | 331 | ### `npm run production` 332 | 333 | This command: 334 | 335 | - Sets the **environment variable** to `production`; 336 | - Opens the db connection for `production`; 337 | - Starts the server on 127.0.0.1:APP_PORT. 338 | 339 | Before running on production you have to set the **environment vaiables**: 340 | 341 | - APP_PORT - Port for your application (usually `80`); 342 | - DB_DIALECT - `mysql` or `postgres`l; 343 | - DB_HOST - Host address of your database; 344 | - DB_NAME - Database name for production; 345 | - DB_USER - Database username for production; 346 | - DB_PASS - Database password for production; 347 | - DB_PORT - Database port for production; 348 | - JWT_ACCESS_SECRET - Secret for JSON web token for direct API requests; 349 | - JWT_REFRESH_SECRET - Secret for JSON web token to renew the Access-JWT. 350 | 351 | ### `npm start` 352 | 353 | This command: 354 | - Opens the db connection for default environment in `.env` file. 355 | - Simply start the server on 127.0.0.1:APP_PORT without a watcher. 356 | 357 | ### `npm run db:migrate` 358 | 359 | This command: 360 | - Ensures that database schemas are equivalent to the ones configured in `/app/models/index.js`. 361 | 362 | ### `npm run db:seed` 363 | 364 | This command: 365 | - Inserts all seeds, configured in `/migrator/seeder.js` into the database. 366 | 367 | 368 | ## LICENSE 369 | 370 | MIT 2018-present. By [Mark Khramko](https://github.com/MarkKhramko) 371 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main application file: 3 | */ 4 | 5 | // Info about current and allowed environments. 6 | const environments = require('#configs/envinorments'); 7 | // Middleware for parsing requests bodies. 8 | const bodyParser = require('body-parser'); 9 | // Express. 10 | const express = require('express'); 11 | const http = require('http'); 12 | // Mild security. 13 | const helmet = require('helmet'); 14 | // Cross-origin requests middleware. 15 | const cors = require('cors'); 16 | 17 | // Server configuration: 18 | // ORM. 19 | const DB = require('#services/db.service'); 20 | // Port info. 21 | const serverConfig = require('#configs/server'); 22 | // Server configuration\ 23 | 24 | // Express application. 25 | const app = express(); 26 | // HTTP server (Do not use HTTPS, manage TLS with some proxy, like Nginx). 27 | const server = http.Server(app); 28 | // Routes. 29 | const routes = require('#routes/'); 30 | 31 | 32 | // Allow cross origin requests 33 | // (configure to only allow requests from certain origins). 34 | app.use(cors()); 35 | 36 | // Set views path. 37 | app.set('views', __dirname+'/views'); 38 | // Set template engine (Pug by default). 39 | app.set('view engine', 'pug'); 40 | // Set folder for static contents. 41 | app.use(express.static('public')); 42 | 43 | // Secure express app. 44 | app.use(helmet({ 45 | dnsPrefetchControl: false, 46 | frameguard: false, 47 | ieNoOpen: false, 48 | })); 49 | 50 | // Parsing the request bodies. 51 | app.use(bodyParser.urlencoded({ extended: false })); 52 | app.use(bodyParser.json()); 53 | 54 | // Setup routes. 55 | app.use(routes({ app })); 56 | 57 | 58 | // Reference to the active database connection. 59 | let db; 60 | 61 | async function _beforeStart() { 62 | if (environments.allowed.indexOf(environments.current) === -1) { 63 | console.error(`NODE_ENV is set to ${environments.current}, but only ${environments.allowed.toString()} are valid.`); 64 | process.exit(1); 65 | } 66 | 67 | // Start ORM. 68 | db = await DB.service(environments.current); 69 | db.start(); 70 | 71 | return Promise.resolve(); 72 | } 73 | 74 | // Initialize server: 75 | _beforeStart() 76 | .then(() => { 77 | server.listen(serverConfig.port, () => { 78 | // Server is up! 79 | console.info(`Server is running on port: ${serverConfig.port}`); 80 | }); 81 | }) 82 | .catch((error) => { 83 | console.error('Could not start server:', error); 84 | }); 85 | // Initialize server\ 86 | 87 | // Handle process errors: 88 | process.on('unhandledRejection', (reason, p) => { 89 | console.error(reason, 'Unhandled Rejection at Promise', p); 90 | }); 91 | 92 | process.on('uncaughtException', (error) => { 93 | console.error(error, 'Uncaught Exception thrown'); 94 | 95 | _gracefulShutdown(true); 96 | }); 97 | 98 | function _gracefulShutdown(exit=false) { 99 | console.warn('Received SIGINT or SIGTERM. Shutting down gracefully...'); 100 | const exitCode = exit ? 1 : 0; 101 | 102 | server.close(() => { 103 | console.info('Closed out remaining connections.'); 104 | process.exit(exitCode); 105 | }); 106 | 107 | // Force stop after 5 seconds: 108 | setTimeout(() => { 109 | console.warn('Could not close HTTP connections in time, forcefully shutting down'); 110 | process.exit(exitCode); 111 | }, 5*1000); 112 | } 113 | // Handle process errors\ 114 | -------------------------------------------------------------------------------- /app/controllers/api/APIController.js: -------------------------------------------------------------------------------- 1 | // Reponse protocols. 2 | const { 3 | createOKResponse, 4 | createErrorResponse 5 | } = require('#factories/responses/api'); 6 | 7 | 8 | module.exports = APIController; 9 | 10 | function APIController() { 11 | 12 | const _processError = (error, req, res) => { 13 | // Default error message. 14 | let errorMessage = error?.message ?? 'Internal server error'; 15 | // Default HTTP status code. 16 | let statusCode = 500; 17 | 18 | switch(error.name) { 19 | case('TypeError'): 20 | errorMessage = 'Type error. Check your console for details.'; 21 | statusCode = 402; 22 | break; 23 | 24 | // Perform your custom processing here... 25 | 26 | default: 27 | break; 28 | } 29 | 30 | // Send error response with provided status code. 31 | return createErrorResponse({ 32 | res, 33 | error: { 34 | message: errorMessage 35 | }, 36 | status: statusCode 37 | }); 38 | } 39 | 40 | const _getStatus = (req, res) => { 41 | try { 42 | // Try making some faulty operation here, 43 | // to see how error will be displayed: 44 | 45 | // Like this TypeError. 46 | // ({}).test(); 47 | 48 | // Otherwise it will successfully send operational status. 49 | return createOKResponse({ 50 | res, 51 | content:{ 52 | operational: true, 53 | message: 'API is fully functional!' 54 | } 55 | }); 56 | } 57 | catch(error) { 58 | console.error("APIController._getStatus error: ", error); 59 | return _processError(error, req, res); 60 | } 61 | } 62 | 63 | return { 64 | getStatus: _getStatus 65 | } 66 | } -------------------------------------------------------------------------------- /app/controllers/api/UsersController.js: -------------------------------------------------------------------------------- 1 | // Facades: 2 | const usersFacade = require('#facades/users'); 3 | const jwtFacade = require('#facades/jwt.facade'); 4 | // JWT Service. 5 | const JWT = require('#services/jwt.service'); 6 | // Reponse protocols. 7 | const { 8 | createOKResponse, 9 | createErrorResponse 10 | } = require('#factories/responses/api'); 11 | // Custom error. 12 | const { Err } = require('#factories/errors'); 13 | 14 | 15 | module.exports = UsersController; 16 | 17 | function UsersController() { 18 | 19 | const _processError = (error, req, res) => { 20 | // Default error message. 21 | let errorMessage = error?.message ?? 'Internal server error'; 22 | // Default HTTP status code. 23 | let statusCode = 500; 24 | 25 | switch(error.name) { 26 | case('Unauthorized'): 27 | errorMessage = 'Email or password are incorrect.'; 28 | statusCode = 406; 29 | break; 30 | case('ValidationError'): 31 | errorMessage = "Invalid email OR password input"; 32 | statusCode = 401; 33 | break; 34 | case('InvalidToken'): 35 | errorMessage = 'Invalid token or token expired'; 36 | statusCode = 401; 37 | break; 38 | case('UserNotFound'): 39 | errorMessage = "Such user doesn't exist"; 40 | statusCode = 400; 41 | break; 42 | 43 | // Perform your custom processing here... 44 | 45 | default: 46 | break; 47 | } 48 | 49 | // Send error response with provided status code. 50 | return createErrorResponse({ 51 | res, 52 | error: { 53 | message: errorMessage 54 | }, 55 | status: statusCode 56 | }); 57 | } 58 | 59 | // Auth: 60 | const _register = async (req, res) => { 61 | try { 62 | // Extract request input: 63 | const email = req.body?.email 64 | const password = req.body?.password 65 | const firstName = req.body?.firstName 66 | const lastName = req.body?.lastName 67 | 68 | // Create new one. 69 | const [ tokens, user ] = await usersFacade.register({ 70 | email, 71 | password, 72 | firstName, 73 | lastName 74 | }); 75 | 76 | // Everything's fine, send response. 77 | return createOKResponse({ 78 | res, 79 | content:{ 80 | tokens, 81 | // Convert user to JSON, to clear sensitive data (like password) 82 | user:user.toJSON() 83 | } 84 | }); 85 | } 86 | catch(error) { 87 | console.error("UsersController._create error: ", error); 88 | return _processError(error, req, res); 89 | } 90 | } 91 | 92 | const _login = async (req, res) => { 93 | try { 94 | // Extract request input: 95 | const email = req.body?.email 96 | const password = req.body?.password 97 | 98 | 99 | if (!email || email === undefined || !password || password === undefined) { 100 | // If bad input, throw ValidationError: 101 | const err = new Error("Invalid email OR password input"); 102 | err.name = "ValidationError"; 103 | throw err; 104 | } 105 | 106 | const [ tokens, user ] = await usersFacade.login({ email, password }); 107 | 108 | // Everything's fine, send response. 109 | return createOKResponse({ 110 | res, 111 | content:{ 112 | tokens, 113 | // Convert user to JSON, to clear sensitive data (like password). 114 | user: user.toJSON() 115 | } 116 | }); 117 | } 118 | catch(error){ 119 | console.error("UsersController._login error: ", error); 120 | return _processError(error, req, res); 121 | } 122 | } 123 | 124 | const _validate = async (req, res) => { 125 | try { 126 | const { token } = req.body; 127 | 128 | // Validate token against local seed. 129 | await JWT.verifyAccessToken(token); 130 | 131 | // Everything's fine, send response. 132 | return createOKResponse({ 133 | res, 134 | content:{ 135 | isValid: true, 136 | message: "Valid Token" 137 | } 138 | }); 139 | } 140 | catch(error) { 141 | console.error("UsersController._validate error: ", error); 142 | 143 | // In any error case, we send token not valid: 144 | // Create custom error with name InvalidToken. 145 | const err = new Error('Invalid Token!'); 146 | err.name = "InvalidToken"; 147 | return _processError(err, req, res); 148 | } 149 | } 150 | 151 | const _refresh = async (req, res) => { 152 | try { 153 | // Unwrap refresh token. 154 | const refreshToken = req?.refreshToken; 155 | if (!refreshToken){ 156 | const err = new Err("No refreshToken found"); 157 | err.name = "Unauthorized"; 158 | err.status = 401; 159 | throw err; 160 | } 161 | 162 | // Everything's ok, issue new one. 163 | const [ accessToken ] = await jwtFacade.refreshAccessToken({ refreshToken }); 164 | 165 | return createOKResponse({ 166 | res, 167 | content:{ 168 | token: accessToken 169 | } 170 | }); 171 | } 172 | catch(error) { 173 | console.error("UsersController._refresh error: ", error); 174 | 175 | // In any error case, we send token not valid: 176 | // Create custom error with name InvalidToken. 177 | const err = new Error('Invalid Token!'); 178 | err.name = "InvalidToken"; 179 | return _processError(err, req, res); 180 | } 181 | } 182 | 183 | const _logout = async (req, res) => { 184 | try { 185 | const refreshToken = req?.refreshToken; 186 | if (!refreshToken){ 187 | const err = new Err("No refreshToken found"); 188 | err.name = "Unauthorized"; 189 | err.status = 401; 190 | throw err; 191 | } 192 | 193 | // Everything's ok, destroy token. 194 | const [ status ] = await jwtFacade.disableRefreshToken({ refreshToken }); 195 | 196 | return createOKResponse({ 197 | res, 198 | content:{ 199 | status, 200 | loggedIn: status === true 201 | } 202 | }); 203 | } 204 | catch(error) { 205 | console.error("UsersController._logout error: ", error); 206 | 207 | // In any error case, we send token not valid: 208 | // Create custom error with name InvalidToken. 209 | const err = new Error('Invalid Token!'); 210 | err.name = "InvalidToken"; 211 | return _processError(err, req, res); 212 | } 213 | } 214 | // Auth\ 215 | 216 | // Protected: 217 | const _getFullName = async (req, res) => { 218 | try { 219 | // Unwrap user's id. 220 | const userId = req?.token?.id; 221 | 222 | // Try to get full name. 223 | const [ fullName ] = await usersFacade.getFullName({ userId }); 224 | 225 | console.log({ fullName }); 226 | 227 | return createOKResponse({ 228 | res, 229 | content:{ 230 | fullName 231 | } 232 | }); 233 | } 234 | catch(error) { 235 | console.error("UsersController._getFullName error: ", error); 236 | return _processError(error, req, res); 237 | } 238 | } 239 | 240 | return { 241 | // Auth: 242 | register: _register, 243 | login: _login, 244 | validate: _validate, 245 | refresh: _refresh, 246 | logout: _logout, 247 | 248 | // Protected: 249 | getFullName:_getFullName 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /app/controllers/web/HomePageController.js: -------------------------------------------------------------------------------- 1 | // Reponse protocols. 2 | const { 3 | createOKResponse, 4 | createErrorResponse 5 | } = require('#factories/responses/web'); 6 | 7 | const HomePageController = () => { 8 | 9 | const _getHomePage = (req, res) =>{ 10 | try { 11 | // Try making some faulty operation here, 12 | // to see how error will be displayed: 13 | 14 | // Like this TypeError. 15 | // ({}).test(); 16 | 17 | // Otherwise it will successfully render home page. 18 | return createOKResponse(res, 'home'); 19 | } 20 | catch(error) { 21 | console.error("HomePageController._getHomePage error:", error); 22 | return createErrorResponse(res, error); 23 | } 24 | } 25 | 26 | return { 27 | getHomePage: _getHomePage 28 | } 29 | } 30 | 31 | module.exports = HomePageController; -------------------------------------------------------------------------------- /app/facades/jwt.facade.js: -------------------------------------------------------------------------------- 1 | // Reference models. 2 | const DisabledRefreshToken = require('#models/DisabledRefreshToken'); 3 | // JWT service. 4 | const JWT = require('#services/jwt.service'); 5 | // Custom error. 6 | const { Err } = require('#factories/errors'); 7 | 8 | 9 | module.exports = { 10 | issueAccessToken: _issueAccessToken, 11 | issueTokens: _issueTokens, 12 | 13 | isRefreshTokenActive: _isRefreshTokenActive, 14 | refreshAccessToken: _refreshAccessToken, 15 | 16 | disableRefreshToken: _disableRefreshToken, 17 | 18 | // Add your methods here... 19 | } 20 | 21 | async function _issueAccessToken({ refreshToken, user }){ 22 | try { 23 | let newAccessToken = null; 24 | 25 | // If refresh token was provided: 26 | if (refreshToken) { 27 | const payload = { 28 | id:refreshToken?.id, 29 | roles:refreshToken?.roles ?? [] 30 | }; 31 | newAccessToken = await JWT.issueAccessToken(payload); 32 | } 33 | // If user was provided: 34 | else if (user) { 35 | const payload = { id:user?.id }; 36 | newAccessToken = await JWT.issueAccessToken(payload); 37 | } 38 | else { 39 | const err = new Err('No "user" or "refreshToken" provided for JWT issue.'); 40 | err.name = "ValidationError"; 41 | err.status = 403; 42 | throw err; 43 | } 44 | 45 | // Check if issue was successful. 46 | if (!newAccessToken){ 47 | const err = new Err("Could not issue new access token."); 48 | err.status = 401; 49 | throw err; 50 | } 51 | 52 | // Send output. 53 | return Promise.resolve([ 54 | newAccessToken 55 | ]); 56 | } 57 | catch(error) { 58 | return Promise.reject(error); 59 | } 60 | } 61 | 62 | async function _issueTokens({ user }) { 63 | try { 64 | // Prepare payload container. 65 | let payload = {}; 66 | 67 | if (user) { 68 | payload = { id:user?.id }; 69 | } 70 | else { 71 | const err = new Err('No "user" provided for JWT issue.'); 72 | err.name = "ValidationError"; 73 | err.status = 403; 74 | throw err; 75 | } 76 | 77 | const [ accessToken ] = await JWT.issueAccessToken(payload); 78 | const [ refreshToken ] = await JWT.issueRefreshToken(payload); 79 | 80 | // Prepare output, 81 | const tokens = { 82 | accessToken, 83 | refreshToken 84 | }; 85 | // Send output. 86 | return Promise.resolve([ 87 | tokens 88 | ]); 89 | } 90 | catch(error) { 91 | return Promise.reject(error); 92 | } 93 | } 94 | 95 | async function _refreshAccessToken({ refreshToken }) { 96 | try { 97 | // Issue new access token, based on refresh token. 98 | const [ accessToken ] = await _issueAccessToken({ refreshToken }); 99 | 100 | // Send output. 101 | return Promise.resolve([ 102 | accessToken 103 | ]); 104 | } 105 | catch(error){ 106 | return Promise.reject(error); 107 | } 108 | } 109 | 110 | async function _isRefreshTokenActive({ refreshToken }) { 111 | try { 112 | // Unwrap nessessary data. 113 | const { token } = refreshToken; 114 | 115 | const foundTokens = await DisabledRefreshToken.selectAll({ token }); 116 | 117 | // Prepare output. Check if provided token was not disabled. 118 | const isActive = foundTokens.length === 0; 119 | 120 | // Send output. 121 | return Promise.resolve([ isActive ]); 122 | } 123 | catch(error) { 124 | return Promise.reject(error); 125 | } 126 | } 127 | 128 | async function _disableRefreshToken({ refreshToken }) { 129 | try { 130 | // Unwrap nessessary data. 131 | const { id, token } = refreshToken; 132 | 133 | // Find or create. 134 | const [ disabledRefreshToken, created ] = await DisabledRefreshToken.createOrFind({ 135 | userId:id, 136 | token 137 | }); 138 | 139 | // Check result, 140 | const createdStatus = created === true || !!disabledRefreshToken; 141 | 142 | // Send output. 143 | return Promise.resolve([ createdStatus ]); 144 | } 145 | catch(error) { 146 | return Promise.reject(error); 147 | } 148 | } -------------------------------------------------------------------------------- /app/facades/users.js: -------------------------------------------------------------------------------- 1 | // Reference models. 2 | const User = require('#models/User'); 3 | // JWT facade. 4 | const JWT = require('#facades/jwt.facade'); 5 | // Password hash and compare service. 6 | const bcrypt = require('#services/bcrypt.service'); 7 | // Custom error. 8 | const { Err } = require('#factories/errors'); 9 | 10 | 11 | module.exports = { 12 | // Auth: 13 | register: _register, 14 | login: _login, 15 | // Auth\ 16 | 17 | // Private: 18 | getFullName: _getFullName 19 | 20 | // Add your methods here... 21 | 22 | // Private\ 23 | } 24 | 25 | // Auth: 26 | async function _register({ email, password }) { 27 | try{ 28 | // Try to create new user. 29 | const user = await User.create({ 30 | email, 31 | password 32 | }); 33 | 34 | // Issue new access and refresh JWT. 35 | const [ tokens ] = await JWT.issueTokens({ user }); 36 | 37 | // Prepare output. 38 | const result = [ 39 | tokens, 40 | user 41 | ]; 42 | // Send output. 43 | return Promise.resolve(result); 44 | } 45 | catch(error){ 46 | return Promise.reject(error); 47 | } 48 | } 49 | 50 | async function _login({ email, password }) { 51 | try{ 52 | // Try to find user. 53 | const user = await User.findOneByEmail(email); 54 | 55 | if (!user) { 56 | // If no such user was found, throw error with name UserNotFound: 57 | const err = new Err('User not found'); 58 | err.name = "UserNotFound"; 59 | throw err; 60 | } 61 | 62 | if (!bcrypt.comparePasswords(password, user.password)) { 63 | // Validation failed, 64 | // throw custom error with name Unauthorized: 65 | const err = new Err(`Validation failed.`); 66 | err.name = "ValidationError"; 67 | throw err; 68 | } 69 | 70 | // Issue new access and refresh JWT. 71 | const [ tokens ] = await JWT.issueTokens({ user }); 72 | 73 | // Prepare output. 74 | const result = [ 75 | tokens, 76 | user 77 | ]; 78 | // Send output. 79 | return Promise.resolve(result); 80 | } 81 | catch(error){ 82 | return Promise.reject(error); 83 | } 84 | } 85 | // Auth\ 86 | 87 | // Private: 88 | async function _getFullName({ userId }) { 89 | try{ 90 | // Try to find user. 91 | const user = await User.findById(userId); 92 | 93 | if (!user) { 94 | // If no such user was found, throw error with name UserNotFound: 95 | const err = new Err('User not found'); 96 | err.name = "UserNotFound"; 97 | throw err; 98 | } 99 | 100 | // Get value of virtual field 'fullName'. 101 | const fullName = user.fullName; 102 | 103 | // Send output. 104 | return Promise.resolve([ fullName ]); 105 | } 106 | catch(error){ 107 | return Promise.reject(error); 108 | } 109 | } 110 | // Private\ 111 | -------------------------------------------------------------------------------- /app/factories/errors/CustomError.js: -------------------------------------------------------------------------------- 1 | class CustomError extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = this.constructor.name; 5 | this.status = 500; 6 | 7 | // Remove constructor info from stack. 8 | Error.captureStackTrace(this, this.constructor); 9 | } 10 | 11 | replicate(originalError) { 12 | this.name = originalError?.name ?? this.name; 13 | this.code = originalError?.code ?? this.code; 14 | this.status = originalError?.status ?? this.status; 15 | 16 | // Append stack from original error. 17 | const messageLines = (this.message.match(/\n/g)||[]).length + 1 18 | this.stack = this.stack.split('\n').slice(0, messageLines+1).join('\n') + '\n' + originalError.stack; 19 | } 20 | } 21 | 22 | module.exports = CustomError; -------------------------------------------------------------------------------- /app/factories/errors/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Add all your custom errors in this file. 3 | */ 4 | const Err = require("./CustomError"); 5 | 6 | module.exports = { 7 | Err 8 | } -------------------------------------------------------------------------------- /app/factories/responses/api.js: -------------------------------------------------------------------------------- 1 | const FORMATS = { 2 | JSON: "JSON", 3 | XML: "XML" 4 | } 5 | 6 | 7 | module.exports = { 8 | createOKResponse: _createOKResponse, 9 | createErrorResponse: _createErrorResponse 10 | } 11 | 12 | /* 13 | Format for all API responses will be JSON 14 | { 15 | content: {...} 16 | error: {...} 17 | } 18 | Status code is sent in header. 19 | 20 | If error is not present, error should be null. 21 | If error is present, content can be null (But it's not required). 22 | */ 23 | function _createGenericResponse(options={ res:null, status:200, content:{}, error:null, format:FORMATS.JSON }) { 24 | try{ 25 | const data = { 26 | content: options?.content ?? null, 27 | error: options?.error ?? null 28 | }; 29 | 30 | switch(options?.format){ 31 | case FORMATS.JSON: 32 | return options?.res.status(options?.status).json(data); 33 | case FORMATS.XML: 34 | break; 35 | default:{ 36 | const err = new Error("No response format specified."); 37 | throw err; 38 | } 39 | } 40 | } 41 | catch(error){ 42 | const err = new Error(`Could not create generic response: ${error.message}`); 43 | err.name = error?.name; 44 | err.code = error?.code; 45 | throw err; 46 | } 47 | } 48 | 49 | /** 50 | * Sends response with status code 200. 51 | * Should be called on all successful respones. 52 | * 53 | * @param res 54 | * @param content 55 | * @param format 56 | */ 57 | function _createOKResponse(options) { 58 | return _createGenericResponse({ 59 | ...options, 60 | status:200, 61 | format:options?.format ?? FORMATS.JSON 62 | }); 63 | } 64 | 65 | /** 66 | * Sends response with provided error code. 67 | * Should be called on all failed respones. 68 | * 69 | * @param res 70 | * @param error 71 | * @param content (optional) 72 | * @param status 73 | * @param format 74 | */ 75 | function _createErrorResponse(options) { 76 | return _createGenericResponse({ 77 | ...options, 78 | status:options?.status ?? 500, 79 | format:options?.format ?? FORMATS.JSON 80 | }); 81 | } 82 | 83 | -------------------------------------------------------------------------------- /app/factories/responses/web.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createOKResponse: _createOKResponse, 3 | createErrorResponse: _createErrorResponse 4 | } 5 | 6 | /** 7 | * Sends rendererd HTML view with status code 200. 8 | * Should be called on all successful respones. 9 | * 10 | * @param res 11 | * @param viewName 12 | * @param params 13 | */ 14 | function _createOKResponse(res=null, viewName=null, viewParams={}) { 15 | return res.render(viewName, viewParams); 16 | } 17 | 18 | /** 19 | * Sends response with provided error code. 20 | * Should be called on all failed respones. 21 | * 22 | * @param res 23 | * @param error 24 | * @param code 25 | */ 26 | function _createErrorResponse(res, error={}, code=500) { 27 | const statusCode = error?.status ?? code; 28 | const viewParams = { 29 | title: `${statusCode} error`, 30 | heading: `${statusCode} | ${error.message}.` 31 | }; 32 | return res.render('error', viewParams); 33 | } -------------------------------------------------------------------------------- /app/models/DisabledRefreshToken.js: -------------------------------------------------------------------------------- 1 | // ORM: 2 | const { DataTypes } = require('sequelize'); 3 | const database = require('#services/db.service'); 4 | 5 | 6 | const DisabledRefreshToken = database.define( 7 | 'DisabledRefreshToken', 8 | { 9 | token: { 10 | type: DataTypes.STRING, 11 | required: true, 12 | allowNull: false, 13 | unique: true 14 | }, 15 | UserId: { 16 | type: DataTypes.INTEGER, 17 | required: true, 18 | allowNull: false 19 | } 20 | }, 21 | { 22 | // Enable automatic 'createdAt' and 'updatedAt' fields. 23 | timestamps: true, 24 | // Only allow 'soft delete' 25 | // (set of 'deletedAt' field, insted of the real deletion). 26 | paranoid: true 27 | } 28 | ); 29 | 30 | // Static methods: 31 | DisabledRefreshToken.associate = models => { 32 | models.DisabledRefreshToken.belongsTo(models.User, { 33 | foreignKey: 'UserId', 34 | as: 'user' 35 | }); 36 | } 37 | 38 | DisabledRefreshToken.createOrFind = function({ token, userId }) { 39 | const where = { 40 | token 41 | }; 42 | 43 | const defaults = { 44 | token: token, 45 | UserId: userId 46 | }; 47 | 48 | const query = { 49 | where, 50 | defaults 51 | }; 52 | return this.findOrCreate(query); 53 | } 54 | 55 | DisabledRefreshToken.selectAll = function({ token }) { 56 | const where = { 57 | token 58 | }; 59 | const query = { where }; 60 | return this.findAll(query); 61 | } 62 | // Static methods\ 63 | 64 | // Instance methods: 65 | DisabledRefreshToken.prototype.toJSON = function() { 66 | const values = Object.assign({}, this.get()); 67 | return values; 68 | } 69 | // Instance methods\ 70 | 71 | module.exports = DisabledRefreshToken; 72 | -------------------------------------------------------------------------------- /app/models/User.js: -------------------------------------------------------------------------------- 1 | // ORM: 2 | const { DataTypes } = require('sequelize'); 3 | const database = require('#services/db.service'); 4 | 5 | // Password hasher. 6 | const bcryptSevice = require('#services/bcrypt.service'); 7 | 8 | 9 | const User = database.define( 10 | 'User', 11 | { 12 | email: { 13 | type: DataTypes.STRING(255), 14 | unique: true, 15 | allowNull: false 16 | }, 17 | password: { 18 | type: DataTypes.STRING(255), 19 | allowNull: false 20 | }, 21 | 22 | firstName: { 23 | type: DataTypes.STRING(80), 24 | allowNull: true 25 | }, 26 | lastName: { 27 | type: DataTypes.STRING(175), 28 | allowNull: true 29 | }, 30 | 31 | // Example of virtual field: 32 | fullName: { 33 | type: DataTypes.VIRTUAL, 34 | get: function() { 35 | const firstName = this.getDataValue('firstName'); 36 | const lastName = this.getDataValue('lastName'); 37 | return `${(firstName || '').trim()} ${(lastName || '').trim()}`.trim(); 38 | } 39 | } 40 | }, 41 | { 42 | // Enable automatic 'createdAt' and 'updatedAt' fields. 43 | timestamps: true, 44 | // Only allow 'soft delete' 45 | // (set of 'deletedAt' field, insted of the real deletion). 46 | paranoid: true 47 | } 48 | ); 49 | 50 | // Hooks: 51 | User.beforeValidate((user, options) => { 52 | // Hash user's password. 53 | user.password = bcryptSevice.hashPassword(user); 54 | }) 55 | // Hooks\ 56 | 57 | // Static methods: 58 | User.associate = (models) => { 59 | models.User.hasMany(models.DisabledRefreshToken, { 60 | foreignKey: 'UserId', 61 | as: 'disabledRefreshTokens' 62 | }); 63 | } 64 | 65 | User.findById = function(id) { 66 | return this.findByPk(id); 67 | } 68 | 69 | User.findOneByEmail = function(email) { 70 | const query = { 71 | where: { 72 | email 73 | } 74 | }; 75 | return this.findOne(query); 76 | } 77 | // Static methods\ 78 | 79 | // Instance methods: 80 | User.prototype.toJSON = function() { 81 | const values = { ...this.get() }; 82 | delete values.password; 83 | return values; 84 | } 85 | // Instance methods\ 86 | 87 | module.exports = User; 88 | -------------------------------------------------------------------------------- /app/models/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Import all models, that you want to use in application. 3 | */ 4 | require('./User'); 5 | require('./DisabledRefreshToken'); 6 | // Add your models here ... -------------------------------------------------------------------------------- /app/policies/accessToken.policy.js: -------------------------------------------------------------------------------- 1 | // JWT Service. 2 | const JWT = require('#services/jwt.service'); 3 | // Reponse protocols. 4 | const { createErrorResponse } = require('#factories/responses/api'); 5 | // Custom error. 6 | const { Err } = require('#factories/errors'); 7 | 8 | 9 | // Format of token: "Authorization: Bearer [token]" 10 | const ACCESS_TOKEN_NAME = 'Authorization'; 11 | 12 | module.exports = async (req, res, next) => { 13 | try { 14 | let tokenToVerify; 15 | 16 | // Check token in Header: 17 | if (req.header(ACCESS_TOKEN_NAME)) { 18 | const parts = req.header(ACCESS_TOKEN_NAME).split(' '); 19 | 20 | if (parts.length === 2 && /^Bearer$/.test(parts[0])) { 21 | tokenToVerify = parts[1]; 22 | } 23 | else { 24 | const err = new Err(`Format for ${ACCESS_TOKEN_NAME}: Bearer [token]`); 25 | err.status = 401; 26 | throw err; 27 | } 28 | } 29 | // Check token in query: 30 | else if (req.query.token) { 31 | tokenToVerify = req.query.token; 32 | delete req.body.token; 33 | } 34 | // Check token in body: 35 | else if (req.body.token) { 36 | tokenToVerify = req.body.token; 37 | delete req.query.token; 38 | } 39 | else { 40 | const err = new Err(`No ${ACCESS_TOKEN_NAME} was found`); 41 | err.status = 401; 42 | throw err; 43 | } 44 | 45 | const [ parsedToken ] = await JWT.verifyAccessToken(tokenToVerify); 46 | 47 | // Everything's good, procceed: 48 | req.token = parsedToken; 49 | return next(); 50 | } 51 | catch(error) { 52 | // If error is not our custom error, log it. 53 | if (error.name !== Err.name) 54 | console.error('refreshToken.policy error:', error); 55 | else 56 | error.name = 'ValidationError'; 57 | 58 | return createErrorResponse({ 59 | res, 60 | error, 61 | status: error?.statusCode ?? 401 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/policies/refreshToken.policy.js: -------------------------------------------------------------------------------- 1 | // JWT service. 2 | const JWT = require('#services/jwt.service'); 3 | // JWT facade. 4 | const { isRefreshTokenActive } = require('#facades/jwt.facade'); 5 | // Response protocol. 6 | const { createErrorResponse } = require("#factories/responses/api"); 7 | // Custom error. 8 | const { Err } = require('#factories/errors'); 9 | 10 | 11 | const REFRESH_TOKEN_NAME = 'x-refresh-token'; 12 | 13 | module.exports = async (req, res, next) => { 14 | try { 15 | let tokenToVerify; 16 | 17 | // Check token in Header: 18 | if (req.header(REFRESH_TOKEN_NAME)) { 19 | tokenToVerify = req.header(REFRESH_TOKEN_NAME); 20 | } 21 | else { 22 | const err = new Err(`No ${REFRESH_TOKEN_NAME} was found`); 23 | err.status = 401; 24 | throw err; 25 | } 26 | 27 | // Check token against local seed. 28 | const [ parsedToken ] = await JWT.verifyRefreshToken(tokenToVerify); 29 | 30 | // Construct full refresh token. 31 | const refreshToken = { ...parsedToken, token:tokenToVerify }; 32 | 33 | // If token is not in active tokens, then it has been disabled: 34 | const [ isActive ] = await isRefreshTokenActive({ refreshToken }); 35 | if (!isActive) { 36 | const err = new Err("Token is disabled"); 37 | err.status = 401; 38 | throw err; 39 | } 40 | 41 | // Everything's good, procceed: 42 | req.refreshToken = refreshToken; 43 | return next(); 44 | } 45 | catch(error) { 46 | // If error is not our custom error, log it. 47 | if (error.name !== Err.name) 48 | console.error("refreshToken.policy error:", error); 49 | else 50 | error.name = 'ValidationError'; 51 | 52 | return createErrorResponse({ 53 | res, 54 | error, 55 | status: error?.status ?? 401 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/routes/api/index.js: -------------------------------------------------------------------------------- 1 | module.exports = versionString => ({ 2 | private: require(`./${versionString}/privateRoutes`), 3 | public: require(`./${versionString}/publicRoutes`) 4 | }); -------------------------------------------------------------------------------- /app/routes/api/v1/privateRoutes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'GET /users/name': 'UsersController.getFullName', 3 | }; 4 | -------------------------------------------------------------------------------- /app/routes/api/v1/publicRoutes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'GET /status': 'APIController.getStatus', 3 | 4 | // User: 5 | 'POST /auth/register': 'UsersController.register', 6 | 'POST /auth/login': 'UsersController.login', 7 | 'POST /auth/validate': 'UsersController.validate', 8 | 'POST /auth/refresh': 'UsersController.refresh', 9 | 'POST /auth/logout': 'UsersController.logout', 10 | }; 11 | -------------------------------------------------------------------------------- /app/routes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware for app routes initialization. 3 | */ 4 | 5 | // API options. 6 | const apiOptions = require('#configs/api'); 7 | // Routes: 8 | const apiRoutes = require('#routes/api'); 9 | const webRoutes = require('#routes/web'); 10 | // Policies: 11 | const accessTokenMiddleware = require('#policies/accessToken.policy'); 12 | const refreshTokenMiddleware = require('#policies/refreshToken.policy'); 13 | // Mapper of routes to controllers. 14 | const mapRoutes = require('express-routes-mapper'); 15 | 16 | 17 | module.exports = _setUpRoutes; 18 | 19 | function _setUpRoutes(options={}) { 20 | try { 21 | const app = options?.app; 22 | 23 | apiOptions.versions.all.map(versionString => { 24 | // Secure private API routes with JWT access token middleware. 25 | app.all(`/api/${versionString}/private/*`, accessTokenMiddleware); 26 | 27 | // Secure refresh route and logout with JWT refresh token middleware: 28 | app.use(`/api/${versionString}/auth/refresh`, refreshTokenMiddleware); 29 | app.use(`/api/${versionString}/auth/logout`, refreshTokenMiddleware); 30 | 31 | 32 | // Set API routes for express application 33 | app.use(`/api/${versionString}`, mapRoutes(apiRoutes(versionString).public, 'app/controllers/api/')); 34 | app.use(`/api/${versionString}/private`, mapRoutes(apiRoutes(versionString).private, 'app/controllers/api/')); 35 | }); 36 | 37 | // Set web routes for Express appliction. 38 | app.use('/', mapRoutes(webRoutes.public, `app/controllers/web/`)); 39 | 40 | // Everything's ok, continue. 41 | return (req, res, next)=>next(); 42 | } 43 | catch(error) { 44 | const err = new Error(`Could not setup routes: ${error.message}`); 45 | err.name = error?.name; 46 | err.code = error?.code; 47 | throw err; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/routes/web/index.js: -------------------------------------------------------------------------------- 1 | const publicRoutes = require('./publicRoutes'); 2 | 3 | module.exports = { 4 | public:publicRoutes 5 | } -------------------------------------------------------------------------------- /app/routes/web/publicRoutes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'GET /': 'HomePageController.getHomePage' 3 | }; -------------------------------------------------------------------------------- /app/services/bcrypt.service.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | 3 | 4 | module.exports = { 5 | hashPassword: (user) => { 6 | const salt = bcrypt.genSaltSync(); 7 | const hash = bcrypt.hashSync(user.password, salt); 8 | 9 | return hash; 10 | }, 11 | comparePasswords: (password, hash) => bcrypt.compareSync(password, hash) 12 | } 13 | -------------------------------------------------------------------------------- /app/services/db.service.js: -------------------------------------------------------------------------------- 1 | // ORM. 2 | const Sequelize = require('sequelize'); 3 | // Connection configs. 4 | const Configs = require('#configs/database'); 5 | 6 | 7 | // Make first database connection. 8 | const connection = new Sequelize( 9 | Configs.database, 10 | Configs.username, 11 | Configs.password, 12 | { 13 | host: Configs.host, 14 | port: Configs.port, 15 | dialect: Configs.dialect, 16 | pool: Configs.pool, 17 | charset: Configs.charset, 18 | collate: Configs.collate, 19 | timestamps: Configs.timestamps, 20 | logging: Configs.logging 21 | } 22 | ); 23 | 24 | module.exports = connection; 25 | module.exports.service = DBService; 26 | module.exports.migrate = _migrate; 27 | 28 | function DBService() { 29 | 30 | const _authenticateDB = () => ( 31 | connection.authenticate() 32 | ); 33 | 34 | const _start = async () => { 35 | try{ 36 | // Test database connection. 37 | _authenticateDB(); 38 | 39 | // Include all models for associations. 40 | require('#models/'); 41 | 42 | // Get newly included models from db connection. 43 | const models = connection.models; 44 | 45 | // Associate all models with each other. 46 | await _associateModels(models); 47 | 48 | console.info(`Database info: ${Object.keys(models).length} models associated.`); 49 | console.info('\x1b[1m', 'Connection to the database is fully operational', '\x1b[0m'); 50 | 51 | return Promise.resolve(this.connection); 52 | } 53 | catch(error) { 54 | console.error('Unable to connect to the database:', error) 55 | return Promise.reject(error); 56 | } 57 | }; 58 | 59 | return { 60 | start:_start 61 | }; 62 | }; 63 | 64 | function _migrate(environment, force=false) { 65 | // Validation of NODE_ENV. 66 | if (environment !== 'development'){ 67 | console.error(`Could not migrate in env ${environment}`); 68 | return; 69 | } 70 | // Validation of 'force' parameter. 71 | else if (typeof force !== 'boolean'){ 72 | console.error("Wrong force parameter; must be boolean"); 73 | return; 74 | } 75 | 76 | const _successfulDBMigration = () => ( 77 | console.log('Successful migration') 78 | ) 79 | 80 | return connection 81 | .authenticate() 82 | .then(() => { 83 | console.log('Models to sync:', connection.models); 84 | 85 | return _associateModels(connection.models) 86 | .then(() => connection.sync({ force })) 87 | .then(() => _successfulDBMigration()) 88 | .catch(error => console.error(error)); 89 | }) 90 | .catch(error => console.error(error)); 91 | } 92 | 93 | async function _associateModels(models) { 94 | return new Promise((resolve, reject) => { 95 | try{ 96 | Object.keys(models).map(modelName => ( 97 | models[modelName].associate(models) 98 | )); 99 | 100 | return resolve(models); 101 | } 102 | catch(error){ 103 | reject(error); 104 | } 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /app/services/jwt.service.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const { 3 | accessToken, 4 | refreshToken 5 | } = require('#configs/jwt'); 6 | const { addSeconds } = require('#utils/dates'); 7 | 8 | 9 | module.exports = { 10 | issueAccessToken: (payload) => _issueToken({ payload, secret:accessToken.secret, expiresIn:accessToken.expiresIn }), 11 | issueRefreshToken: (payload) => _issueToken({ payload, secret:refreshToken.secret, expiresIn:refreshToken.expiresIn }), 12 | verifyAccessToken: (token) => _verifyToken({ token, secret:accessToken.secret }), 13 | verifyRefreshToken: (token) => _verifyToken({ token, secret:refreshToken.secret }) 14 | }; 15 | 16 | async function _issueToken({ payload, secret, expiresIn }) { 17 | try { 18 | const token = jwt.sign(payload, secret, { expiresIn }); 19 | const expirationDateValue = (addSeconds(new Date(), expiresIn/1000)).valueOf(); 20 | 21 | const fullToken = { token, expiresIn, expirationDateValue }; 22 | return Promise.resolve([ fullToken ]); 23 | } 24 | catch(error) { 25 | return Promise.reject(error); 26 | } 27 | } 28 | 29 | async function _verifyToken({ token, secret }) { 30 | try { 31 | const parsedToken = await jwt.verify(token, secret, {}); 32 | return Promise.resolve([ parsedToken ]); 33 | } 34 | catch(error) { 35 | return Promise.reject(error); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/utils/dates.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | addSeconds: _addSeconds, 3 | addDays: _addDays 4 | } 5 | 6 | function _addSeconds(date=null, seconds=0) { 7 | const newDate = new Date(date.valueOf()); 8 | newDate.setSeconds(newDate.getSeconds() + seconds); 9 | return newDate; 10 | } 11 | 12 | function _addDays(date=null, days=0) { 13 | const newDate = new Date(date.valueOf()); 14 | newDate.setDate(newDate.getDate() + days); 15 | return newDate; 16 | } 17 | 18 | -------------------------------------------------------------------------------- /app/views/error.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | head 4 | title=title 5 | body 6 | h1=heading 7 | -------------------------------------------------------------------------------- /app/views/home.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | head 4 | title="Home" 5 | body 6 | h5="Hi!" -------------------------------------------------------------------------------- /configs/api.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | versions: { 3 | // 'latest' should be a string of a current API version. 4 | latest: "v1", 5 | // 'all' should contain strings of 6 | // all your API versions. 7 | all: ["v1"] 8 | } 9 | } -------------------------------------------------------------------------------- /configs/database.js: -------------------------------------------------------------------------------- 1 | const CHARSET = 'utf8'; 2 | const COLLATE = 'utf8_general_ci'; 3 | 4 | module.exports = { 5 | database: process.env.DB_NAME, 6 | username: process.env.DB_USER, 7 | password: process.env.DB_PASSWORD, 8 | host: process.env.DB_HOST ?? 'localhost', 9 | port: process.env.DB_PORT ?? '3306', 10 | dialect: process.env.DB_DIALECT ?? 'mysql', // or: 'postgres' 11 | 12 | pool: { 13 | max: 5, 14 | min: 0, 15 | idle: 10000, 16 | }, 17 | charset: CHARSET, 18 | collate: COLLATE, 19 | timestamps: true, 20 | logging:false 21 | } -------------------------------------------------------------------------------- /configs/envinorments.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | current: process.env.NODE_ENV, 5 | allowed: [ 6 | 'development', 7 | 'testing', 8 | 'staging', 9 | 'production' 10 | ] 11 | } -------------------------------------------------------------------------------- /configs/jwt.js: -------------------------------------------------------------------------------- 1 | const accessToken = { 2 | secret: process.env.JWT_ACCESS_SECRET, 3 | expiresIn: 10*60*1000 // 10 mins 4 | } 5 | const refreshToken = { 6 | secret: process.env.JWT_REFRESH_SECRET, 7 | expiresIn: 14*24*60*60*1000 // 14 days 8 | } 9 | 10 | module.exports = { 11 | accessToken, 12 | refreshToken 13 | } -------------------------------------------------------------------------------- /configs/server.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | keep: false, 3 | port: process.env.APP_PORT || process.env.PORT || '8080' 4 | }; -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | 4 | 5 | export default [ 6 | { 7 | files: ["**/*.js"], 8 | languageOptions: { 9 | sourceType: "commonjs" 10 | } 11 | }, 12 | 13 | { 14 | languageOptions: { 15 | globals: globals.node 16 | } 17 | }, 18 | 19 | pluginJs.configs.recommended, 20 | ]; -------------------------------------------------------------------------------- /migrator/models.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Populate this file with models, that you want to migrate in db 3 | */ 4 | require('#models/User'); 5 | require('#models/DisabledRefreshToken'); 6 | // Add your models here ... 7 | -------------------------------------------------------------------------------- /migrator/seeder.js: -------------------------------------------------------------------------------- 1 | // Import config from .env file. 2 | require('dotenv').config(); 3 | 4 | // Data to seed: 5 | const users = require('./seeds/users'); 6 | // Import your seeders here... 7 | 8 | // Connection to database. 9 | const db = require('#services/db.service'); 10 | 11 | 12 | async function _main () { 13 | try { 14 | if (process.env.NODE_ENV !== 'development'){ 15 | const error = new Error("Can not make any actions in non-dev env."); 16 | throw error; 17 | }; 18 | 19 | // Make database connection active. 20 | const DB = await db.service(process.env.NODE_ENV).start(); 21 | 22 | await users.run(); 23 | // Run seeders here... 24 | 25 | console.warn("All seeds inserted"); 26 | process.exit(0); 27 | } 28 | catch(error) { 29 | console.error('Seeder error:', error); 30 | process.exit(1); 31 | } 32 | } 33 | 34 | // Start. 35 | _main(); 36 | -------------------------------------------------------------------------------- /migrator/seeds/users.js: -------------------------------------------------------------------------------- 1 | // Facades. 2 | const usersFacade = require('#facades/users'); 3 | 4 | 5 | module.exports = { 6 | run:_run 7 | } 8 | 9 | async function _run () { 10 | try { 11 | const exampleUserData = { 12 | email: 'test@test.com', 13 | password: 'simplepass' 14 | } 15 | 16 | await usersFacade.register(exampleUserData); 17 | 18 | return Promise.resolve(); 19 | } 20 | catch(error) { 21 | return Promise.reject(error); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /migrator/start.js: -------------------------------------------------------------------------------- 1 | // Import config from .env file. 2 | require('dotenv').config(); 3 | // Models to migrate. 4 | require('./models'); 5 | // Connection to database 6 | const db = require('#services/db.service'); 7 | 8 | 9 | async function _main() { 10 | try { 11 | if (process.env.NODE_ENV !== 'development') { 12 | const error = new Error("Can not make any actions in non-dev env."); 13 | throw error; 14 | } 15 | 16 | // Set 'force' to true if you want to rewrite database. 17 | const force = true; 18 | await db.migrate(process.env.NODE_ENV, force); 19 | 20 | console.info('All models migrated.'); 21 | process.exit(0); 22 | } 23 | catch(error) { 24 | console.error('Migrator error:', error); 25 | process.exit(1); 26 | } 27 | } 28 | 29 | // Start. 30 | _main(); 31 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore":[ "public/", "README.md" ], 4 | "execMap": { 5 | "app":"babel-node ./app/app.js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-express-jwt", 3 | "version": "1.0.2", 4 | "author": "Mark Khramko ", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "babel-node ./app/app.js", 8 | "dev": "cross-env NODE_ENV=development nodemon", 9 | "production": "cross-env NODE_ENV=production babel-node ./app/app.js", 10 | "db:migrate": "babel-node ./migrator/start.js", 11 | "db:seed": "babel-node ./migrator/seeder.js", 12 | "lint": "eslint", 13 | "lint:fix": "eslint --fix ." 14 | }, 15 | "dependencies": { 16 | "bcrypt": "^5.0.0", 17 | "body-parser": "^1.20.1", 18 | "core-js": "^2.6.11", 19 | "cors": "^2.8.5", 20 | "dotenv": "^8.2.0", 21 | "express": "^4.18.2", 22 | "express-routes-mapper": "^1.0.2", 23 | "helmet": "^3.22.0", 24 | "jsonwebtoken": "^9.0.0", 25 | "mysql2": "^3.11.3", 26 | "pug": "^3.0.1", 27 | "sequelize": "^6.37.3" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "^7.12.10", 31 | "@babel/core": "^7.12.10", 32 | "@babel/node": "^7.12.10", 33 | "@babel/preset-env": "^7.12.11", 34 | "@eslint/js": "^9.10.0", 35 | "babel-plugin-module-resolver": "^5.0.0", 36 | "cross-env": "^7.0.3", 37 | "eslint": "^9.10.0", 38 | "globals": "^15.9.0", 39 | "nodemon": "^3.1.5" 40 | }, 41 | "description": "Node.js Express REST API boilerplate with JWT Authentication and support for MySQL and PostgreSQL.", 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/MarkKhramko/nodejs-express-jwt.git" 45 | }, 46 | "homepage": "https://github.com/MarkKhramko/nodejs-express-jwt#readme", 47 | "bugs": { 48 | "url": "https://github.com/MarkKhramko/nodejs-express-jwt/issues" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkKhramko/nodejs-express-jwt/426e1708ebb98813f8df62d2b75bbba8a4e983c8/public/.gitkeep --------------------------------------------------------------------------------