├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .github ├── actions │ └── setvars │ │ └── action.yml ├── dependabot.yml ├── variables │ └── myvars.env └── workflows │ ├── ci.yml │ └── dependabot-auto-merge.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc ├── .swcrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose-test.yml ├── docker-compose.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── bcrypt.service.ts │ ├── dto │ │ ├── sign-in.dto.ts │ │ └── sign-up.dto.ts │ └── guards │ │ └── jwt-auth.guard.ts ├── common │ ├── config │ │ ├── app.config.ts │ │ ├── database.config.ts │ │ ├── jwt.config.ts │ │ ├── redis.config.ts │ │ └── swagger.config.ts │ ├── constants │ │ └── index.ts │ ├── decorators │ │ ├── active-user.decorator.ts │ │ ├── match.decorator.ts │ │ └── public.decorator.ts │ ├── enums │ │ ├── environment.enum.ts │ │ └── error-codes.enum.ts │ ├── interceptors │ │ └── transform.interceptor.ts │ ├── interfaces │ │ └── active-user-data.interface.ts │ └── validation │ │ └── env.validation.ts ├── database │ └── database.module.ts ├── main.ts ├── metadata.ts ├── redis │ ├── redis.constants.ts │ ├── redis.module.ts │ └── redis.service.ts ├── swagger.ts └── users │ ├── entities │ └── user.entity.ts │ ├── users.controller.ts │ ├── users.module.ts │ └── users.service.ts ├── test ├── e2e │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── factories │ ├── app.factory.ts │ └── user.factory.ts └── unit │ ├── app.service.unit-spec.ts │ ├── auth │ ├── auth.service.unit-spec.ts │ ├── bcrypt.service.unit-spec.ts │ └── guards │ │ └── jwt-auth.guard.unit-spec.ts │ ├── jest-unit.json │ ├── redis │ └── redis.service.unit-spec.ts │ └── users │ └── users.service.unit-spec.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # Versioning and metadata 2 | .vscode 3 | .git 4 | .gitignore 5 | .dockerignore 6 | 7 | # Build dependencies 8 | dist 9 | node_modules 10 | coverage 11 | 12 | # Environment (contains sensitive data) 13 | .env 14 | 15 | # Files not required for production 16 | Dockerfile 17 | README.md 18 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # APPLICATION 2 | PORT=3000 3 | NODE_ENV=development 4 | 5 | # DATABASE 6 | DB_HOST=localhost 7 | DB_PORT=3306 8 | DB_USER=root 9 | DB_PASSWORD=root-password 10 | DB_NAME=nest-jwt-authentication 11 | 12 | # REDIS 13 | REDIS_HOST=localhost 14 | REDIS_PORT=6379 15 | REDIS_USERNAME= 16 | REDIS_PASSWORD= 17 | REDIS_DATABASE=1 18 | REDIS_KEY_PREFIX=nest-auth 19 | 20 | # JWT 21 | JWT_SECRET=your-secret-key 22 | JWT_ACCESS_TOKEN_TTL=3600 23 | 24 | # SWAGGER 25 | SWAGGER_SITE_TITLE=The NestJs Authentication API 26 | SWAGGER_DOC_TITLE=NestJs Authentication 27 | SWAGGER_DOC_DESCRIPTION=The NestJs Authentication API 28 | SWAGGER_DOC_VERSION=1.0 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/no-unused-vars': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.github/actions/setvars/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Set environment variables' 2 | description: 'Configures environment variables for a workflow' 3 | inputs: 4 | varFilePath: 5 | description: 'File path to variable file or directory. Defaults to ./.github/variables/* if none specified and runs against each file in that directory.' 6 | required: false 7 | default: ./.github/variables/* 8 | runs: 9 | using: "composite" 10 | steps: 11 | - run: | 12 | sed "" ${{ inputs.varFilePath }} >> $GITHUB_ENV 13 | shell: bash -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | open-pull-requests-limit: 10 8 | labels: 9 | - 'dependencies' 10 | - package-ecosystem: 'github-actions' 11 | directory: '/' 12 | schedule: 13 | interval: 'daily' 14 | -------------------------------------------------------------------------------- /.github/variables/myvars.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=3000 3 | 4 | DB_HOST=localhost 5 | DB_PORT=3306 6 | DB_USER=admin 7 | DB_PASSWORD=test123! 8 | DB_NAME=nestjs-auth 9 | 10 | REDIS_HOST=localhost 11 | REDIS_PORT=6379 12 | REDIS_USERNAME= 13 | REDIS_PASSWORD= 14 | REDIS_DATABASE=1 15 | REDIS_KEY_PREFIX=nestjs-auth 16 | 17 | JWT_SECRET=your-secret-key 18 | JWT_ACCESS_TOKEN_TTL=86400 19 | 20 | SWAGGER_SITE_TITLE=The NestJs Authentication API 21 | SWAGGER_DOC_TITLE=NestJs Authentication 22 | SWAGGER_DOC_DESCRIPTION=The NestJs Authentication API 23 | SWAGGER_DOC_VERSION=1.0 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Testing 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | 9 | jobs: 10 | unit-tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 20.x] 15 | test: [nestjs-authentication-and-authorization] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use NodeJS ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Setup npm 25 | run: npm install -g npm 26 | 27 | - name: Setup Nodejs with npm caching 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: npm 32 | 33 | - name: Install dependencies 34 | run: npm i 35 | 36 | - name: Run unit test 37 | run: npm run test:unit 38 | 39 | e2e-test: 40 | runs-on: ubuntu-latest 41 | strategy: 42 | matrix: 43 | node-version: [18.x, 20.x] 44 | needs: [unit-tests] 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Use Node.js ${{ matrix.node-version }} 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: ${{ matrix.node-version }} 51 | 52 | - name: Setup npm 53 | run: npm install -g npm 54 | 55 | - name: Setup Nodejs with npm caching 56 | uses: actions/setup-node@v4 57 | with: 58 | node-version: ${{ matrix.node-version }} 59 | cache: npm 60 | 61 | - name: Set Environment Variables 62 | uses: ./.github/actions/setvars 63 | with: 64 | varFilePath: ./.github/variables/myvars.env 65 | 66 | - name: Start Docker-Compose 67 | run: docker-compose -f docker-compose-test.yml up -d 68 | 69 | - name: Install dependencies 70 | run: npm i 71 | 72 | - name: Run tests 73 | run: npm run test:e2e 74 | 75 | - name: Stop Docker-Compose 76 | run: docker-compose -f docker-compose-test.yml down 77 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge PRs if CI Testing succeeds 2 | 3 | on: 4 | pull_request_target: 5 | workflow_run: 6 | workflows: ['CI Testing'] 7 | types: 8 | - completed 9 | 10 | permissions: 11 | pull-requests: write 12 | contents: write 13 | 14 | jobs: 15 | dependabot: 16 | runs-on: ubuntu-latest 17 | if: ${{ github.actor == 'dependabot[bot]' }} && ${{ github.event.workflow_run.conclusion == 'success' }} 18 | steps: 19 | - name: Dependabot metadata 20 | id: metadata 21 | uses: dependabot/fetch-metadata@v2.4.0 22 | with: 23 | github-token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Enable auto-merge for Dependabot PRs 26 | # if: contains(steps.metadata.outputs.dependency-names, 'my-dependency') && steps.metadata.outputs.update-type == 'version-update:semver-patch' 27 | run: gh pr merge --auto --rebase --delete-branch "${{ github.event.pull_request.html_url }}" 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # Environment 38 | .env 39 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run test:unit && npm run test:e2e 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{js,jsx,ts,tsx}": [ 3 | "eslint --fix", 4 | "prettier --config ./.prettierrc --write" 5 | ], 6 | "**/*.{css,scss,md,html,json}": ["prettier --config ./.prettierrc --write"] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "jsc": { 5 | "parser": { 6 | "syntax": "typescript", 7 | "decorators": true, 8 | "dynamicImport": true 9 | }, 10 | "transform": { 11 | "legacyDecorator": true, 12 | "decoratorMetadata": true 13 | }, 14 | "target": "es2017", 15 | "keepClassNames": true, 16 | "baseUrl": "./" 17 | }, 18 | "module": { 19 | "type": "commonjs", 20 | "strictMode": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ################### 2 | # BUILD FOR LOCAL DEVELOPMENT 3 | ################### 4 | 5 | FROM node:18-alpine As development 6 | 7 | # Create app directory 8 | WORKDIR /usr/src/app 9 | 10 | # Copy application dependency manifests to the container image. 11 | # A wildcard is used to ensure copying both package.json AND package-lock.json (when available). 12 | # Copying this first prevents re-running npm install on every code change. 13 | COPY --chown=node:node package*.json ./ 14 | 15 | # Install app dependencies using the `npm ci` command instead of `npm install` 16 | RUN npm ci 17 | 18 | # Bundle app source 19 | COPY --chown=node:node . . 20 | 21 | # Use the node user from the image (instead of the root user) 22 | USER node 23 | 24 | ################### 25 | # BUILD FOR PRODUCTION 26 | ################### 27 | 28 | FROM node:18-alpine As build 29 | 30 | WORKDIR /usr/src/app 31 | 32 | COPY --chown=node:node package*.json ./ 33 | 34 | # In order to run `npm run build` we need access to the Nest CLI. 35 | # The Nest CLI is a dev dependency, 36 | # In the previous development stage we ran `npm ci` which installed all dependencies. 37 | # So we can copy over the node_modules directory from the development image into this build image. 38 | COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules 39 | 40 | COPY --chown=node:node . . 41 | 42 | # Run the build command which creates the production bundle 43 | RUN npm run build 44 | 45 | # Set NODE_ENV environment variable 46 | ENV NODE_ENV production 47 | 48 | # Running `npm ci` removes the existing node_modules directory. 49 | # Passing in --only=production ensures that only the production dependencies are installed. 50 | # This ensures that the node_modules directory is as optimized as possible. 51 | RUN npm ci --only=production && npm cache clean --force 52 | 53 | USER node 54 | 55 | ################### 56 | # PRODUCTION 57 | ################### 58 | 59 | FROM node:18-alpine As production 60 | 61 | # Copy the bundled code from the build stage to the production image 62 | COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules 63 | COPY --chown=node:node --from=build /usr/src/app/dist ./dist 64 | 65 | # Start the server using the production build 66 | CMD [ "node", "dist/main.js" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Anil Ahir 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 | # NestJS Authentication 2 | 3 | ![Workflow Test](https://github.com/anilahir/nestjs-authentication-and-authorization/actions/workflows/ci.yml/badge.svg) 4 | ![Prettier](https://img.shields.io/badge/Code%20style-prettier-informational?logo=prettier&logoColor=white) 5 | [![GPL v3 License](https://img.shields.io/badge/License-GPLv3-green.svg)](./LICENSE) 6 | [![HitCount](https://hits.dwyl.com/anilahir/nestjs-authentication-and-authorization.svg)](https://hits.dwyl.com/anilahir/nestjs-authentication-and-authorization) 7 | 8 | ## Description 9 | 10 | NestJS Authentication without Passport using Bcrypt, JWT and Redis 11 | 12 | ## Features 13 | 14 | 1. Register 15 | 2. Login 16 | 3. Show profile 17 | 4. Logout 18 | 19 | ## Technologies stack: 20 | 21 | - JWT 22 | - Bcrypt 23 | - TypeORM + MySQL 24 | - Redis 25 | - Docker 26 | 27 | ## Setup 28 | 29 | ### 1. Install the required dependencies 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ### 2. Rename the .env.example filename to .env and set your local variables 36 | 37 | ```bash 38 | $ mv .env.example .env 39 | ``` 40 | 41 | ### 3. Start the application 42 | 43 | ```bash 44 | # development 45 | $ npm run start 46 | 47 | # watch mode 48 | $ npm run start:dev 49 | 50 | # production mode 51 | $ npm run start:prod 52 | ``` 53 | 54 | ## Docker for development 55 | 56 | ```bash 57 | # start the application 58 | $ npm run docker:up 59 | 60 | # stop the application 61 | $ npm run docker:down 62 | ``` 63 | 64 | ## Swagger documentation 65 | 66 | - [localhost:3000/docs](http://localhost:3000/docs) 67 | 68 | ## References 69 | 70 | - [NestJS Authentication without Passport](https://trilon.io/blog/nestjs-authentication-without-passport) 71 | - [NestJS, Redis and Postgres local development with Docker Compose](https://www.tomray.dev/nestjs-docker-compose-postgres) 72 | 73 | ## Author 74 | 75 | 👤 **Anil Ahir** 76 | 77 | - Twitter: [@anilahir220](https://twitter.com/anilahir220) 78 | - Github: [@anilahir](https://github.com/anilahir) 79 | - LinkedIn: [@anilahir](https://www.linkedin.com/in/anilahir) 80 | 81 | ## Show your support 82 | 83 | Give a ⭐️ if this project helped you! 84 | 85 | ## Related projects 86 | 87 | Explore more NestJS example projects: 88 | 89 | [![GraphQL example](https://github-readme-stats.vercel.app/api/pin/?username=anilahir&repo=nestjs-graphql-demo)](https://github.com/anilahir/nestjs-graphql-demo) 90 | 91 | ## License 92 | 93 | Release under the terms of [MIT](./LICENSE) 94 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mysql: 3 | image: mysql:8.0 4 | ports: 5 | - 3306:3306 6 | environment: 7 | MYSQL_RANDOM_ROOT_PASSWORD: 'true' 8 | MYSQL_USER: admin 9 | MYSQL_PASSWORD: test123! 10 | MYSQL_DATABASE: nestjs-auth 11 | TZ: 'utc' 12 | command: --default-authentication-plugin=mysql_native_password 13 | 14 | redis: 15 | image: redis:alpine 16 | ports: 17 | - 6379:6379 18 | 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nestjs-auth-api: 3 | container_name: nestjs-auth-api 4 | image: nestjs-auth-api 5 | restart: unless-stopped 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | target: development # Only will build development stage from our dockerfile 10 | volumes: 11 | - ./:/usr/src/app 12 | ports: 13 | - ${PORT}:${PORT} 14 | networks: 15 | - nestjs-auth-intranet 16 | env_file: 17 | - .env # Available inside container not in compose file 18 | environment: 19 | - DB_HOST=nestjs-auth-mysql 20 | - REDIS_HOST=nestjs-auth-redis 21 | depends_on: 22 | nestjs-auth-mysql: 23 | condition: service_healthy 24 | nestjs-auth-redis: 25 | condition: service_healthy 26 | command: npm run start:dev # Run in development mode 27 | 28 | nestjs-auth-mysql: 29 | container_name: nestjs-auth-mysql 30 | image: mysql:8.0 31 | restart: unless-stopped 32 | volumes: 33 | - mysql:/var/lib/mysql 34 | ports: 35 | - 3307:${DB_PORT} 36 | networks: 37 | - nestjs-auth-intranet 38 | environment: 39 | MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} 40 | MYSQL_DATABASE: ${DB_NAME} 41 | MYSQL_USER: ${DB_USER} 42 | MYSQL_PASSWORD: ${DB_PASSWORD} 43 | TZ: 'utc' 44 | command: --default-authentication-plugin=mysql_native_password 45 | healthcheck: 46 | test: ['CMD', 'mysqladmin', '-u${DB_USER}', '-p${DB_PASSWORD}', 'ping'] 47 | interval: 5s 48 | retries: 3 49 | timeout: 3s 50 | 51 | nestjs-auth-redis: 52 | container_name: nestjs-auth-redis 53 | image: redis:alpine 54 | restart: unless-stopped 55 | volumes: 56 | - redis:/data 57 | ports: 58 | - 6380:${REDIS_PORT} 59 | networks: 60 | - nestjs-auth-intranet 61 | healthcheck: 62 | test: ['CMD', 'redis-cli', 'ping'] 63 | interval: 5s 64 | retries: 3 65 | timeout: 3s 66 | 67 | volumes: 68 | mysql: 69 | name: nestjs-auth-mysql 70 | redis: 71 | name: nestjs-auth-redis 72 | 73 | networks: 74 | nestjs-auth-intranet: 75 | name: nestjs-auth-intranet 76 | driver: bridge 77 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "plugins": [ 7 | { 8 | "name": "@nestjs/swagger", 9 | "options": { 10 | "dtoFileNameSuffix": [".entity.ts", ".dto.ts"], 11 | "controllerFileNameSuffix": [".controller.ts"], 12 | "classValidatorShim": true, 13 | "dtoKeyOfComment": "description", 14 | "controllerKeyOfComment": "description", 15 | "introspectComments": true 16 | } 17 | } 18 | ], 19 | "builder": "swc", 20 | "typeCheck": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-authentication-and-authorization", 3 | "version": "0.0.1", 4 | "description": "NestJS authentication and authorization using Bcrypt, JWT & Redis", 5 | "author": "Anil Ahir", 6 | "private": false, 7 | "license": "MIT", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test:watch": "jest --config ./test/unit/jest-unit.json --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "NODE_ENV=test jest --config ./test/e2e/jest-e2e.json --runInBand --detectOpenHandles", 21 | "test:unit": "NODE_ENV=test jest --config ./test/unit/jest-unit.json --runInBand", 22 | "prepare": "husky install", 23 | "docker:up": "docker-compose up -d -V --build", 24 | "docker:down": "docker-compose down" 25 | }, 26 | "dependencies": { 27 | "@nestjs/common": "^11.1.2", 28 | "@nestjs/config": "^4.0.2", 29 | "@nestjs/core": "^11.1.2", 30 | "@nestjs/jwt": "^11.0.0", 31 | "@nestjs/mapped-types": "*", 32 | "@nestjs/platform-express": "^11.1.2", 33 | "@nestjs/swagger": "^11.2.0", 34 | "@nestjs/typeorm": "^11.0.0", 35 | "bcrypt": "^6.0.0", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.14.2", 38 | "ioredis": "^5.6.1", 39 | "mysql2": "^3.14.1", 40 | "reflect-metadata": "^0.2.2", 41 | "rimraf": "^6.0.1", 42 | "rxjs": "^7.8.2", 43 | "swagger-ui-express": "^5.0.1", 44 | "typeorm": "^0.3.24" 45 | }, 46 | "devDependencies": { 47 | "@golevelup/ts-jest": "^0.7.0", 48 | "@nestjs/cli": "^11.0.7", 49 | "@nestjs/schematics": "^11.0.5", 50 | "@nestjs/testing": "^11.1.2", 51 | "@swc/cli": "^0.7.7", 52 | "@swc/core": "^1.11.29", 53 | "@swc/jest": "^0.2.38", 54 | "@types/bcrypt": "^5.0.2", 55 | "@types/express": "^5.0.2", 56 | "@types/jest": "29.5.14", 57 | "@types/node": "^22.15.29", 58 | "@types/supertest": "^6.0.3", 59 | "@typescript-eslint/eslint-plugin": "^8.33.1", 60 | "@typescript-eslint/parser": "^8.33.1", 61 | "eslint": "^9.28.0", 62 | "eslint-config-prettier": "^10.1.5", 63 | "eslint-plugin-prettier": "^5.4.1", 64 | "husky": "^9.1.7", 65 | "jest": "^29.7.0", 66 | "lint-staged": "^16.1.0", 67 | "prettier": "^3.5.3", 68 | "source-map-support": "^0.5.21", 69 | "supertest": "^7.1.1", 70 | "ts-jest": "29.3.4", 71 | "ts-loader": "^9.5.2", 72 | "ts-node": "^10.9.2", 73 | "tsconfig-paths": "4.2.0", 74 | "typescript": "^5.8.3" 75 | }, 76 | "engines": { 77 | "node": ">= 18" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "src", 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s?$": [ 89 | "@swc/jest" 90 | ] 91 | }, 92 | "collectCoverageFrom": [ 93 | "**/*.(t|j)s" 94 | ], 95 | "coverageDirectory": "../coverage", 96 | "testEnvironment": "node" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { ApiOkResponse } from '@nestjs/swagger'; 3 | 4 | import { AppService } from './app.service'; 5 | import { Public } from './common/decorators/public.decorator'; 6 | 7 | @Controller() 8 | export class AppController { 9 | constructor(private readonly appService: AppService) {} 10 | 11 | @ApiOkResponse({ description: "Returns 'Hello World'" }) 12 | @Public() 13 | @Get() 14 | getHello(): string { 15 | return this.appService.getHello(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { APP_GUARD } from '@nestjs/core'; 4 | 5 | import { AppController } from './app.controller'; 6 | import { AppService } from './app.service'; 7 | import { AuthModule } from './auth/auth.module'; 8 | import appConfig from './common/config/app.config'; 9 | import databaseConfig from './common/config/database.config'; 10 | import jwtConfig from './common/config/jwt.config'; 11 | import { validate } from './common/validation/env.validation'; 12 | import { DatabaseModule } from './database/database.module'; 13 | import { UsersModule } from './users/users.module'; 14 | import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; 15 | import redisConfig from './common/config/redis.config'; 16 | import { RedisModule } from './redis/redis.module'; 17 | import swaggerConfig from './common/config/swagger.config'; 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigModule.forRoot({ 22 | isGlobal: true, 23 | load: [appConfig, jwtConfig, databaseConfig, redisConfig, swaggerConfig], 24 | validate, 25 | }), 26 | DatabaseModule, 27 | RedisModule, 28 | AuthModule, 29 | UsersModule, 30 | ], 31 | controllers: [AppController], 32 | providers: [ 33 | AppService, 34 | { 35 | provide: APP_GUARD, 36 | useClass: JwtAuthGuard, 37 | }, 38 | ], 39 | }) 40 | export class AppModule {} 41 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiBearerAuth, 5 | ApiConflictResponse, 6 | ApiCreatedResponse, 7 | ApiOkResponse, 8 | ApiTags, 9 | ApiUnauthorizedResponse, 10 | } from '@nestjs/swagger'; 11 | 12 | import { ActiveUser } from '../common/decorators/active-user.decorator'; 13 | import { Public } from '../common/decorators/public.decorator'; 14 | import { AuthService } from './auth.service'; 15 | import { SignInDto } from './dto/sign-in.dto'; 16 | import { SignUpDto } from './dto/sign-up.dto'; 17 | 18 | @ApiTags('auth') 19 | @Controller('auth') 20 | export class AuthController { 21 | constructor(private readonly authService: AuthService) {} 22 | 23 | @ApiConflictResponse({ 24 | description: 'User already exists', 25 | }) 26 | @ApiBadRequestResponse({ 27 | description: 'Return errors for invalid sign up fields', 28 | }) 29 | @ApiCreatedResponse({ 30 | description: 'User has been successfully signed up', 31 | }) 32 | @Public() 33 | @Post('sign-up') 34 | signUp(@Body() signUpDto: SignUpDto): Promise { 35 | return this.authService.signUp(signUpDto); 36 | } 37 | 38 | @ApiBadRequestResponse({ 39 | description: 'Return errors for invalid sign in fields', 40 | }) 41 | @ApiOkResponse({ description: 'User has been successfully signed in' }) 42 | @HttpCode(HttpStatus.OK) 43 | @Public() 44 | @Post('sign-in') 45 | signIn(@Body() signInDto: SignInDto): Promise<{ accessToken: string }> { 46 | return this.authService.signIn(signInDto); 47 | } 48 | 49 | @ApiUnauthorizedResponse({ description: 'Unauthorized' }) 50 | @ApiOkResponse({ description: 'User has been successfully signed out' }) 51 | @ApiBearerAuth() 52 | @HttpCode(HttpStatus.OK) 53 | @Post('sign-out') 54 | signOut(@ActiveUser('id') userId: string): Promise { 55 | return this.authService.signOut(userId); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { AuthService } from './auth.service'; 6 | import { AuthController } from './auth.controller'; 7 | import { BcryptService } from './bcrypt.service'; 8 | import { User } from '../users/entities/user.entity'; 9 | import jwtConfig from '../common/config/jwt.config'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([User]), 14 | JwtModule.registerAsync(jwtConfig.asProvider()), 15 | ], 16 | controllers: [AuthController], 17 | providers: [AuthService, BcryptService], 18 | exports: [JwtModule], 19 | }) 20 | export class AuthModule {} 21 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ConflictException, 4 | Inject, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { ConfigType } from '@nestjs/config'; 8 | import { JwtService } from '@nestjs/jwt'; 9 | import { InjectRepository } from '@nestjs/typeorm'; 10 | import { randomUUID } from 'crypto'; 11 | import { Repository } from 'typeorm'; 12 | 13 | import jwtConfig from '../common/config/jwt.config'; 14 | import { MysqlErrorCode } from '../common/enums/error-codes.enum'; 15 | import { ActiveUserData } from '../common/interfaces/active-user-data.interface'; 16 | import { RedisService } from '../redis/redis.service'; 17 | import { User } from '../users/entities/user.entity'; 18 | import { BcryptService } from './bcrypt.service'; 19 | import { SignInDto } from './dto/sign-in.dto'; 20 | import { SignUpDto } from './dto/sign-up.dto'; 21 | 22 | @Injectable() 23 | export class AuthService { 24 | constructor( 25 | @Inject(jwtConfig.KEY) 26 | private readonly jwtConfiguration: ConfigType, 27 | private readonly bcryptService: BcryptService, 28 | private readonly jwtService: JwtService, 29 | @InjectRepository(User) 30 | private readonly userRepository: Repository, 31 | private readonly redisService: RedisService, 32 | ) {} 33 | 34 | async signUp(signUpDto: SignUpDto): Promise { 35 | const { email, password } = signUpDto; 36 | 37 | try { 38 | const user = new User(); 39 | user.email = email; 40 | user.password = await this.bcryptService.hash(password); 41 | await this.userRepository.save(user); 42 | } catch (error) { 43 | if (error.code === MysqlErrorCode.UniqueViolation) { 44 | throw new ConflictException(`User [${email}] already exist`); 45 | } 46 | throw error; 47 | } 48 | } 49 | 50 | async signIn(signInDto: SignInDto): Promise<{ accessToken: string }> { 51 | const { email, password } = signInDto; 52 | 53 | const user = await this.userRepository.findOne({ 54 | where: { 55 | email, 56 | }, 57 | }); 58 | if (!user) { 59 | throw new BadRequestException('Invalid email'); 60 | } 61 | 62 | const isPasswordMatch = await this.bcryptService.compare( 63 | password, 64 | user.password, 65 | ); 66 | if (!isPasswordMatch) { 67 | throw new BadRequestException('Invalid password'); 68 | } 69 | 70 | return await this.generateAccessToken(user); 71 | } 72 | 73 | async signOut(userId: string): Promise { 74 | this.redisService.delete(`user-${userId}`); 75 | } 76 | 77 | async generateAccessToken( 78 | user: Partial, 79 | ): Promise<{ accessToken: string }> { 80 | const tokenId = randomUUID(); 81 | 82 | await this.redisService.insert(`user-${user.id}`, tokenId); 83 | 84 | const accessToken = await this.jwtService.signAsync( 85 | { 86 | id: user.id, 87 | email: user.email, 88 | tokenId, 89 | } as ActiveUserData, 90 | { 91 | secret: this.jwtConfiguration.secret, 92 | expiresIn: this.jwtConfiguration.accessTokenTtl, 93 | }, 94 | ); 95 | 96 | return { accessToken }; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/auth/bcrypt.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { compare, genSalt, hash } from 'bcrypt'; 3 | 4 | @Injectable() 5 | export class BcryptService { 6 | async hash(data: string): Promise { 7 | const salt = await genSalt(); 8 | return hash(data, salt); 9 | } 10 | 11 | async compare(data: string, encrypted: string): Promise { 12 | return compare(data, encrypted); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/dto/sign-in.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsEmail, 4 | IsNotEmpty, 5 | Matches, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | export class SignInDto { 11 | @ApiProperty({ 12 | description: 'Email of user', 13 | example: 'atest@email.com', 14 | }) 15 | @IsEmail() 16 | @MaxLength(255) 17 | @IsNotEmpty() 18 | readonly email: string; 19 | 20 | @ApiProperty({ 21 | description: 'Password of user', 22 | example: 'Pass#123', 23 | }) 24 | @MinLength(8, { 25 | message: 'password too short', 26 | }) 27 | @MaxLength(20, { 28 | message: 'password too long', 29 | }) 30 | @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, { 31 | message: 'password too weak', 32 | }) 33 | @IsNotEmpty() 34 | readonly password: string; 35 | } 36 | -------------------------------------------------------------------------------- /src/auth/dto/sign-up.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsEmail, 4 | IsNotEmpty, 5 | Matches, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | import { Match } from '../../common/decorators/match.decorator'; 11 | 12 | export class SignUpDto { 13 | @ApiProperty({ 14 | example: 'atest@email.com', 15 | description: 'Email of user', 16 | }) 17 | @IsEmail() 18 | @MaxLength(255) 19 | @IsNotEmpty() 20 | readonly email: string; 21 | 22 | @ApiProperty({ 23 | description: 'Password of user', 24 | example: 'Pass#123', 25 | }) 26 | @MinLength(8, { 27 | message: 'password too short', 28 | }) 29 | @MaxLength(20, { 30 | message: 'password too long', 31 | }) 32 | @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, { 33 | message: 'password too weak', 34 | }) 35 | @IsNotEmpty() 36 | readonly password: string; 37 | 38 | @ApiProperty({ 39 | description: 'Repeat same value as in password field', 40 | example: 'Pass#123', 41 | }) 42 | @Match('password') 43 | @IsNotEmpty() 44 | readonly passwordConfirm: string; 45 | } 46 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Inject, 5 | Injectable, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { ConfigType } from '@nestjs/config'; 9 | import { Reflector } from '@nestjs/core'; 10 | import { JwtService } from '@nestjs/jwt'; 11 | import { Request } from 'express'; 12 | 13 | import jwtConfig from '../../common/config/jwt.config'; 14 | import { REQUEST_USER_KEY } from '../../common/constants'; 15 | import { ActiveUserData } from '../../common/interfaces/active-user-data.interface'; 16 | import { RedisService } from '../../redis/redis.service'; 17 | 18 | @Injectable() 19 | export class JwtAuthGuard implements CanActivate { 20 | constructor( 21 | @Inject(jwtConfig.KEY) 22 | private readonly jwtConfiguration: ConfigType, 23 | private readonly jwtService: JwtService, 24 | private readonly redisService: RedisService, 25 | private reflector: Reflector, 26 | ) {} 27 | 28 | async canActivate(context: ExecutionContext): Promise { 29 | const isPublic = this.reflector.getAllAndOverride('isPublic', [ 30 | context.getHandler(), 31 | context.getClass(), 32 | ]); 33 | if (isPublic) { 34 | return true; 35 | } 36 | 37 | const request = context.switchToHttp().getRequest(); 38 | const token = this.getToken(request); 39 | if (!token) { 40 | throw new UnauthorizedException('Authorization token is required'); 41 | } 42 | 43 | try { 44 | const payload = await this.jwtService.verifyAsync( 45 | token, 46 | this.jwtConfiguration, 47 | ); 48 | 49 | const isValidToken = await this.redisService.validate( 50 | `user-${payload.id}`, 51 | payload.tokenId, 52 | ); 53 | if (!isValidToken) { 54 | throw new UnauthorizedException('Authorization token is not valid'); 55 | } 56 | 57 | request[REQUEST_USER_KEY] = payload; 58 | } catch (error) { 59 | throw new UnauthorizedException(error.message); 60 | } 61 | 62 | return true; 63 | } 64 | 65 | private getToken(request: Request) { 66 | const [_, token] = request.headers.authorization?.split(' ') ?? []; 67 | return token; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/common/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('app', () => { 4 | return { 5 | nodeEnv: process.env.NODE_ENV || 'development', 6 | port: parseInt(process.env.PORT, 10) || 3000, 7 | }; 8 | }); 9 | -------------------------------------------------------------------------------- /src/common/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('database', () => { 4 | return { 5 | type: 'mysql', 6 | host: process.env.DB_HOST || 'localhost', 7 | port: parseInt(process.env.DB_PORT, 10) || 3306, 8 | username: process.env.DB_USER, 9 | password: process.env.DB_PASSWORD, 10 | name: process.env.DB_NAME, 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/common/config/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('jwt', () => { 4 | return { 5 | secret: process.env.JWT_SECRET, 6 | accessTokenTtl: process.env.JWT_ACCESS_TOKEN_TTL, 7 | }; 8 | }); 9 | -------------------------------------------------------------------------------- /src/common/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('redis', () => { 4 | return { 5 | host: process.env.REDIS_HOST, 6 | port: parseInt(process.env.REDIS_PORT, 10) || 6379, 7 | db: parseInt(process.env.REDIS_DATABASE, 10), 8 | keyPrefix: process.env.REDIS_KEY_PREFIX + ':', 9 | ...(process.env.REDIS_USERNAME && { 10 | username: process.env.REDIS_USERNAME, 11 | }), 12 | ...(process.env.REDIS_PASSWORD && { 13 | password: process.env.REDIS_PASSWORD, 14 | }), 15 | }; 16 | }); 17 | -------------------------------------------------------------------------------- /src/common/config/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('swagger', () => { 4 | return { 5 | siteTitle: process.env.SWAGGER_SITE_TITLE, 6 | docTitle: process.env.SWAGGER_DOC_TITLE, 7 | docDescription: process.env.SWAGGER_DOC_DESCRIPTION, 8 | docVersion: process.env.SWAGGER_DOC_VERSION, 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/common/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const REQUEST_USER_KEY = 'user'; 2 | -------------------------------------------------------------------------------- /src/common/decorators/active-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | import { REQUEST_USER_KEY } from '../constants'; 4 | import { ActiveUserData } from '../interfaces/active-user-data.interface'; 5 | 6 | export const ActiveUser = createParamDecorator( 7 | (field: keyof ActiveUserData | undefined, ctx: ExecutionContext) => { 8 | const request = ctx.switchToHttp().getRequest(); 9 | const user: ActiveUserData | undefined = request[REQUEST_USER_KEY]; 10 | return field ? user?.[field] : user; 11 | }, 12 | ); 13 | -------------------------------------------------------------------------------- /src/common/decorators/match.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | ValidatorConstraint, 6 | ValidatorConstraintInterface, 7 | } from 'class-validator'; 8 | 9 | export function Match(property: string, validationOptions?: ValidationOptions) { 10 | return (object: any, propertyName: string) => { 11 | registerDecorator({ 12 | target: object.constructor, 13 | propertyName, 14 | options: validationOptions, 15 | constraints: [property], 16 | validator: MatchConstraint, 17 | }); 18 | }; 19 | } 20 | 21 | @ValidatorConstraint({ name: 'Match' }) 22 | export class MatchConstraint implements ValidatorConstraintInterface { 23 | validate(value: any, args: ValidationArguments) { 24 | const [relatedPropertyName] = args.constraints; 25 | const relatedValue = (args.object as any)[relatedPropertyName]; 26 | return value === relatedValue; 27 | } 28 | defaultMessage(args: ValidationArguments) { 29 | return args.property + ' must match ' + args.constraints[0]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/common/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, CustomDecorator } from '@nestjs/common'; 2 | 3 | export const Public = (): CustomDecorator => SetMetadata('isPublic', true); 4 | -------------------------------------------------------------------------------- /src/common/enums/environment.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Environment { 2 | Development = 'development', 3 | Production = 'production', 4 | Test = 'test', 5 | } 6 | -------------------------------------------------------------------------------- /src/common/enums/error-codes.enum.ts: -------------------------------------------------------------------------------- 1 | export enum MysqlErrorCode { 2 | UniqueViolation = 'ER_DUP_ENTRY', 3 | } 4 | -------------------------------------------------------------------------------- /src/common/interceptors/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { instanceToPlain } from 'class-transformer'; 8 | import { map, Observable } from 'rxjs'; 9 | 10 | @Injectable() 11 | export class TransformInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | return next.handle().pipe(map((data) => instanceToPlain(data))); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/interfaces/active-user-data.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ActiveUserData { 2 | id: string; 3 | email: string; 4 | tokenId: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/validation/env.validation.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { 3 | IsEnum, 4 | IsNotEmpty, 5 | IsNumber, 6 | IsOptional, 7 | IsString, 8 | validateSync, 9 | } from 'class-validator'; 10 | 11 | import { Environment } from '../enums/environment.enum'; 12 | 13 | class EnvironmentVariables { 14 | @IsEnum(Environment) 15 | NODE_ENV: Environment; 16 | 17 | @IsNumber() 18 | @IsNotEmpty() 19 | PORT: number; 20 | 21 | @IsString() 22 | @IsNotEmpty() 23 | DB_HOST: string; 24 | 25 | @IsNumber() 26 | @IsNotEmpty() 27 | DB_PORT: number; 28 | 29 | @IsString() 30 | @IsNotEmpty() 31 | DB_USER: string; 32 | 33 | @IsString() 34 | @IsNotEmpty() 35 | DB_PASSWORD: string; 36 | 37 | @IsString() 38 | @IsNotEmpty() 39 | DB_NAME: string; 40 | 41 | @IsString() 42 | @IsNotEmpty() 43 | REDIS_HOST: string; 44 | 45 | @IsNumber() 46 | @IsNotEmpty() 47 | REDIS_PORT: number; 48 | 49 | @IsString() 50 | @IsOptional() 51 | REDIS_USERNAME: string; 52 | 53 | @IsString() 54 | @IsOptional() 55 | REDIS_PASSWORD: string; 56 | 57 | @IsNumber() 58 | @IsNotEmpty() 59 | REDIS_DATABASE: number; 60 | 61 | @IsString() 62 | @IsNotEmpty() 63 | REDIS_KEY_PREFIX: string; 64 | 65 | @IsString() 66 | @IsNotEmpty() 67 | JWT_SECRET: string; 68 | 69 | @IsNotEmpty() 70 | @IsNumber() 71 | JWT_ACCESS_TOKEN_TTL: number; 72 | 73 | @IsString() 74 | @IsNotEmpty() 75 | SWAGGER_SITE_TITLE: string; 76 | 77 | @IsString() 78 | @IsNotEmpty() 79 | SWAGGER_DOC_TITLE: string; 80 | 81 | @IsString() 82 | @IsNotEmpty() 83 | SWAGGER_DOC_DESCRIPTION: string; 84 | 85 | @IsString() 86 | @IsNotEmpty() 87 | SWAGGER_DOC_VERSION: string; 88 | } 89 | 90 | export function validate(config: Record) { 91 | const validatedConfig = plainToInstance(EnvironmentVariables, config, { 92 | enableImplicitConversion: true, 93 | }); 94 | const errors = validateSync(validatedConfig, { 95 | skipMissingProperties: false, 96 | }); 97 | 98 | let errorMessage = errors 99 | .map((message) => message.constraints[Object.keys(message.constraints)[0]]) 100 | .join('\n'); 101 | 102 | const COLOR = { 103 | reset: '\x1b[0m', 104 | bright: '\x1b[1m', 105 | fgRed: '\x1b[31m', 106 | }; 107 | 108 | errorMessage = `${COLOR.fgRed}${COLOR.bright}${errorMessage}${COLOR.reset}`; 109 | 110 | if (errors.length > 0) { 111 | throw new Error(errorMessage); 112 | } 113 | 114 | return validatedConfig; 115 | } 116 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | @Module({ 6 | imports: [ 7 | TypeOrmModule.forRootAsync({ 8 | imports: [ConfigModule], 9 | inject: [ConfigService], 10 | useFactory: async (configService: ConfigService) => ({ 11 | type: 'mysql', 12 | host: configService.get('database.host'), 13 | port: configService.get('database.port'), 14 | username: configService.get('database.username'), 15 | password: configService.get('database.password'), 16 | database: configService.get('database.name'), 17 | autoLoadEntities: true, 18 | synchronize: true, 19 | logging: false, 20 | }), 21 | }), 22 | ], 23 | }) 24 | export class DatabaseModule {} 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | 5 | import { AppModule } from './app.module'; 6 | import { TransformInterceptor } from './common/interceptors/transform.interceptor'; 7 | import { setupSwagger } from './swagger'; 8 | 9 | async function bootstrap() { 10 | const app = await NestFactory.create(AppModule); 11 | 12 | setupSwagger(app); 13 | 14 | app.useGlobalInterceptors(new TransformInterceptor()); 15 | 16 | app.useGlobalPipes( 17 | new ValidationPipe({ 18 | transform: true, 19 | whitelist: true, 20 | forbidUnknownValues: true, 21 | stopAtFirstError: true, 22 | validateCustomDecorators: true, 23 | }), 24 | ); 25 | 26 | const configService = app.get(ConfigService); 27 | 28 | const port = configService.get('PORT'); 29 | 30 | await app.listen(port, () => { 31 | console.log(`Application running at ${port}`); 32 | }); 33 | } 34 | 35 | bootstrap(); 36 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default async () => { 3 | const t = { 4 | ['./users/entities/user.entity']: await import( 5 | './users/entities/user.entity' 6 | ), 7 | }; 8 | return { 9 | '@nestjs/swagger': { 10 | models: [ 11 | [ 12 | import('./users/entities/user.entity'), 13 | { 14 | User: { 15 | id: { required: true, type: () => String }, 16 | email: { required: true, type: () => String }, 17 | createdAt: { required: true, type: () => Date }, 18 | updatedAt: { required: true, type: () => Date }, 19 | }, 20 | }, 21 | ], 22 | [ 23 | import('./auth/dto/sign-in.dto'), 24 | { 25 | SignInDto: { 26 | email: { required: true, type: () => String, maxLength: 255 }, 27 | password: { 28 | required: true, 29 | type: () => String, 30 | minLength: 8, 31 | maxLength: 20, 32 | pattern: 33 | '/((?=.*\\d)|(?=.*\\W+))(?![.\\n])(?=.*[A-Z])(?=.*[a-z]).*$/', 34 | }, 35 | }, 36 | }, 37 | ], 38 | [ 39 | import('./auth/dto/sign-up.dto'), 40 | { 41 | SignUpDto: { 42 | email: { required: true, type: () => String, maxLength: 255 }, 43 | password: { 44 | required: true, 45 | type: () => String, 46 | minLength: 8, 47 | maxLength: 20, 48 | pattern: 49 | '/((?=.*\\d)|(?=.*\\W+))(?![.\\n])(?=.*[A-Z])(?=.*[a-z]).*$/', 50 | }, 51 | passwordConfirm: { required: true, type: () => String }, 52 | }, 53 | }, 54 | ], 55 | ], 56 | controllers: [ 57 | [ 58 | import('./app.controller'), 59 | { AppController: { getHello: { type: String } } }, 60 | ], 61 | [ 62 | import('./auth/auth.controller'), 63 | { AuthController: { signUp: {}, signIn: {}, signOut: {} } }, 64 | ], 65 | [ 66 | import('./users/users.controller'), 67 | { 68 | UsersController: { 69 | getMe: { type: t['./users/entities/user.entity'].User }, 70 | }, 71 | }, 72 | ], 73 | ], 74 | }, 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /src/redis/redis.constants.ts: -------------------------------------------------------------------------------- 1 | export const IORedisKey = 'IORedis'; 2 | -------------------------------------------------------------------------------- /src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module, OnApplicationShutdown, Scope } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { ModuleRef } from '@nestjs/core'; 4 | import { Redis } from 'ioredis'; 5 | 6 | import { IORedisKey } from './redis.constants'; 7 | import { RedisService } from './redis.service'; 8 | 9 | @Global() 10 | @Module({ 11 | imports: [ConfigModule], 12 | providers: [ 13 | { 14 | provide: IORedisKey, 15 | useFactory: async (configService: ConfigService) => { 16 | return new Redis(configService.get('redis')); 17 | }, 18 | inject: [ConfigService], 19 | }, 20 | RedisService, 21 | ], 22 | exports: [RedisService], 23 | }) 24 | export class RedisModule implements OnApplicationShutdown { 25 | constructor(private readonly moduleRef: ModuleRef) {} 26 | 27 | async onApplicationShutdown(signal?: string): Promise { 28 | return new Promise((resolve) => { 29 | const redis = this.moduleRef.get(IORedisKey); 30 | redis.quit(); 31 | redis.on('end', () => { 32 | resolve(); 33 | }); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Redis } from 'ioredis'; 3 | 4 | import { IORedisKey } from './redis.constants'; 5 | 6 | @Injectable() 7 | export class RedisService { 8 | constructor( 9 | @Inject(IORedisKey) 10 | private readonly redisClient: Redis, 11 | ) {} 12 | 13 | async getKeys(pattern?: string): Promise { 14 | return await this.redisClient.keys(pattern); 15 | } 16 | 17 | async insert(key: string, value: string | number): Promise { 18 | await this.redisClient.set(key, value); 19 | } 20 | 21 | async get(key: string): Promise { 22 | return this.redisClient.get(key); 23 | } 24 | 25 | async delete(key: string): Promise { 26 | await this.redisClient.del(key); 27 | } 28 | 29 | async validate(key: string, value: string): Promise { 30 | const storedValue = await this.redisClient.get(key); 31 | return storedValue === value; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { 4 | DocumentBuilder, 5 | SwaggerCustomOptions, 6 | SwaggerDocumentOptions, 7 | SwaggerModule, 8 | } from '@nestjs/swagger'; 9 | 10 | import metadata from './metadata'; 11 | 12 | export const setupSwagger = async (app: INestApplication) => { 13 | const configService = app.get(ConfigService); 14 | const swaggerConfig = configService.get('swagger'); 15 | 16 | const config = new DocumentBuilder() 17 | .setTitle(swaggerConfig.docTitle) 18 | .setDescription(swaggerConfig.docDescription) 19 | .setVersion(swaggerConfig.docVersion) 20 | .addBearerAuth() 21 | .build(); 22 | 23 | const options: SwaggerDocumentOptions = { 24 | operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, 25 | }; 26 | 27 | await SwaggerModule.loadPluginMetadata(metadata); 28 | 29 | const document = SwaggerModule.createDocument(app, config, options); 30 | 31 | const customOptions: SwaggerCustomOptions = { 32 | swaggerOptions: { 33 | persistAuthorization: true, 34 | // defaultModelsExpandDepth: -1, 35 | }, 36 | customSiteTitle: swaggerConfig.siteTitle, 37 | }; 38 | 39 | SwaggerModule.setup('docs', app, document, customOptions); 40 | }; 41 | -------------------------------------------------------------------------------- /src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; 2 | import { Exclude } from 'class-transformer'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | 11 | @Entity({ 12 | name: 'users', 13 | }) 14 | export class User { 15 | @ApiProperty({ 16 | description: 'ID of user', 17 | example: '89c018cc-8a77-4dbd-94e1-dbaa710a2a9c', 18 | }) 19 | @PrimaryGeneratedColumn('uuid') 20 | id: string; 21 | 22 | @ApiProperty({ description: 'Email of user', example: 'atest@email.com' }) 23 | @Column({ unique: true }) 24 | email: string; 25 | 26 | @ApiHideProperty() 27 | @Column() 28 | @Exclude({ toPlainOnly: true }) 29 | password: string; 30 | 31 | @ApiProperty({ description: 'Created date of user' }) 32 | @CreateDateColumn({ name: 'created_at' }) 33 | createdAt: Date; 34 | 35 | @ApiProperty({ description: 'Updated date of user' }) 36 | @UpdateDateColumn({ name: 'updated_at' }) 37 | updatedAt: Date; 38 | } 39 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { 3 | ApiBearerAuth, 4 | ApiOkResponse, 5 | ApiTags, 6 | ApiUnauthorizedResponse, 7 | } from '@nestjs/swagger'; 8 | 9 | import { ActiveUser } from '../common/decorators/active-user.decorator'; 10 | import { User } from './entities/user.entity'; 11 | import { UsersService } from './users.service'; 12 | 13 | @ApiTags('users') 14 | @Controller('users') 15 | export class UsersController { 16 | constructor(private readonly usersService: UsersService) {} 17 | 18 | @ApiUnauthorizedResponse({ description: 'Unauthorized' }) 19 | @ApiOkResponse({ description: "Get logged in user's details", type: User }) 20 | @ApiBearerAuth() 21 | @Get('me') 22 | async getMe(@ActiveUser('id') userId: string): Promise { 23 | return this.usersService.getMe(userId); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { User } from './entities/user.entity'; 5 | import { UsersController } from './users.controller'; 6 | import { UsersService } from './users.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User])], 10 | controllers: [UsersController], 11 | providers: [UsersService], 12 | }) 13 | export class UsersModule {} 14 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { User } from './entities/user.entity'; 6 | 7 | @Injectable() 8 | export class UsersService { 9 | constructor( 10 | @InjectRepository(User) 11 | private readonly userRepository: Repository, 12 | ) {} 13 | 14 | async getMe(userId: string): Promise { 15 | const user = await this.userRepository.findOne({ 16 | where: { 17 | id: userId, 18 | }, 19 | }); 20 | if (!user) { 21 | throw new BadRequestException('User not found'); 22 | } 23 | 24 | return user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import * as request from 'supertest'; 3 | import { DataSource } from 'typeorm'; 4 | import { Server } from 'http'; 5 | 6 | import { AppFactory } from '../factories/app.factory'; 7 | import { AuthService } from '../../src/auth/auth.service'; 8 | import { SignUpDto } from '../../src/auth/dto/sign-up.dto'; 9 | import { UserFactory } from '../factories/user.factory'; 10 | import { SignInDto } from '../../src/auth/dto/sign-in.dto'; 11 | 12 | describe('App (e2e)', () => { 13 | let app: AppFactory; 14 | let server: Server; 15 | let dataSource: DataSource; 16 | let authService: AuthService; 17 | 18 | beforeAll(async () => { 19 | app = await AppFactory.new(); 20 | server = app.instance.getHttpServer(); 21 | dataSource = app.dbSource; 22 | authService = app.instance.get(AuthService); 23 | }); 24 | 25 | afterAll(async () => { 26 | await app.close(); 27 | }); 28 | 29 | beforeEach(async () => { 30 | await app.cleanupDB(); 31 | }); 32 | 33 | describe('AppModule', () => { 34 | describe('GET /', () => { 35 | it("should return 'Hello World'", () => { 36 | return request(app.instance.getHttpServer()) 37 | .get('/') 38 | .expect(HttpStatus.OK) 39 | .expect('Hello World!'); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('AuthModule', () => { 45 | describe('POST /auth/sign-up', () => { 46 | it('should create a new user', async () => { 47 | await new Promise((resolve) => setTimeout(resolve, 1)); 48 | 49 | const signUpDto: SignUpDto = { 50 | email: 'atest@email.com', 51 | password: 'Pass#123', 52 | passwordConfirm: 'Pass#123', 53 | }; 54 | 55 | return request(server) 56 | .post('/auth/sign-up') 57 | .send(signUpDto) 58 | .expect(HttpStatus.CREATED); 59 | }); 60 | 61 | it('should return 400 for invalid sign up fields', async () => { 62 | await new Promise((resolve) => setTimeout(resolve, 1)); 63 | 64 | const signUpDto: SignUpDto = { 65 | email: 'invalid-email', 66 | password: 'Pass#123', 67 | passwordConfirm: 'Pass#123', 68 | }; 69 | 70 | return request(server) 71 | .post('/auth/sign-up') 72 | .send(signUpDto) 73 | .expect(HttpStatus.BAD_REQUEST); 74 | }); 75 | 76 | it('should return 409 if user already exists', async () => { 77 | await UserFactory.new(dataSource).create({ 78 | email: 'atest@email.com', 79 | password: 'Pass#123', 80 | }); 81 | 82 | const signUpDto: SignUpDto = { 83 | email: 'atest@email.com', 84 | password: 'Pass#123', 85 | passwordConfirm: 'Pass#123', 86 | }; 87 | 88 | return request(server) 89 | .post('/auth/sign-up') 90 | .send(signUpDto) 91 | .expect(HttpStatus.CONFLICT); 92 | }); 93 | }); 94 | 95 | describe('POST /auth/sign-in', () => { 96 | it('should sign in the user and return access token', async () => { 97 | const email = 'atest@email.com'; 98 | const password = 'Pass#123'; 99 | await UserFactory.new(dataSource).create({ 100 | email, 101 | password, 102 | }); 103 | 104 | const signInDto: SignInDto = { 105 | email, 106 | password, 107 | }; 108 | 109 | return request(server) 110 | .post('/auth/sign-in') 111 | .send(signInDto) 112 | .expect(HttpStatus.OK) 113 | .expect((res) => { 114 | expect(res.body).toEqual({ accessToken: expect.any(String) }); 115 | }); 116 | }); 117 | 118 | it('should return 400 for invalid sign in fields', async () => { 119 | const signInDto: SignInDto = { 120 | email: 'atest@email.com', 121 | password: '', 122 | }; 123 | 124 | return request(server) 125 | .post('/auth/sign-in') 126 | .send(signInDto) 127 | .expect(HttpStatus.BAD_REQUEST); 128 | }); 129 | }); 130 | 131 | describe('POST /auth/sign-out', () => { 132 | it('should sign out the user', async () => { 133 | const user = await UserFactory.new(dataSource).create({ 134 | email: 'atest@email.com', 135 | password: 'Pass#123', 136 | }); 137 | 138 | const { accessToken } = await authService.generateAccessToken(user); 139 | 140 | return request(server) 141 | .post('/auth/sign-out') 142 | .set('Authorization', `Bearer ${accessToken}`) 143 | .expect(HttpStatus.OK); 144 | }); 145 | 146 | it('should return 401 if not authorized', async () => { 147 | return request(server) 148 | .post('/auth/sign-out') 149 | .expect(HttpStatus.UNAUTHORIZED); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('UsersModule', () => { 155 | describe('GET /users/me', () => { 156 | it('should return 401 unauthorized when no access token provided', () => { 157 | return request(server).get('/users/me').expect(HttpStatus.UNAUTHORIZED); 158 | }); 159 | 160 | it('should return user details when access token provided', async () => { 161 | const user = await UserFactory.new(dataSource).create({ 162 | email: 'atest@email.com', 163 | password: 'Pass#123', 164 | }); 165 | 166 | const { accessToken } = await authService.generateAccessToken(user); 167 | 168 | return request(server) 169 | .get('/users/me') 170 | .set('Authorization', `Bearer ${accessToken}`) 171 | .expect(HttpStatus.OK) 172 | .expect((res) => { 173 | expect(res.body).toEqual( 174 | expect.objectContaining({ 175 | id: user.id, 176 | email: user.email, 177 | }), 178 | ); 179 | }); 180 | }); 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /test/e2e/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "../../", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "modulePaths": ["."] 10 | } 11 | -------------------------------------------------------------------------------- /test/factories/app.factory.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type, ValidationPipe } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { DataSource } from 'typeorm'; 4 | import { Redis } from 'ioredis'; 5 | import { getDataSourceToken } from '@nestjs/typeorm'; 6 | 7 | import { AppModule } from 'src/app.module'; 8 | import { TransformInterceptor } from '../../src/common/interceptors/transform.interceptor'; 9 | import { IORedisKey } from '../../src/redis/redis.constants'; 10 | 11 | export class AppFactory { 12 | private constructor( 13 | private readonly appInstance: INestApplication, 14 | private readonly dataSource: DataSource, 15 | private readonly redis: Redis, 16 | ) {} 17 | 18 | get instance() { 19 | return this.appInstance; 20 | } 21 | 22 | get dbSource() { 23 | return this.dataSource; 24 | } 25 | 26 | static async new() { 27 | const module = await Test.createTestingModule({ 28 | imports: [AppModule], 29 | }).compile(); 30 | 31 | const app = module.createNestApplication(); 32 | 33 | app.useGlobalInterceptors(new TransformInterceptor()); 34 | 35 | app.useGlobalPipes( 36 | new ValidationPipe({ 37 | transform: true, 38 | whitelist: true, 39 | forbidUnknownValues: true, 40 | stopAtFirstError: true, 41 | validateCustomDecorators: true, 42 | }), 43 | ); 44 | 45 | await app.init(); 46 | 47 | const dataSource = module.get( 48 | getDataSourceToken() as Type, 49 | ); 50 | 51 | const redis = module.get(IORedisKey); 52 | 53 | return new AppFactory(app, dataSource, redis); 54 | } 55 | 56 | async close() { 57 | await this.appInstance.close(); 58 | } 59 | 60 | async cleanupDB() { 61 | await this.redis.flushall(); 62 | 63 | const tables = this.dataSource.manager.connection.entityMetadatas.map( 64 | (entity) => `${entity.tableName}`, 65 | ); 66 | for (const table of tables) { 67 | await this.dataSource.manager.connection.query(`DELETE FROM ${table};`); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/factories/user.factory.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, EntityManager } from 'typeorm'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | import { User } from '../../src/users/entities/user.entity'; 5 | 6 | export class UserFactory { 7 | private dataSource: DataSource; 8 | 9 | static new(dataSource: DataSource) { 10 | const factory = new UserFactory(); 11 | factory.dataSource = dataSource; 12 | return factory; 13 | } 14 | 15 | async create(user: Partial = {}) { 16 | const userRepository = this.dataSource.getRepository(User); 17 | const salt = await bcrypt.genSalt(); 18 | const password = await this.hashPassword(user.password, salt); 19 | const payload = { 20 | ...user, 21 | password, 22 | }; 23 | return userRepository.save(payload); 24 | } 25 | 26 | private hashPassword(password: string, salt: string) { 27 | return bcrypt.hash(password, salt); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/unit/app.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { AppService } from '../../src/app.service'; 4 | 5 | describe('AppService', () => { 6 | let service: AppService; 7 | 8 | beforeEach(async () => { 9 | const moduleRef: TestingModule = await Test.createTestingModule({ 10 | providers: [AppService], 11 | }).compile(); 12 | 13 | service = moduleRef.get(AppService); 14 | }); 15 | 16 | describe('getHello', () => { 17 | it('should return "Hello World!"', () => { 18 | const result = service.getHello(); 19 | 20 | expect(result).toEqual('Hello World!'); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/auth/auth.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { Repository } from 'typeorm'; 5 | import { BadRequestException, ConflictException } from '@nestjs/common'; 6 | import { createMock } from '@golevelup/ts-jest'; 7 | import { getRepositoryToken } from '@nestjs/typeorm'; 8 | 9 | import { AuthService } from '../../../src/auth/auth.service'; 10 | import { BcryptService } from '../../../src/auth/bcrypt.service'; 11 | import { RedisService } from '../../../src/redis/redis.service'; 12 | import jwtConfig from '../../../src/common/config/jwt.config'; 13 | import { User } from '../../../src/users/entities/user.entity'; 14 | import { SignUpDto } from '../../../src/auth/dto/sign-up.dto'; 15 | import { MysqlErrorCode } from '../../../src/common/enums/error-codes.enum'; 16 | import { ActiveUserData } from '../../../src/common/interfaces/active-user-data.interface'; 17 | 18 | describe('AuthService', () => { 19 | let authService: AuthService; 20 | let bcryptService: BcryptService; 21 | let jwtService: JwtService; 22 | let userRepository: Repository; 23 | let redisService: RedisService; 24 | let jwtConfiguration: ConfigType; 25 | 26 | beforeEach(async () => { 27 | const moduleRef = await Test.createTestingModule({ 28 | providers: [ 29 | AuthService, 30 | { provide: BcryptService, useValue: createMock() }, 31 | { provide: JwtService, useValue: createMock() }, 32 | { provide: RedisService, useValue: createMock() }, 33 | { 34 | provide: getRepositoryToken(User), 35 | useClass: Repository, 36 | }, 37 | { 38 | provide: jwtConfig.KEY, 39 | useValue: jwtConfig.asProvider(), 40 | }, 41 | ], 42 | }).compile(); 43 | 44 | authService = moduleRef.get(AuthService); 45 | bcryptService = moduleRef.get(BcryptService); 46 | jwtService = moduleRef.get(JwtService); 47 | userRepository = moduleRef.get>(getRepositoryToken(User)); 48 | redisService = moduleRef.get(RedisService); 49 | jwtConfiguration = moduleRef.get>( 50 | jwtConfig.KEY, 51 | ); 52 | }); 53 | 54 | describe('signUp', () => { 55 | const signUpDto: SignUpDto = { 56 | email: 'test@example.com', 57 | password: 'password', 58 | passwordConfirm: 'password', 59 | }; 60 | let user: User; 61 | 62 | beforeEach(() => { 63 | user = new User(); 64 | user.email = signUpDto.email; 65 | user.password = 'hashed_password'; 66 | }); 67 | 68 | it('should create a new user', async () => { 69 | const saveSpy = jest 70 | .spyOn(userRepository, 'save') 71 | .mockResolvedValueOnce(user); 72 | const hashSpy = jest 73 | .spyOn(bcryptService, 'hash') 74 | .mockResolvedValueOnce('hashed_password'); 75 | 76 | await authService.signUp(signUpDto); 77 | 78 | expect(hashSpy).toHaveBeenCalledWith(signUpDto.password); 79 | expect(saveSpy).toHaveBeenCalledWith(user); 80 | }); 81 | 82 | it('should throw a ConflictException if a user with the same email already exists', async () => { 83 | const saveSpy = jest 84 | .spyOn(userRepository, 'save') 85 | .mockRejectedValueOnce({ code: MysqlErrorCode.UniqueViolation }); 86 | 87 | await expect(authService.signUp(signUpDto)).rejects.toThrowError( 88 | new ConflictException(`User [${signUpDto.email}] already exist`), 89 | ); 90 | 91 | expect(saveSpy).toHaveBeenCalledWith(user); 92 | }); 93 | 94 | it('should rethrow any other error that occurs during signup', async () => { 95 | const saveSpy = jest 96 | .spyOn(userRepository, 'save') 97 | .mockRejectedValueOnce(new Error('Unexpected error')); 98 | 99 | await expect(authService.signUp(signUpDto)).rejects.toThrowError( 100 | new Error('Unexpected error'), 101 | ); 102 | 103 | expect(saveSpy).toHaveBeenCalledWith(user); 104 | }); 105 | }); 106 | 107 | describe('signIn', () => { 108 | it('should sign in a user and return an access token', async () => { 109 | const signInDto = { 110 | email: 'johndoe@example.com', 111 | password: 'password', 112 | }; 113 | 114 | const user = new User(); 115 | user.id = '123'; 116 | user.email = signInDto.email; 117 | user.password = 'encryptedPassword'; 118 | 119 | const encryptedPassword = 'encryptedPassword'; 120 | const comparedPassword = true; 121 | const tokenId = expect.any(String); 122 | 123 | jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); 124 | jest.spyOn(bcryptService, 'compare').mockResolvedValue(comparedPassword); 125 | jest 126 | .spyOn(authService, 'generateAccessToken') 127 | .mockResolvedValue({ accessToken: 'accessToken' }); 128 | 129 | const result = await authService.signIn(signInDto); 130 | 131 | expect(result).toEqual({ accessToken: 'accessToken' }); 132 | expect(userRepository.findOne).toHaveBeenCalledWith({ 133 | where: { email: signInDto.email }, 134 | }); 135 | expect(bcryptService.compare).toHaveBeenCalledWith( 136 | signInDto.password, 137 | encryptedPassword, 138 | ); 139 | }); 140 | 141 | it('should throw an error when email is invalid', async () => { 142 | const signInDto = { 143 | email: 'invalid-email', 144 | password: 'Pass#123', 145 | }; 146 | jest.spyOn(userRepository, 'findOne').mockResolvedValue(undefined); 147 | 148 | await expect(authService.signIn(signInDto)).rejects.toThrow( 149 | BadRequestException, 150 | ); 151 | 152 | expect(userRepository.findOne).toHaveBeenCalledWith({ 153 | where: { email: signInDto.email }, 154 | }); 155 | }); 156 | 157 | it('should throw an error when password is invalid', async () => { 158 | const signInDto = { 159 | email: 'johndoe@example.com', 160 | password: 'password', 161 | }; 162 | 163 | const user = new User(); 164 | user.id = '123'; 165 | user.email = signInDto.email; 166 | user.password = 'encryptedPassword'; 167 | 168 | jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); 169 | jest.spyOn(bcryptService, 'compare').mockResolvedValue(false); 170 | 171 | await expect(authService.signIn(signInDto)).rejects.toThrow( 172 | BadRequestException, 173 | ); 174 | 175 | expect(userRepository.findOne).toHaveBeenCalledWith({ 176 | where: { email: signInDto.email }, 177 | }); 178 | expect(bcryptService.compare).toHaveBeenCalledWith( 179 | signInDto.password, 180 | user.password, 181 | ); 182 | }); 183 | }); 184 | 185 | describe('signOut', () => { 186 | it('should delete user token from Redis', async () => { 187 | const userId = 'test-user-id'; 188 | const deleteSpy = jest 189 | .spyOn(redisService, 'delete') 190 | .mockResolvedValue(undefined); 191 | 192 | await authService.signOut(userId); 193 | 194 | expect(deleteSpy).toHaveBeenCalledWith(`user-${userId}`); 195 | }); 196 | }); 197 | 198 | describe('generateAccessToken', () => { 199 | it('should insert a token into Redis and return an access token', async () => { 200 | const user = { id: '123', email: 'test@example.com' }; 201 | const tokenId = expect.any(String); 202 | const accessToken = 'test-access-token'; 203 | (redisService.insert as any).mockResolvedValueOnce(undefined); 204 | (jwtService.signAsync as any).mockResolvedValueOnce(accessToken); 205 | 206 | const result = await authService.generateAccessToken(user); 207 | 208 | expect(redisService.insert).toHaveBeenCalledWith( 209 | `user-${user.id}`, 210 | tokenId, 211 | ); 212 | expect(jwtService.signAsync).toHaveBeenCalledWith( 213 | { id: user.id, email: user.email, tokenId } as ActiveUserData, 214 | { 215 | secret: jwtConfiguration.secret, 216 | expiresIn: jwtConfiguration.accessTokenTtl, 217 | }, 218 | ); 219 | expect(result).toEqual({ accessToken }); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /test/unit/auth/bcrypt.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { BcryptService } from '../../../src/auth/bcrypt.service'; 2 | 3 | describe('BcryptService', () => { 4 | let bcryptService: BcryptService; 5 | 6 | beforeEach(() => { 7 | bcryptService = new BcryptService(); 8 | }); 9 | 10 | describe('hash', () => { 11 | it('should return a hashed string', async () => { 12 | const data = 'password'; 13 | 14 | const result = await bcryptService.hash(data); 15 | 16 | expect(result).not.toBe(data); 17 | expect(result).toBeDefined(); 18 | expect(result).not.toBeNull(); 19 | expect(typeof result).toBe('string'); 20 | }); 21 | }); 22 | 23 | describe('compare', () => { 24 | it('should return true if the data matches the encrypted string', async () => { 25 | const data = 'password'; 26 | const encrypted = 27 | '$2b$10$iUp/PtR8IlnyKFD5ZjP0X.DUg4.zFec3jr/XoMm9/rIXC0dzaRUmS'; 28 | 29 | const result = await bcryptService.compare(data, encrypted); 30 | 31 | expect(result).toBe(true); 32 | }); 33 | 34 | it('should return false if the data does not match the encrypted string', async () => { 35 | const data = 'password'; 36 | const encrypted = 37 | '$2b$10$iUp/PtR8IlnyKFD5ZjP0X.DUg4.zFec3jr/XoMm9/rIXC0dzaRUmS'; 38 | 39 | const result = await bcryptService.compare('wrong-password', encrypted); 40 | 41 | expect(result).toBe(false); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/unit/auth/guards/jwt-auth.guard.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { Reflector } from '@nestjs/core'; 5 | import { createMock } from '@golevelup/ts-jest'; 6 | import { ConfigModule } from '@nestjs/config'; 7 | 8 | import { JwtAuthGuard } from '../../../../src/auth/guards/jwt-auth.guard'; 9 | import { RedisService } from '../../../../src/redis/redis.service'; 10 | import jwtConfig from '../../../../src/common/config/jwt.config'; 11 | 12 | describe('JwtAuthGuard', () => { 13 | let guard: JwtAuthGuard; 14 | let redisService: RedisService; 15 | let jwtService: JwtService; 16 | let reflector: Reflector; 17 | let mockExecutionContext: ExecutionContext; 18 | 19 | beforeEach(async () => { 20 | const moduleRef: TestingModule = await Test.createTestingModule({ 21 | imports: [ 22 | ConfigModule.forRoot({ 23 | load: [jwtConfig], 24 | }), 25 | ], 26 | providers: [ 27 | JwtAuthGuard, 28 | { 29 | provide: RedisService, 30 | useValue: createMock(), 31 | }, 32 | { 33 | provide: JwtService, 34 | useValue: createMock(), 35 | }, 36 | { 37 | provide: Reflector, 38 | useValue: createMock(), 39 | }, 40 | ], 41 | }).compile(); 42 | 43 | guard = moduleRef.get(JwtAuthGuard); 44 | redisService = moduleRef.get(RedisService); 45 | jwtService = moduleRef.get(JwtService); 46 | reflector = moduleRef.get(Reflector); 47 | mockExecutionContext = createMock(); 48 | }); 49 | 50 | afterEach(() => { 51 | jest.resetAllMocks(); 52 | }); 53 | 54 | it('should be defined', () => { 55 | expect(guard).toBeDefined(); 56 | }); 57 | 58 | it('should allow access to public routes', async () => { 59 | jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); 60 | 61 | const result = await guard.canActivate(mockExecutionContext); 62 | 63 | expect(result).toBe(true); 64 | }); 65 | 66 | it('should not allow access without a token', async () => { 67 | jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); 68 | jest.spyOn(guard as any, 'getToken').mockReturnValue(undefined); 69 | 70 | await expect(guard.canActivate(mockExecutionContext)).rejects.toThrowError( 71 | new UnauthorizedException('Authorization token is required'), 72 | ); 73 | }); 74 | 75 | it('should not allow access with an invalid token', async () => { 76 | jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); 77 | jest.spyOn(guard as any, 'getToken').mockReturnValue('invalid-token'); 78 | jest.spyOn(redisService, 'validate').mockResolvedValue(false); 79 | 80 | await expect(guard.canActivate(mockExecutionContext)).rejects.toThrowError( 81 | new UnauthorizedException('Authorization token is not valid'), 82 | ); 83 | }); 84 | 85 | it('should allow access with a valid token', async () => { 86 | const validToken = 'valid-token'; 87 | jest.spyOn(guard as any, 'getToken').mockReturnValue(validToken); 88 | jest.spyOn(redisService, 'validate').mockResolvedValue(true); 89 | 90 | const result = await guard.canActivate(mockExecutionContext); 91 | 92 | expect(result).toBe(true); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/unit/jest-unit.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "../../", 4 | "testEnvironment": "node", 5 | "testRegex": ".unit-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "modulePaths": ["."] 10 | } 11 | -------------------------------------------------------------------------------- /test/unit/redis/redis.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { Redis } from 'ioredis'; 4 | 5 | import { IORedisKey } from '../../../src/redis/redis.constants'; 6 | import { RedisService } from '../../../src/redis/redis.service'; 7 | 8 | describe('RedisService', () => { 9 | let redisService: RedisService; 10 | let redisClient: Redis; 11 | 12 | beforeEach(async () => { 13 | const moduleRef = await Test.createTestingModule({ 14 | providers: [ 15 | RedisService, 16 | { 17 | provide: IORedisKey, 18 | useValue: createMock(), 19 | }, 20 | ], 21 | }).compile(); 22 | 23 | redisService = moduleRef.get(RedisService); 24 | redisClient = moduleRef.get(IORedisKey); 25 | }); 26 | 27 | afterEach(() => { 28 | jest.resetAllMocks(); 29 | }); 30 | 31 | it('should call redisClient.keys with the provided pattern when getKeys is called', async () => { 32 | const pattern = 'test*'; 33 | const expectedKeys = ['test1', 'test2']; 34 | (redisClient.keys as any).mockResolvedValue(expectedKeys); 35 | 36 | const result = await redisService.getKeys(pattern); 37 | 38 | expect(redisClient.keys).toHaveBeenCalledWith(pattern); 39 | expect(result).toEqual(expectedKeys); 40 | }); 41 | 42 | it('should call redisClient.set with the provided key and value when insert is called', async () => { 43 | const key = 'test-key'; 44 | const value = 'test-value'; 45 | 46 | await redisService.insert(key, value); 47 | 48 | expect(redisClient.set).toHaveBeenCalledWith(key, value); 49 | }); 50 | 51 | it('should call redisClient.get with the provided key when get is called', async () => { 52 | const key = 'test-key'; 53 | const expectedValue = 'test-value'; 54 | (redisClient.get as any).mockResolvedValue(expectedValue); 55 | 56 | const result = await redisService.get(key); 57 | 58 | expect(redisClient.get).toHaveBeenCalledWith(key); 59 | expect(result).toEqual(expectedValue); 60 | }); 61 | 62 | it('should call redisClient.del with the provided key when delete is called', async () => { 63 | const key = 'test-key'; 64 | 65 | await redisService.delete(key); 66 | 67 | expect(redisClient.del).toHaveBeenCalledWith(key); 68 | }); 69 | 70 | it('should return true if the stored value matches the provided value when validate is called', async () => { 71 | const key = 'test-key'; 72 | const value = 'test-value'; 73 | (redisClient.get as any).mockResolvedValue(value); 74 | 75 | const result = await redisService.validate(key, value); 76 | 77 | expect(redisClient.get).toHaveBeenCalledWith(key); 78 | expect(result).toBe(true); 79 | }); 80 | 81 | it('should return false if the stored value does not match the provided value when validate is called', async () => { 82 | const key = 'test-key'; 83 | const value = 'test-value'; 84 | (redisClient.get as any).mockResolvedValue('other-value'); 85 | 86 | const result = await redisService.validate(key, value); 87 | 88 | expect(redisClient.get).toHaveBeenCalledWith(key); 89 | expect(result).toBe(false); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/unit/users/users.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | 6 | import { User } from '../../../src/users/entities/user.entity'; 7 | import { UsersService } from '../../../src/users/users.service'; 8 | 9 | describe('UsersService', () => { 10 | let usersService: UsersService; 11 | let userRepository: Repository; 12 | 13 | beforeEach(async () => { 14 | const moduleRef: TestingModule = await Test.createTestingModule({ 15 | providers: [ 16 | UsersService, 17 | { 18 | provide: getRepositoryToken(User), 19 | useClass: Repository, 20 | }, 21 | ], 22 | }).compile(); 23 | 24 | usersService = moduleRef.get(UsersService); 25 | userRepository = moduleRef.get>(getRepositoryToken(User)); 26 | }); 27 | 28 | describe('getMe', () => { 29 | it('should return a user with the specified ID', async () => { 30 | const userId = '123'; 31 | const user = new User(); 32 | user.id = userId; 33 | jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); 34 | 35 | const result = await usersService.getMe(userId); 36 | 37 | expect(result).toEqual(user); 38 | }); 39 | 40 | it('should throw a BadRequestException if user is not found', async () => { 41 | const userId = '123'; 42 | jest.spyOn(userRepository, 'findOne').mockResolvedValue(undefined); 43 | 44 | await expect(usersService.getMe(userId)).rejects.toThrow( 45 | BadRequestException, 46 | ); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------