├── .dockerignore ├── .env.development.local ├── .env.production.local ├── .env.test.local ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── docker-compose-db.yml ├── docker-compose.yml ├── ecosystem.config.js ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── api │ │ └── v1 │ │ │ └── auth │ │ │ └── register.integeration.test.ts │ └── jest │ │ ├── config.ts │ │ ├── db.ts │ │ ├── factories │ │ ├── faker.ts │ │ ├── index.ts │ │ └── user.ts │ │ ├── globalSetup.ts │ │ ├── globalTeardown.ts │ │ └── setupFile.ts ├── api │ └── v1 │ │ ├── auth │ │ ├── auth.controller.ts │ │ └── dtos │ │ │ ├── forgotPassword.dto.ts │ │ │ ├── login.dto.ts │ │ │ ├── logout.dto.ts │ │ │ ├── refreshToken.dto.ts │ │ │ ├── register.dto.ts │ │ │ └── resetPassword.dto.ts │ │ ├── index.ts │ │ └── user │ │ └── user.controller.ts ├── app.ts ├── common │ ├── constants │ │ └── index.ts │ └── interfaces │ │ ├── crud.interface.ts │ │ └── timestamp.interface.ts ├── config │ ├── index.ts │ └── passport.ts ├── exceptions │ └── HttpException.ts ├── index.ts ├── middlewares │ ├── auth.middleware.ts │ ├── handlingErrors.middleware.ts │ └── validation.middleware.ts ├── models │ ├── tokens.model.ts │ └── users.model.ts ├── services │ └── v1 │ │ ├── auth.service.ts │ │ ├── index.ts │ │ ├── token.service.ts │ │ └── user.service.ts └── utils │ └── toJSON.plugin.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | .vscode 3 | /node_modules 4 | 5 | # code formatter 6 | .eslintrc 7 | .eslintignore 8 | .editorconfig 9 | 10 | .prettierrc 11 | 12 | # test 13 | jest.config.js 14 | 15 | # docker 16 | Dockerfile 17 | docker-compose.yml 18 | -------------------------------------------------------------------------------- /.env.development.local: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | MONGO_URI=mongodb://127.0.0.1:27017 3 | DATABASE=express_typescript_boilerplate 4 | CORS_ORIGINS=["http://localhost:3001"] 5 | CREDENTIALS =true 6 | 7 | SENTRY_DSN=SENTRY_DSN 8 | 9 | #JWT 10 | JWT_SECRET=JWT_SECRET 11 | JWT_ACCESS_EXPIRE_IN=60 12 | JWT_ACCESS_EXPIRE_FORMAT=minutes 13 | JWT_REFRESH_EXPIRE_IN=30 14 | JWT_REFRESH_EXPIRE_FORMAT=days 15 | JWT_RESET_PASSWORD_EXPIRE_IN=30 16 | JWT_RESET_PASSWORD_EXPIRE_FORMAT=minutes -------------------------------------------------------------------------------- /.env.production.local: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | MONGO_URI=mongodb://127.0.0.1:27017 3 | DATABASE=express_typescript_boilerplate 4 | 5 | CORS_ORIGINS=["http://localhost:3001"] 6 | CREDENTIALS =true 7 | 8 | SENTRY_DSN=SENTRY_DSN 9 | 10 | 11 | #JWT 12 | JWT_SECRET=JWT_SECRET 13 | JWT_ACCESS_EXPIRE_IN=60 14 | JWT_ACCESS_EXPIRE_FORMAT=minutes 15 | JWT_REFRESH_EXPIRE_IN=30 16 | JWT_REFRESH_EXPIRE_FORMAT=days 17 | JWT_RESET_PASSWORD_EXPIRE_IN=30 18 | JWT_RESET_PASSWORD_EXPIRE_FORMAT=minutes 19 | -------------------------------------------------------------------------------- /.env.test.local: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | MONGO_URI=mongodb://127.0.0.1:27017 3 | DATABASE=express_typescript_boilerplate_test 4 | 5 | CORS_ORIGINS=["http://localhost:3001"] 6 | CREDENTIALS =true 7 | 8 | 9 | SENTRY_DSN=SENTRY_DSN 10 | 11 | #JWT 12 | JWT_SECRET=JWT_SECRET 13 | JWT_ACCESS_EXPIRE_IN=60 14 | JWT_ACCESS_EXPIRE_FORMAT=minutes 15 | JWT_REFRESH_EXPIRE_IN=30 16 | JWT_REFRESH_EXPIRE_FORMAT=days 17 | JWT_RESET_PASSWORD_EXPIRE_IN=30 18 | JWT_RESET_PASSWORD_EXPIRE_FORMAT=minutes -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["prettier", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 4 | "parserOptions": { 5 | "ecmaVersion": 2018, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["simple-import-sort"], 9 | "rules": { 10 | "@typescript-eslint/explicit-member-accessibility": 0, 11 | "@typescript-eslint/explicit-function-return-type": 0, 12 | "@typescript-eslint/no-parameter-properties": 0, 13 | "@typescript-eslint/interface-name-prefix": 0, 14 | "@typescript-eslint/explicit-module-boundary-types": 0, 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "@typescript-eslint/ban-types": "off", 17 | "@typescript-eslint/no-var-requires": "off", 18 | "simple-import-sort/exports": "warn", 19 | "simple-import-sort/imports": [ 20 | "warn", 21 | { 22 | "groups": [ 23 | ["^@?\\w"], 24 | ["^(@common|@models|@v1|@config|@app|@middlewares|@exceptions|@utils|@services|@__tests__)(/.*|$)"], 25 | // Parent imports. Put `..` last. 26 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"], 27 | // Other relative imports. Put same-folder imports and `.` last. 28 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], 29 | 30 | ] 31 | } 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Test using Node.js 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: '16' 14 | - run: yarn install 15 | - run: yarn test:ci 16 | 17 | - name: Tests ✅ 18 | if: ${{ success() }} 19 | run: | 20 | curl --request POST \ 21 | --url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \ 22 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 23 | --header 'content-type: application/json' \ 24 | --data '{ 25 | "context": "tests", 26 | "state": "success", 27 | "description": "Tests passed", 28 | "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" 29 | }' 30 | 31 | - name: Tests 🚨 32 | if: ${{ failure() }} 33 | run: | 34 | curl --request POST \ 35 | --url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \ 36 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 37 | --header 'content-type: application/json' \ 38 | --data '{ 39 | "context": "tests", 40 | "state": "failure", 41 | "description": "Tests failed", 42 | "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" 43 | }' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | coverage/ 11 | 12 | .env 13 | dist 14 | # Output of 'npm pack' 15 | *.tgz 16 | 17 | # Yarn Integrity file 18 | .yarn-integrity -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "semi": true, 7 | "arrowParens": "avoid" 8 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM node:16.14.0-alpine as build-stage 3 | 4 | COPY . ./app 5 | 6 | WORKDIR /app 7 | 8 | RUN npm install 9 | 10 | RUN npm install -g yarn 11 | 12 | 13 | EXPOSE 3001 14 | 15 | # dev 16 | FROM build-stage as dev-build-stage 17 | 18 | ENV NODE_ENV development 19 | 20 | CMD ["yarn", "dev"] 21 | 22 | # Production 23 | FROM build-stage as prod-build-stage 24 | 25 | ENV NODE_ENV production 26 | 27 | CMD ["run", "start"] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🎫 A Class Base Routing Boilerplate for Node.js, Express.js, MongoDB with Typescript. 2 | 3 | ### What is it? 4 | Try to implement A Class Base Routing with clean structure and scalable boilerplate in Node.js,Express.js and Typescript. 5 | 6 | ### Features 7 | 8 | - **Sentry** catch errors. 9 | - **API Documentation** using **Swagger**. 10 | - **Basic Security Features** using [Helmet](https://github.com/helmetjs/helmet), [hpp](https://github.com/analog-nico/hpp) and [xss clean](https://github.com/jsonmaur/xss-clean). 11 | - **Validation** using [class-validator](https://github.com/typestack/class-validator) 12 | - **class base routing** using [routing-controllers](https://github.com/typestack/routing-controllers) 13 | - **Authentication** - using [Passport.js](https://github.com/jaredhanson/passport) [passport-jwt](https://github.com/mikenicholson/passport-jwt) which is compatible with Express.js and is a authentication middleware for Node.js. 14 | - **Database** using [mongoose](https://mongoosejs.com/) odm for interacting with mongoDB. 15 | - **run testing** using [Jest](https://jestjs.io/) 16 | - **linting** using [ESLint](https://eslint.org/) 17 | - **prettier** using [Prettier](https://prettier.io/) 18 | 19 | 20 |
21 | 22 | ## Getting Started 23 | 24 | install dependencies 25 | 26 | ```bash 27 | yarn 28 | ``` 29 |
30 | 31 | ### Without Docker 32 | Note: It is assumed here that you have MongoDB running in the background. 33 | 34 | set `.env.development.local` file with your credentials.(like DB URL) 35 | 36 | Run the app 37 | ```bash 38 | yarn dev 39 | ``` 40 | 41 | 42 | ### With Docker 43 | Note: It is assumed here that you have installed Docker and running in the background. 44 | ```bash 45 | yarn docker:db 46 | ``` 47 | set `.env.development.local` file with your credentials.(like DB URL) 48 | 49 | Run the app 50 | ```bash 51 | yarn dev 52 | ``` 53 | 54 | 55 | 56 |
57 |
58 | 59 | ### Route Documents 60 | 61 | you can access swagger documentation at `http://localhost:3000/api-docs` 62 | 63 |
64 |
65 |
66 | 67 | ### What is the Structure of template? 68 | ``` 69 | express-typescript-boilerplate 70 | ├─ .github 71 | │ └─ workflows 72 | │ └─ tests.yml 73 | ├─ README.md 74 | ├─ ecosystem.config.js 75 | ├─ jest.config.js 76 | ├─ package.json 77 | ├─ src 78 | │ ├─ __tests__ 79 | │ │ ├─ api 80 | │ │ │ └─ v1 81 | │ │ │ └─ auth 82 | │ │ │ └─ users 83 | │ ├─ api 84 | │ │ └─ v1 85 | │ │ ├─ auth 86 | │ │ │ ├─ auth.controller.ts 87 | │ │ │ └─ dtos 88 | │ │ ├─ index.ts 89 | │ │ └─ user 90 | │ │ └─ user.controller.ts 91 | │ ├─ app.ts 92 | │ ├─ common 93 | │ │ ├─ constants 94 | │ │ │ └─ index.ts 95 | │ │ ├─ interfaces 96 | │ │ │ ├─ crud.interface.ts 97 | │ │ │ └─ timestamp.interface.ts 98 | │ │ └─ types 99 | │ ├─ config 100 | │ │ ├─ index.ts 101 | │ │ └─ passport.ts 102 | │ ├─ exceptions 103 | │ │ └─ HttpException.ts 104 | │ ├─ index.ts 105 | │ ├─ middlewares 106 | │ │ ├─ auth.middleware.ts 107 | │ │ ├─ handlingErrors.middleware.ts 108 | │ │ └─ validation.middleware.ts 109 | │ ├─ models 110 | │ │ ├─ tokens.model.ts 111 | │ │ └─ users.model.ts 112 | │ ├─ services 113 | │ │ └─ v1 114 | │ │ ├─ auth.service.ts 115 | │ │ ├─ index.ts 116 | │ │ ├─ token.service.ts 117 | │ │ └─ user.service.ts 118 | │ └─ utils 119 | │ └─ toJSON.plugin.ts 120 | ├─ tsconfig.json 121 | 122 | ``` -------------------------------------------------------------------------------- /docker-compose-db.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | mongodb: 4 | container_name: database_mongo 5 | image: mongo:5.0 6 | ports: 7 | - 27017:27017 8 | volumes: 9 | - ~/apps/mongo:/data/db 10 | networks: 11 | - backend 12 | 13 | networks: 14 | backend: 15 | driver: bridge 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | server: 4 | build: 5 | context: ./ 6 | target: dev-build-stage 7 | dockerfile: Dockerfile 8 | container_name: server 9 | ports: 10 | - '3001:3001' 11 | volumes: 12 | - ./:/app 13 | - /app/node_modules 14 | restart: 'unless-stopped' 15 | networks: 16 | - backend 17 | depends_on: 18 | - mongodb 19 | 20 | mongodb: 21 | container_name: database 22 | image: mongo:5.0 23 | ports: 24 | - 27017:27017 25 | volumes: 26 | - ~/apps/mongo:/data/db 27 | networks: 28 | - backend 29 | 30 | networks: 31 | backend: 32 | driver: bridge 33 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | script: 'dist/index.js', 5 | name: 'prod_api', 6 | autorestart: true, 7 | watch: false, 8 | max_memory_restart: '1G', 9 | merge_logs: true, 10 | env: { 11 | PORT: 3001, 12 | NODE_ENV: 'production', 13 | }, 14 | }, 15 | ], 16 | 17 | deploy: { 18 | production: { 19 | user: 'SSH_USERNAME', 20 | host: '0.0.0.0', 21 | ref: 'origin/master', 22 | repo: 'GIT_REPOSITORY', 23 | path: 'DESTINATION_PATH', 24 | 'pre-deploy-local': '', 25 | 'post-deploy': 'yarn install && yarn run build && pm2 reload ecosystem.config.js --only prod_api', 26 | 'pre-setup': '', 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require("ts-jest"); 2 | const { compilerOptions } = require("./tsconfig"); 3 | 4 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 5 | module.exports = { 6 | preset: "ts-jest", 7 | testEnvironment: "node", 8 | roots: ["/src"], 9 | transform: { 10 | "^.+\\.tsx?$": "ts-jest", 11 | }, 12 | globalSetup: "/src/__tests__/jest/globalSetup.ts", 13 | globalTeardown: "/src/__tests__/jest/globalTeardown.ts", 14 | setupFilesAfterEnv: ["/src/__tests__/jest/setupFile.ts"], 15 | coveragePathIgnorePatterns: ["/node_modules/", "/__tests__/jest/"], 16 | testPathIgnorePatterns: ["/src/__tests__/jest"], 17 | clearMocks: true, 18 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 19 | prefix: "/src", 20 | }), 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "private": false, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/miladr0/express-typescript-mongodb.git" 9 | }, 10 | "bug": "https://github.com/miladr0/express-typescript-mongodb/issues", 11 | "homepage": "https://github.com/miladr0/express-typescript-mongodb", 12 | "author": { 13 | "name": "Milad", 14 | "email": "miladr0r@gmail.com", 15 | "url": "https://miladr0.com" 16 | }, 17 | "license": "MIT", 18 | "scripts": { 19 | "start": "yarn build && cross-env NODE_ENV=development node dist/index.js", 20 | "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register src/index.ts", 21 | "prod": "yarn run build && pm2 start ecosystem.config.js --only prod_api", 22 | "seed": "cross-env NODE_ENV=development ts-node -r tsconfig-paths/register src/scripts/seed.ts", 23 | "build": "tsc -b && tsc-alias", 24 | "lint": "eslint --ignore-path .gitignore --ext .ts src/", 25 | "lint:fix": "yarn lint -- --fix", 26 | "test:watch": "cross-env NODE_ENV=test jest --runInBand --verbose --watchAll", 27 | "test": "cross-env NODE_ENV=test node --expose-gc node_modules/jest/bin/jest --runInBand --verbose --coverage", 28 | "test:ci": "yarn test -- --ci --logHeapUsage", 29 | "docker:db": "docker-compose -f docker-compose-db.yml up" 30 | }, 31 | "dependencies": { 32 | "@sentry/node": "^6.19.7", 33 | "bcrypt": "^5.0.1", 34 | "body-parser": "^1.19.0", 35 | "class-transformer": "^0.5.1", 36 | "class-validator": "^0.13.2", 37 | "class-validator-jsonschema": "^3.1.0", 38 | "cookie-parser": "^1.4.6", 39 | "cors": "^2.8.5", 40 | "cross-env": "^7.0.3", 41 | "dotenv": "^16.0.1", 42 | "express": "^4.17.1", 43 | "helmet": "^5.1.0", 44 | "hpp": "^0.2.3", 45 | "http-status-codes": "^2.2.0", 46 | "jsonwebtoken": "^8.5.1", 47 | "lodash": "^4.17.21", 48 | "moment": "^2.29.3", 49 | "mongoose": "^6.3.4", 50 | "passport": "^0.6.0", 51 | "passport-jwt": "^4.0.0", 52 | "pm2": "^5.2.0", 53 | "reflect-metadata": "^0.1.13", 54 | "routing-controllers": "^0.9.0", 55 | "routing-controllers-openapi": "^3.1.0", 56 | "swagger-ui-express": "^4.4.0", 57 | "xss-clean": "^0.1.1" 58 | }, 59 | "devDependencies": { 60 | "@types/body-parser": "^1.19.2", 61 | "@types/cookie-parser": "^1.4.3", 62 | "@types/cors": "^2.8.12", 63 | "@types/express": "^4.17.13", 64 | "@types/express-handlebars": "^6.0.0", 65 | "@types/hpp": "^0.2.2", 66 | "@types/jest": "^27.5.1", 67 | "@types/jsonwebtoken": "^8.5.8", 68 | "@types/lodash": "^4.14.182", 69 | "@types/swagger-ui-express": "^4.1.3", 70 | "@typescript-eslint/eslint-plugin": "^5.26.0", 71 | "@typescript-eslint/parser": "^5.28.0", 72 | "eslint": "^8.16.0", 73 | "eslint-config-prettier": "^8.5.0", 74 | "eslint-plugin-prettier": "^4.0.0", 75 | "eslint-plugin-simple-import-sort": "^7.0.0", 76 | "jest": "^28.1.0", 77 | "mongodb-memory-server": "8.4.0", 78 | "prettier": "^2.6.2", 79 | "supertest": "^6.2.3", 80 | "ts-jest": "^28.0.3", 81 | "ts-node": "^10.8.0", 82 | "ts-node-dev": "^2.0.0", 83 | "tsc-alias": "^1.6.7", 84 | "tsconfig-paths": "^4.0.0", 85 | "typescript": "^4.7.2" 86 | }, 87 | "engines": { 88 | "node": ">=16.14.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/__tests__/api/v1/auth/register.integeration.test.ts: -------------------------------------------------------------------------------- 1 | import supertest, { SuperTest, Test } from 'supertest'; 2 | 3 | import { clearDB } from '@__tests__/jest/db'; 4 | import { fakerData } from '@__tests__/jest/factories'; 5 | import { userFactory } from '@__tests__/jest/factories'; 6 | import App from '@app'; 7 | import { AuthControllerV1 } from '@v1/index'; 8 | 9 | let server: SuperTest; 10 | const baseUrl = '/api/v1/auth'; 11 | 12 | describe('register test suit', () => { 13 | beforeEach(async () => { 14 | await clearDB(); 15 | const app = new App([AuthControllerV1]); 16 | await App.initDB(); 17 | server = supertest(app.getServer()); 18 | }); 19 | 20 | test('email is not valid', async () => { 21 | const newUser = { 22 | email: 'notemail', 23 | username: 'abcabc', 24 | password: '123123', 25 | }; 26 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(400); 27 | expect(body.message).toBe('email must be an email'); 28 | }); 29 | 30 | test('username should at least 4 character', async () => { 31 | const newUser = { 32 | email: fakerData.internet.email(), 33 | username: 'abc', 34 | password: '123123', 35 | }; 36 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(400); 37 | expect(body.message).toBe('username must be longer than or equal to 4 characters'); 38 | }); 39 | 40 | test('password should at least 6 character', async () => { 41 | const newUser = { 42 | email: fakerData.internet.email(), 43 | username: 'abcd', 44 | password: '1231', 45 | }; 46 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(400); 47 | expect(body.message).toBe('password must be longer than or equal to 6 characters'); 48 | }); 49 | 50 | test('email should be unique', async () => { 51 | const email = fakerData.internet.email(); 52 | await userFactory({ email }); 53 | const newUser2 = { 54 | email: email, 55 | username: 'abcd', 56 | password: '123123', 57 | }; 58 | 59 | const { body } = await server.post(`${baseUrl}/register`).send(newUser2).expect(500); 60 | expect(body.message).toBe('Email already Taken'); 61 | }); 62 | 63 | test('email should be unique', async () => { 64 | const newUser = { 65 | email: fakerData.internet.email(), 66 | username: fakerData.internet.userName(), 67 | password: fakerData.internet.password(), 68 | }; 69 | 70 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(201); 71 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 72 | const { password, ...userResult } = newUser; 73 | 74 | expect(body.user).toMatchObject(userResult); 75 | expect(body.tokens).toBeDefined(); 76 | expect(body.tokens.access.token).toBeDefined(); 77 | expect(body.tokens.refresh.token).toBeDefined(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/__tests__/jest/config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | Memory: true, 3 | MongoURI: 'mongodb://localhost:27017', 4 | Database: 'express_typescript_boilerplate_test', 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /src/__tests__/jest/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import config from './config'; 4 | 5 | export const connect = async () => { 6 | await mongoose.connect(`${process.env.MONGO_URI}/${config.Database}`); 7 | }; 8 | 9 | export const disconnect = async () => { 10 | await mongoose.connection.dropDatabase(); 11 | await mongoose.connection.close(); 12 | await mongoose.disconnect(); 13 | }; 14 | 15 | export const clearDB = async () => { 16 | const collections = mongoose.connection.collections; 17 | 18 | for (const key in collections) { 19 | const collection = collections[key]; 20 | await collection.deleteMany({}); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/__tests__/jest/factories/faker.ts: -------------------------------------------------------------------------------- 1 | function getRandomInt(min = 1, max = 1000) { 2 | return Math.floor(Math.random() * (max - min + 1)) + min; 3 | } 4 | 5 | class faker { 6 | get internet() { 7 | return { 8 | email: () => `test${getRandomInt()}@gmail.com`, 9 | userName: () => `test${getRandomInt()}`, 10 | password: () => `12345${getRandomInt()}`, 11 | }; 12 | } 13 | } 14 | export const fakerData = new faker(); 15 | -------------------------------------------------------------------------------- /src/__tests__/jest/factories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './faker'; 2 | export * from './user'; 3 | -------------------------------------------------------------------------------- /src/__tests__/jest/factories/user.ts: -------------------------------------------------------------------------------- 1 | import User, { IUser } from '@models/users.model'; 2 | 3 | import { fakerData } from './faker'; 4 | 5 | export async function userFactory(user: Partial = {}) { 6 | return User.create({ 7 | email: fakerData.internet.email(), 8 | username: fakerData.internet.userName(), 9 | password: fakerData.internet.password(), 10 | ...user, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/__tests__/jest/globalSetup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | import mongoose from 'mongoose'; 3 | 4 | import config from './config'; 5 | 6 | export default async function globalSetup() { 7 | if (config.Memory) { 8 | // Config to decided if an mongodb-memory-server instance should be used 9 | // it's needed in global space, because we don't want to create a new instance every test-suite 10 | const instance = await MongoMemoryServer.create(); 11 | const uri = instance.getUri(); 12 | (global as any).__MONGOINSTANCE = instance; 13 | process.env.MONGO_URI = uri.slice(0, uri.lastIndexOf('/')); 14 | } else { 15 | process.env.MONGO_URI = config.MongoURI; 16 | } 17 | 18 | // The following is to make sure the database is clean before an test starts 19 | await mongoose.connect(`${process.env.MONGO_URI}/${config.Database}`); 20 | await mongoose.connection.db.dropDatabase(); 21 | await mongoose.disconnect(); 22 | } 23 | -------------------------------------------------------------------------------- /src/__tests__/jest/globalTeardown.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | 3 | import config from './config'; 4 | 5 | export default async function globalTeardown() { 6 | if (config.Memory) { 7 | // Config to decided if an mongodb-memory-server instance should be used 8 | const instance: MongoMemoryServer = (global as any).__MONGOINSTANCE; 9 | await instance.stop(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/__tests__/jest/setupFile.ts: -------------------------------------------------------------------------------- 1 | import { connect, disconnect } from './db'; 2 | 3 | beforeAll(async () => { 4 | await connect(); 5 | }); 6 | 7 | afterAll(async () => { 8 | await disconnect(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/api/v1/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, HttpCode, JsonController, Post, UseBefore } from 'routing-controllers'; 2 | import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; 3 | 4 | import { validationMiddleware } from '@middlewares/validation.middleware'; 5 | import { IUser } from '@models/users.model'; 6 | import { AuthService, TokenService, UserService } from '@services/v1'; 7 | 8 | import ForgotPasswordDto from './dtos/forgotPassword.dto'; 9 | import LoginDto, { LoginResponseSchema } from './dtos/login.dto'; 10 | import LogoutDto from './dtos/logout.dto'; 11 | import RefreshTokenDto from './dtos/refreshToken.dto'; 12 | import RegisterDto from './dtos/register.dto'; 13 | import ResetPasswordDto from './dtos/resetPassword.dto'; 14 | 15 | @JsonController('/v1/auth', { transformResponse: false }) 16 | export class AuthController { 17 | private readonly tokenService = new TokenService(); 18 | private readonly userService = new UserService(); 19 | private readonly authService = new AuthService(); 20 | 21 | @Post('/register') 22 | @HttpCode(201) 23 | @OpenAPI({ summary: 'register new user' }) 24 | @ResponseSchema(IUser) 25 | @UseBefore(validationMiddleware(RegisterDto, 'body')) 26 | async register(@Body() userData: RegisterDto) { 27 | const user = await this.userService.createUser(userData); 28 | const tokens = await this.tokenService.generateAuthTokens(user); 29 | 30 | return { user, tokens }; 31 | } 32 | 33 | @Post('/login') 34 | @OpenAPI({ 35 | description: 'user data and tokens', 36 | responses: LoginResponseSchema, 37 | }) 38 | @UseBefore(validationMiddleware(LoginDto, 'body')) 39 | async login(@Body() userData: LoginDto) { 40 | const user = await this.authService.loginUserWithEmailAndPassword(userData.email, userData.password); 41 | const tokens = await this.tokenService.generateAuthTokens(user); 42 | 43 | return { user, tokens }; 44 | } 45 | 46 | @Post('/logout') 47 | @OpenAPI({ summary: 'logout the user' }) 48 | @UseBefore(validationMiddleware(LogoutDto, 'body')) 49 | async logout(@Body() userData: LogoutDto) { 50 | await this.authService.logout(userData.refreshToken); 51 | 52 | return { message: 'logout success' }; 53 | } 54 | 55 | @Post('/refresh-tokens') 56 | @OpenAPI({ description: 'renew user token and refresh token', responses: LoginResponseSchema }) 57 | @UseBefore(validationMiddleware(RefreshTokenDto, 'body')) 58 | async refreshToken(@Body() userData: RefreshTokenDto) { 59 | const result = await this.authService.refreshAuth(userData.refreshToken); 60 | 61 | return { ...result }; 62 | } 63 | 64 | @Post('/forgot-password') 65 | @OpenAPI({ summary: 'send reset token to reset the password' }) 66 | @UseBefore(validationMiddleware(ForgotPasswordDto, 'body')) 67 | async forgotPassword(@Body() userData: ForgotPasswordDto) { 68 | const token = await this.tokenService.generateResetPasswordToken(userData.email); 69 | 70 | // should use email service to send the token to email owner, not return it! 71 | return { token }; 72 | } 73 | 74 | @Post('/reset-password') 75 | @OpenAPI({ summary: 'reset user password' }) 76 | @UseBefore(validationMiddleware(ResetPasswordDto, 'body')) 77 | async resetPassword(@Body() userData: ResetPasswordDto) { 78 | await this.authService.resetPassword(userData.token, userData.password); 79 | 80 | return { message: 'password successfully updated' }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/api/v1/auth/dtos/forgotPassword.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator'; 2 | 3 | export default class ForgotPasswordDto { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | email: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/api/v1/auth/dtos/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; 2 | 3 | export default class LoginDto { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | email: string; 7 | 8 | @MinLength(6) 9 | password: string; 10 | } 11 | 12 | export const LoginResponseSchema = { 13 | '200': { 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | user: { 20 | type: 'object', 21 | properties: { 22 | username: { 23 | type: 'string', 24 | }, 25 | }, 26 | }, 27 | tokens: { 28 | type: 'object', 29 | properties: { 30 | accessToken: { 31 | type: 'object', 32 | properties: { 33 | token: { 34 | type: 'string', 35 | }, 36 | expires: { 37 | type: 'integer', 38 | }, 39 | }, 40 | }, 41 | refreshToken: { 42 | type: 'object', 43 | properties: { 44 | token: { 45 | type: 'string', 46 | }, 47 | expires: { 48 | type: 'integer', 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | required: ['user', 'tokens'], 56 | }, 57 | }, 58 | }, 59 | description: 'Successful response', 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/api/v1/auth/dtos/logout.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export default class LogoutDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | refreshToken: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/api/v1/auth/dtos/refreshToken.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export default class RefreshTokenDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | refreshToken: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/api/v1/auth/dtos/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export default class RegisterDto { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | email: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | @MinLength(4) 11 | @MaxLength(15) 12 | username: string; 13 | 14 | @MinLength(6) 15 | password: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/v1/auth/dtos/resetPassword.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, MinLength } from 'class-validator'; 2 | 3 | export default class ResetPasswordDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | token: string; 7 | 8 | @MinLength(6) 9 | password: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/api/v1/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthController as AuthControllerV1 } from '@v1/auth/auth.controller'; 2 | export { UserController as UserControllerV1 } from '@v1/user/user.controller'; 3 | -------------------------------------------------------------------------------- /src/api/v1/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Get, JsonController, UseBefore } from 'routing-controllers'; 2 | import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; 3 | 4 | import auth from '@middlewares/auth.middleware'; 5 | import { IUser } from '@models/users.model'; 6 | import { UserService } from '@services/v1'; 7 | 8 | @JsonController('/v1/users', { transformResponse: false }) 9 | export class UserController { 10 | private readonly userService = new UserService(); 11 | 12 | @Get('/') 13 | @OpenAPI({ summary: 'get users' }) 14 | @ResponseSchema(IUser, { isArray: true }) 15 | @UseBefore(auth()) 16 | async register() { 17 | const users = await this.userService.findAll(); 18 | 19 | return { users }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line simple-import-sort/imports 2 | import 'reflect-metadata'; 3 | import { CORS_ORIGINS, CREDENTIALS, MONGO_URI, DATABASE, isProduction, PORT, SENTRY_DSN, jwtStrategy } from './config'; 4 | 5 | import * as Sentry from '@sentry/node'; 6 | import bodyParser from 'body-parser'; 7 | 8 | import cookieParser from 'cookie-parser'; 9 | import cors from 'cors'; 10 | import express, { Application, ErrorRequestHandler, RequestHandler } from 'express'; 11 | import helmet from 'helmet'; 12 | import hpp from 'hpp'; 13 | import http from 'http'; 14 | import mongoose from 'mongoose'; 15 | import passport from 'passport'; 16 | import { useExpressServer } from 'routing-controllers'; 17 | import xss from 'xss-clean'; 18 | 19 | import handlingErrorsMiddleware from './middlewares/handlingErrors.middleware'; 20 | 21 | let serverConnection: http.Server; 22 | 23 | export default class App { 24 | private app: Application; 25 | private port: string | number; 26 | private controllers: Function[] = []; 27 | 28 | constructor(controllers: Function[]) { 29 | this.app = express(); 30 | this.port = PORT || 8080; 31 | this.controllers = controllers; 32 | 33 | this.initSentry(); 34 | this.initMiddlewares(); 35 | this.initRoutes(controllers); 36 | 37 | this.initHandlingErrors(); 38 | } 39 | 40 | private initSentry() { 41 | if (isProduction) { 42 | Sentry.init({ dsn: SENTRY_DSN }); 43 | // The request handler must be the first middleware on the app 44 | this.app.use(Sentry.Handlers.requestHandler() as RequestHandler); 45 | } 46 | } 47 | private initMiddlewares() { 48 | this.app.use(helmet()); 49 | this.app.use(cors({ origin: CORS_ORIGINS })); 50 | 51 | this.app.use(bodyParser.json()); 52 | this.app.use(bodyParser.urlencoded({ extended: true })); 53 | // sanitize user data 54 | this.app.use(hpp()); 55 | this.app.use(xss()); 56 | this.app.use(cookieParser()); 57 | 58 | // jwt authentication 59 | this.app.use(passport.initialize()); 60 | passport.use('jwt', jwtStrategy); 61 | } 62 | 63 | private initRoutes(controllers: Function[]) { 64 | useExpressServer(this.app, { 65 | cors: { 66 | origin: CORS_ORIGINS, 67 | credentials: CREDENTIALS, 68 | }, 69 | routePrefix: '/api', 70 | controllers: controllers, 71 | defaultErrorHandler: false, 72 | }); 73 | } 74 | 75 | private initHandlingErrors() { 76 | if (isProduction) { 77 | // The error handler must be before any other error middleware and after all controllers 78 | this.app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler); 79 | } 80 | this.app.use(handlingErrorsMiddleware); 81 | } 82 | 83 | static async initDB() { 84 | await mongoose.connect(`${MONGO_URI}/${DATABASE}`); 85 | } 86 | 87 | static async closeDB() { 88 | await mongoose.disconnect(); 89 | } 90 | 91 | public initWebServer = async () => { 92 | return new Promise(resolve => { 93 | serverConnection = this.app.listen(this.port, () => { 94 | console.log(`✅ Ready on port http://localhost:${this.port}`); 95 | 96 | resolve(serverConnection.address()); 97 | }); 98 | }); 99 | }; 100 | 101 | public initServerWithDB = async () => { 102 | await Promise.all([App.initDB(), this.initWebServer()]); 103 | }; 104 | 105 | public stopWebServer = async () => { 106 | return new Promise(resolve => { 107 | serverConnection.close(() => { 108 | resolve(void 0); 109 | }); 110 | }); 111 | }; 112 | 113 | public getServer = () => { 114 | return this.app; 115 | }; 116 | 117 | public get getControllers() { 118 | return this.controllers; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/common/constants/index.ts: -------------------------------------------------------------------------------- 1 | export enum MODELS { 2 | USERS = 'USERS', 3 | TOKENS = 'TOKENS', 4 | } 5 | 6 | export enum TokenTypes { 7 | ACCESS = 'access', 8 | REFRESH = 'refresh', 9 | RESET_PASSWORD = 'resetPassword', 10 | } 11 | -------------------------------------------------------------------------------- /src/common/interfaces/crud.interface.ts: -------------------------------------------------------------------------------- 1 | import { LeanDocument, ObjectId } from 'mongoose'; 2 | 3 | export default interface CRUD { 4 | findAll: ( 5 | limit: number, 6 | page: number, 7 | ) => Promise<{ 8 | docs: Array | Array>; 9 | meta: { 10 | totalDocs: number; 11 | totalPages: number; 12 | page: number; 13 | }; 14 | }>; 15 | getById: (id: ObjectId) => Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/common/interfaces/timestamp.interface.ts: -------------------------------------------------------------------------------- 1 | import { IsDate } from 'class-validator'; 2 | 3 | export default class ITimesStamp { 4 | @IsDate() 5 | createdAt: Date; 6 | @IsDate() 7 | updatedAt: Date; 8 | } 9 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | dotenv.config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` }); 3 | 4 | const checkEnv = (envVar: string, defaultValue?: string) => { 5 | if (!process.env[envVar]) { 6 | if (defaultValue) { 7 | return defaultValue; 8 | } 9 | throw new Error(`Please define the Enviroment variable"${envVar}"`); 10 | } else { 11 | return process.env[envVar] as string; 12 | } 13 | }; 14 | 15 | export const PORT: number = parseInt(checkEnv('PORT'), 10); 16 | export const MONGO_URI: string = checkEnv('MONGO_URI'); 17 | export const DATABASE: string = checkEnv('DATABASE'); 18 | export const CORS_ORIGINS = JSON.parse(checkEnv('CORS_ORIGINS')); 19 | export const CREDENTIALS = checkEnv('CREDENTIALS') === 'true'; 20 | 21 | export const isProduction = checkEnv('NODE_ENV') === 'production'; 22 | export const isTest = checkEnv('NODE_ENV') === 'test'; 23 | 24 | export const SENTRY_DSN = checkEnv('SENTRY_DSN'); 25 | 26 | export const jwt = { 27 | secret: checkEnv('JWT_SECRET'), 28 | accessExpireIn: checkEnv('JWT_ACCESS_EXPIRE_IN'), 29 | accessExpireFormat: checkEnv('JWT_ACCESS_EXPIRE_FORMAT'), 30 | refreshExpireIn: checkEnv('JWT_REFRESH_EXPIRE_IN'), 31 | refreshExpireFormat: checkEnv('JWT_REFRESH_EXPIRE_FORMAT'), 32 | resetPasswordExpireIn: checkEnv('JWT_RESET_PASSWORD_EXPIRE_IN'), 33 | resetPasswordExpireFormat: checkEnv('JWT_RESET_PASSWORD_EXPIRE_FORMAT'), 34 | }; 35 | 36 | export * from './passport'; 37 | -------------------------------------------------------------------------------- /src/config/passport.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy as JwtStrategy } from 'passport-jwt'; 2 | 3 | import { TokenTypes } from '@common/constants'; 4 | import Users from '@models/users.model'; 5 | 6 | import { jwt } from './index'; 7 | 8 | const jwtOptions = { 9 | secretOrKey: jwt.secret, 10 | 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | }; 13 | 14 | const jwtVerify = async (payload, done) => { 15 | try { 16 | if (payload.type !== TokenTypes.ACCESS) { 17 | throw new Error('Invalid token type'); 18 | } 19 | const user = await Users.findById(payload.sub); 20 | if (!user) { 21 | return done(null, false); 22 | } 23 | done(null, user); 24 | } catch (error) { 25 | done(error, false); 26 | } 27 | }; 28 | 29 | export const jwtStrategy = new JwtStrategy(jwtOptions, jwtVerify); 30 | -------------------------------------------------------------------------------- /src/exceptions/HttpException.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from 'routing-controllers'; 2 | 3 | export default class HttpException extends HttpError { 4 | public status: number; 5 | public message: string; 6 | 7 | constructor(status: number, message: string) { 8 | super(status, message); 9 | this.status = status; 10 | this.message = message; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { defaultMetadataStorage as classTransformerDefaultMetadataStorage } from 'class-transformer/cjs/storage'; 2 | import { validationMetadatasToSchemas } from 'class-validator-jsonschema'; 3 | import { getMetadataArgsStorage } from 'routing-controllers'; 4 | import { routingControllersToSpec } from 'routing-controllers-openapi'; 5 | import swaggerUi from 'swagger-ui-express'; 6 | 7 | import { AuthControllerV1, UserControllerV1 } from '@v1/index'; 8 | 9 | import App from './app'; 10 | 11 | function initSwagger(server: App) { 12 | const schemas = validationMetadatasToSchemas({ 13 | classTransformerMetadataStorage: classTransformerDefaultMetadataStorage, 14 | refPointerPrefix: '#/components/schemas/', 15 | }); 16 | const routingControllersOptions = { 17 | controllers: server.getControllers, 18 | }; 19 | const storage = getMetadataArgsStorage(); 20 | const spec = routingControllersToSpec(storage, routingControllersOptions, { 21 | components: { 22 | schemas, 23 | securitySchemes: { 24 | basicAuth: { 25 | scheme: 'basic', 26 | type: 'http', 27 | }, 28 | }, 29 | }, 30 | info: { 31 | description: 'API Generated with `routing-controllers-openapi` package', 32 | title: 'API', 33 | version: '1.0.0', 34 | }, 35 | }); 36 | server.getServer().use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec)); 37 | } 38 | 39 | const server = new App([AuthControllerV1, UserControllerV1]); 40 | initSwagger(server); 41 | 42 | (async () => { 43 | await server.initServerWithDB(); 44 | })(); 45 | 46 | const gracefulShutdown = async () => { 47 | try { 48 | await server.stopWebServer(); 49 | await App.closeDB(); 50 | 51 | console.log(`Process ${process.pid} received a graceful shutdown signal`); 52 | process.exit(0); 53 | } catch (error) { 54 | console.log(`graceful shutdown Process ${process.pid} got failed!`); 55 | process.exit(1); 56 | } 57 | }; 58 | 59 | process.on('SIGTERM', gracefulShutdown).on('SIGINT', gracefulShutdown); 60 | -------------------------------------------------------------------------------- /src/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import { UnauthorizedError } from 'routing-controllers'; 3 | 4 | const verifyCallback = (req, resolve, reject) => async (err, user, info) => { 5 | if (err || info || !user) { 6 | return reject(new UnauthorizedError('Please authenticate')); 7 | } 8 | req.user = user; 9 | 10 | resolve(); 11 | }; 12 | 13 | const auth = () => async (req, res, next) => { 14 | return new Promise((resolve, reject) => { 15 | passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject))(req, res, next); 16 | }) 17 | .then(() => next()) 18 | .catch(err => next(err)); 19 | }; 20 | 21 | export default auth; 22 | -------------------------------------------------------------------------------- /src/middlewares/handlingErrors.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { getReasonPhrase, StatusCodes } from 'http-status-codes'; 3 | const handlingErrors = (error: any, req: Request, res: Response, next: NextFunction) => { 4 | try { 5 | const statusCode: number = error.status || StatusCodes.INTERNAL_SERVER_ERROR; 6 | const message: string = error.message || getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR); 7 | 8 | res.status(statusCode).json({ message }); 9 | } catch (err) { 10 | next(err); 11 | } 12 | }; 13 | 14 | export default handlingErrors; 15 | -------------------------------------------------------------------------------- /src/middlewares/validation.middleware.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { validate, ValidationError } from 'class-validator'; 3 | import { RequestHandler } from 'express'; 4 | 5 | import HttpException from '@exceptions/HttpException'; 6 | 7 | const getAllNestedErrors = (error: ValidationError) => { 8 | if (error.constraints) { 9 | return Object.values(error.constraints); 10 | } 11 | return error.children.map(getAllNestedErrors).join(','); 12 | }; 13 | 14 | export const validationMiddleware = ( 15 | type: any, 16 | value: string | 'body' | 'query' | 'params' = 'body', 17 | skipMissingProperties = false, 18 | whitelist = true, 19 | forbidNonWhitelisted = true, 20 | ): RequestHandler => { 21 | return (req, res, next) => { 22 | const obj = plainToInstance(type, req[value]); 23 | validate(obj, { 24 | skipMissingProperties, 25 | whitelist, 26 | forbidNonWhitelisted, 27 | }).then((errors: ValidationError[]) => { 28 | if (errors.length > 0) { 29 | const message = errors.map(getAllNestedErrors).join(', '); 30 | next(new HttpException(400, message)); 31 | } else { 32 | next(); 33 | } 34 | }); 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/models/tokens.model.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsDate, IsString } from 'class-validator'; 2 | import mongoose, { Document, ObjectId, Schema } from 'mongoose'; 3 | 4 | import { MODELS, TokenTypes } from '@common/constants'; 5 | import ITimesStamp from '@common/interfaces/timestamp.interface'; 6 | import toJSON from '@utils/toJSON.plugin'; 7 | 8 | export class IToken extends ITimesStamp { 9 | @IsString() 10 | token: string; 11 | 12 | @IsString() 13 | userId: ObjectId; 14 | 15 | @IsString() 16 | type: string; 17 | 18 | @IsDate() 19 | expires: Date; 20 | 21 | @IsBoolean() 22 | blacklisted: boolean; 23 | } 24 | 25 | export interface ITokenSchema extends Document, IToken {} 26 | 27 | const tokenSchema: Schema = new Schema( 28 | { 29 | token: { 30 | type: String, 31 | required: true, 32 | index: true, 33 | }, 34 | userId: { 35 | type: Schema.Types.ObjectId, 36 | ref: MODELS.USERS, 37 | required: true, 38 | }, 39 | type: { 40 | type: String, 41 | enum: [TokenTypes.ACCESS, TokenTypes.REFRESH, TokenTypes.RESET_PASSWORD], 42 | required: true, 43 | }, 44 | expires: { 45 | type: Date, 46 | required: true, 47 | }, 48 | blacklisted: { 49 | type: Boolean, 50 | default: false, 51 | }, 52 | }, 53 | { 54 | timestamps: true, 55 | }, 56 | ); 57 | 58 | tokenSchema.plugin(toJSON); 59 | 60 | export default mongoose.model(MODELS.TOKENS, tokenSchema); 61 | -------------------------------------------------------------------------------- /src/models/users.model.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import { IsBoolean, IsEmail, IsString } from 'class-validator'; 3 | import mongoose, { Document, Schema } from 'mongoose'; 4 | 5 | import { MODELS } from '@common/constants'; 6 | import ITimesStamp from '@common/interfaces/timestamp.interface'; 7 | import toJSON from '@utils/toJSON.plugin'; 8 | 9 | export class IUser extends ITimesStamp { 10 | @IsString() 11 | username: string; 12 | 13 | @IsEmail() 14 | email: string; 15 | 16 | @IsString() 17 | password: string; 18 | 19 | @IsBoolean() 20 | isEmailVerified: boolean; 21 | } 22 | 23 | export interface IUserSchema extends Document, IUser {} 24 | 25 | const userSchema: Schema = new Schema( 26 | { 27 | username: { 28 | type: String, 29 | required: true, 30 | maxlength: 20, 31 | trim: true, 32 | }, 33 | email: { 34 | type: String, 35 | required: true, 36 | unique: true, 37 | trim: true, 38 | lowercase: true, 39 | }, 40 | password: { 41 | type: String, 42 | required: true, 43 | trim: true, 44 | minlength: 6, 45 | private: true, 46 | }, 47 | isEmailVerified: { 48 | type: Boolean, 49 | default: false, 50 | }, 51 | }, 52 | { 53 | timestamps: true, 54 | }, 55 | ); 56 | 57 | userSchema.pre('save', async function (next) { 58 | // eslint-disable-next-line @typescript-eslint/no-this-alias 59 | const user = this; 60 | if (user.isModified('password')) { 61 | user.password = await bcrypt.hash(user.password, 8); 62 | } 63 | next(); 64 | }); 65 | 66 | userSchema.plugin(toJSON); 67 | 68 | export default mongoose.model(MODELS.USERS, userSchema); 69 | -------------------------------------------------------------------------------- /src/services/v1/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError, UnauthorizedError } from 'routing-controllers'; 2 | 3 | import { TokenTypes } from '@common/constants'; 4 | import Tokens from '@models/tokens.model'; 5 | import { TokenService, UserService } from '@services/v1'; 6 | 7 | export class AuthService { 8 | private readonly tokenModel = Tokens; 9 | private readonly userService = new UserService(); 10 | private readonly tokenService = new TokenService(); 11 | 12 | async loginUserWithEmailAndPassword(email: string, password: string) { 13 | const user = await this.userService.getUserByEmail(email); 14 | 15 | if (!user || !(await this.userService.comparePassword(password, user.password))) { 16 | throw new UnauthorizedError('Invalid credentials'); 17 | } 18 | 19 | return user; 20 | } 21 | 22 | async logout(refreshToken: string) { 23 | const token = await this.tokenModel.findOne({ token: refreshToken, type: TokenTypes.REFRESH, blacklisted: false }); 24 | 25 | if (!token) { 26 | throw new NotFoundError('Not Found'); 27 | } 28 | 29 | await token.remove(); 30 | } 31 | 32 | async refreshAuth(refreshToken: string) { 33 | try { 34 | const refreshTokenDoc = await this.tokenService.verifyToken(refreshToken, TokenTypes.REFRESH); 35 | const user = await this.userService.getById(refreshTokenDoc.userId); 36 | if (!user) { 37 | throw new Error(); 38 | } 39 | 40 | await refreshTokenDoc.remove(); 41 | const tokens = await this.tokenService.generateAuthTokens(user); 42 | return { user, tokens }; 43 | } catch (error) { 44 | if (error.message === 'Token not found' || error.message === 'jwt expired') { 45 | throw new UnauthorizedError('Token not found'); 46 | } 47 | throw new UnauthorizedError('Please authenticate'); 48 | } 49 | } 50 | 51 | async resetPassword(token: string, password: string) { 52 | try { 53 | const tokenDoc = await this.tokenService.verifyToken(token, TokenTypes.RESET_PASSWORD); 54 | const user = await this.userService.getById(tokenDoc.userId); 55 | if (!user) { 56 | throw new NotFoundError('User not found'); 57 | } 58 | 59 | await this.userService.updateById(user.id, { password }); 60 | await this.tokenModel.deleteMany({ userId: user.id }); 61 | } catch (error) { 62 | if (error.message === 'Token not found' || error.message === 'jwt expired') { 63 | throw new UnauthorizedError('Token not found'); 64 | } 65 | throw error; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/services/v1/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.service'; 2 | export * from './token.service'; 3 | export * from './user.service'; 4 | -------------------------------------------------------------------------------- /src/services/v1/token.service.ts: -------------------------------------------------------------------------------- 1 | import jsonwebtoken from 'jsonwebtoken'; 2 | import moment from 'moment'; 3 | import { ObjectId } from 'mongoose'; 4 | import { NotFoundError } from 'routing-controllers'; 5 | 6 | import { TokenTypes } from '@common/constants'; 7 | import { jwt } from '@config'; 8 | import Tokens from '@models/tokens.model'; 9 | import { IUserSchema } from '@models/users.model'; 10 | 11 | import { UserService } from './user.service'; 12 | 13 | export class TokenService { 14 | private readonly userService = new UserService(); 15 | 16 | async generateAuthTokens(user: IUserSchema) { 17 | const accessTokenExpire = moment().add(jwt.accessExpireIn as moment.unitOfTime.DurationConstructor, jwt.accessExpireFormat); 18 | const accessToken = this.generateToken(user.id, accessTokenExpire.unix(), TokenTypes.ACCESS); 19 | 20 | const refreshTokenExpire = moment().add(jwt.refreshExpireIn as moment.unitOfTime.DurationConstructor, jwt.refreshExpireFormat); 21 | const refreshToken = this.generateToken(user.id, refreshTokenExpire.unix(), TokenTypes.REFRESH); 22 | 23 | await this.saveToken(refreshToken, user.id, refreshTokenExpire.toDate(), TokenTypes.REFRESH); 24 | 25 | return { 26 | access: { 27 | token: accessToken, 28 | expires: accessTokenExpire.unix(), 29 | }, 30 | refresh: { 31 | token: refreshToken, 32 | expire: refreshTokenExpire.unix(), 33 | }, 34 | }; 35 | } 36 | 37 | generateToken(userId: ObjectId, expire: number, type: string) { 38 | const payload = { 39 | sub: userId, 40 | iat: moment().unix(), 41 | exp: expire, 42 | type, 43 | }; 44 | 45 | return jsonwebtoken.sign(payload, jwt.secret); 46 | } 47 | 48 | async saveToken(token: string, userId: ObjectId, expires: Date, type: TokenTypes, blacklisted = false) { 49 | return await Tokens.create({ 50 | token, 51 | userId, 52 | expires, 53 | type, 54 | blacklisted, 55 | }); 56 | } 57 | 58 | async verifyToken(token: string, type: string) { 59 | const payload = jsonwebtoken.verify(token, jwt.secret); 60 | const tokenDoc = await Tokens.findOne({ token, type, userId: payload.sub, blacklisted: false }); 61 | if (!tokenDoc) { 62 | throw new Error('Token not found'); 63 | } 64 | return tokenDoc; 65 | } 66 | 67 | async generateResetPasswordToken(email: string) { 68 | const user = await this.userService.getUserByEmail(email); 69 | if (!user) { 70 | throw new NotFoundError('User not exists with this email'); 71 | } 72 | 73 | const expireIn = moment().add(jwt.resetPasswordExpireIn as moment.unitOfTime.DurationConstructor, jwt.resetPasswordExpireFormat); 74 | const resetPasswordToken = this.generateToken(user.id, expireIn.unix(), TokenTypes.RESET_PASSWORD); 75 | await this.saveToken(resetPasswordToken, user.id, expireIn.toDate(), TokenTypes.RESET_PASSWORD); 76 | 77 | return resetPasswordToken; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/services/v1/user.service.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import { ObjectId } from 'mongoose'; 3 | import { BadRequestError } from 'routing-controllers'; 4 | 5 | import CRUD from '@common/interfaces/crud.interface'; 6 | import Users, { IUser, IUserSchema } from '@models/users.model'; 7 | import RegisterDto from '@v1/auth/dtos/register.dto'; 8 | 9 | export class UserService implements CRUD { 10 | private readonly userModel = Users; 11 | 12 | async isEmailTaken(email: string): Promise { 13 | const user = await this.userModel.findOne({ email }); 14 | 15 | return !!user; 16 | } 17 | 18 | async createUser(userData: RegisterDto) { 19 | const { email } = userData; 20 | if (await this.isEmailTaken(email)) { 21 | throw new BadRequestError('Email already Taken'); 22 | } 23 | 24 | const user = await this.userModel.create({ ...userData }); 25 | return user; 26 | } 27 | 28 | async getUserByEmail(email: string) { 29 | return await this.userModel.findOne({ email }); 30 | } 31 | 32 | async comparePassword(inputPass: string, userPass: string) { 33 | return await bcrypt.compare(inputPass, userPass); 34 | } 35 | 36 | async getById(id: ObjectId): Promise { 37 | return await this.userModel.findById(id); 38 | } 39 | 40 | async updateById(id: ObjectId, updateBody: Partial): Promise { 41 | // prevent user change his email 42 | if (updateBody.email) { 43 | delete updateBody.email; 44 | } 45 | 46 | const user = await this.getById(id); 47 | if (!user) { 48 | throw new BadRequestError('User not found'); 49 | } 50 | 51 | Object.assign(user, updateBody); 52 | await user.save(); 53 | return user; 54 | } 55 | 56 | async findAll(limit = 10, page = 0) { 57 | const query = {}; 58 | const totalDocs = await this.userModel.countDocuments(query); 59 | const docs = await this.userModel 60 | .find(query) 61 | .limit(limit) 62 | .skip(limit * page) 63 | .sort({ createdAt: -1 }) 64 | .lean(); 65 | 66 | return { 67 | docs: JSON.parse(JSON.stringify(docs)), 68 | meta: { 69 | totalDocs, 70 | totalPages: Math.ceil(totalDocs / limit) || 0, 71 | page, 72 | }, 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/toJSON.plugin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | // eslint-disable-next-line 3 | // @ts-nocheck 4 | 5 | /** 6 | * A mongoose schema plugin which applies the following in the toJSON transform call: 7 | * - removes __v and any path that has private: true 8 | * - replaces _id with id 9 | */ 10 | 11 | const deleteAtPath = (obj: Document, path: string, index: number) => { 12 | if (index === path.length - 1) { 13 | delete obj[path[index]]; 14 | return; 15 | } 16 | deleteAtPath(obj[path[index]], path, index + 1); 17 | }; 18 | 19 | const toJSON = schema => { 20 | let transform; 21 | if (schema.options.toJSON && schema.options.toJSON.transform) { 22 | transform = schema.options.toJSON.transform; 23 | } 24 | 25 | schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { 26 | transform(doc, ret, options) { 27 | Object.keys(schema.paths).forEach(path => { 28 | if (schema.paths[path].options && schema.paths[path].options.private) { 29 | deleteAtPath(ret, path.split('.'), 0); 30 | } 31 | }); 32 | 33 | ret.id = ret._id.toString(); 34 | delete ret._id; 35 | delete ret.__v; 36 | if (transform) { 37 | return transform(doc, ret, options); 38 | } 39 | }, 40 | }); 41 | }; 42 | 43 | export default toJSON; 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "ES2017", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "outDir": "dist", 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "moduleResolution": "node", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "baseUrl": "src", 14 | "paths": { 15 | "@app": ["app"], 16 | "@config": ["config"], 17 | "@middlewares/*": ["middlewares/*"], 18 | "@models/*": ["models/*"], 19 | "@common/*": ["common/*"], 20 | "@exceptions/*": ["exceptions/*"], 21 | "@v1/*": ["api/v1/*"], 22 | "@utils/*": ["utils/*"], 23 | "@services/*": ["services/*"], 24 | "@__tests__/*": ["__tests__/*"] 25 | } 26 | } 27 | } 28 | --------------------------------------------------------------------------------