├── .github └── ISSUE_TEMPLATE │ └── bug_report.yml ├── .gitignore ├── .prettierrc ├── README.md ├── db-config ├── data-source.ts └── index.ts ├── docker-compose.yml ├── nest-cli.json ├── ormconfig.bak ├── package-lock.json ├── package.json ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.spec.ts ├── app.service.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── crypto.service.ts │ ├── decorators │ │ └── index.ts │ ├── dtos │ │ ├── auth-response.dto.ts │ │ ├── change-password.dto.ts │ │ ├── forgot-password.dto.ts │ │ ├── google-login.dto.ts │ │ ├── login-user.dto.ts │ │ ├── login-with-2fa.dto.ts │ │ ├── refresh-token.dto.ts │ │ ├── resend-2fa-otp.dto.ts │ │ ├── reset-password.dto.ts │ │ ├── signup-user.dto.ts │ │ └── validate-otp.dto.ts │ ├── entities │ │ └── refresh-token.entity.ts │ ├── enums │ │ └── index.ts │ ├── middlewares │ │ └── validation.middleware.ts │ ├── password.service.ts │ ├── strategies │ │ ├── jwt.strategy.ts │ │ ├── local.strategy.ts │ │ └── refresh.strategy.ts │ ├── token.service.ts │ ├── two-factor.service.ts │ └── verification.service.ts ├── common │ ├── common.module.ts │ ├── constants │ │ └── index.ts │ ├── dtos │ │ ├── app-info.dto.ts │ │ ├── healt-check.dto.ts │ │ └── meta.dto.ts │ ├── enums │ │ └── index.ts │ ├── exceptions │ │ ├── account-inactive.exception.ts │ │ └── exception-filter.ts │ ├── interceptors │ │ └── serialize.interceptor.ts │ ├── middlewares │ │ └── logs.middleware.ts │ └── validation │ │ └── validation.module.ts ├── database │ └── database.module.ts ├── guards │ ├── 2FA.guard.ts │ ├── admin.guard.ts │ ├── jwt-auth.guard.ts │ ├── local.guard.ts │ ├── refresh.guard.ts │ └── role.guard.ts ├── main.ts ├── notifications │ ├── email.service.ts │ ├── notifications.controller.ts │ ├── notifications.module.ts │ ├── notifications.service.ts │ └── sms.service.ts └── users │ ├── decorators │ ├── current-user.decorator.ts │ ├── index.ts │ └── roles.decorator.ts │ ├── dtos │ ├── paginated-users.dto.ts │ ├── update-me.dto.ts │ ├── update-user.dto.ts │ ├── user-query.dto.ts │ └── user.dto.ts │ ├── entities │ └── user.entity.ts │ ├── interceptors │ ├── current-user.interceptor.ts │ └── user-id.interceptor.ts │ ├── middlewares │ └── current-user.middleware.ts │ ├── users.controller.spec.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.service.spec.ts │ └── users.service.ts ├── test ├── app.e2e-spec.ts ├── auth.e2e-spec.ts ├── global.test.ts ├── jest-e2e.json ├── test-db.config.ts └── test.setup.ts ├── tsconfig.build.json ├── tsconfig.json └── tsconfig.test.json /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug in our application or services. 3 | title: "[Backend Bug]: " 4 | labels: ["backend", "bug"] 5 | assignees: 6 | - backendteam 7 | 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | 📡 Thank you for taking the time to fill out this bug report for our backend services. Please fill out the sections below to the best of your ability. 13 | 14 | - type: textarea 15 | id: what-happened 16 | attributes: 17 | label: What happened? 18 | description: Describe the issue with the backend service, including what you expected to happen instead. 19 | placeholder: Please describe the issue in detail, including expected and actual outcomes. 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: steps-to-reproduce 25 | attributes: 26 | label: Steps to Reproduce 27 | description: Detail the steps taken to encounter the issue. 28 | placeholder: 1. Make a request to endpoint '...' with payload '...' 2. Observe the unexpected response 29 | validations: 30 | required: true 31 | 32 | - type: input 33 | id: api-version 34 | attributes: 35 | label: API Version 36 | description: Which version of the API are you using? 37 | placeholder: e.g., v1, v2 38 | validations: 39 | required: true 40 | 41 | - type: input 42 | id: device-info 43 | attributes: 44 | label: Client Device Info 45 | description: If applicable, include device model and OS version. 46 | placeholder: e.g., iPhone 12, iOS 14.4 or Android device, Android 11 47 | validations: 48 | required: false 49 | 50 | - type: textarea 51 | id: additional-context 52 | attributes: 53 | label: Additional Context 54 | description: Any additional information (e.g., network conditions, headers, etc.) that might help in diagnosing the issue. 55 | placeholder: Include any other details that might be helpful. 56 | validations: 57 | required: false 58 | -------------------------------------------------------------------------------- /.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 | #env file 38 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS Authentication and User Management API 2 | 3 | This project is a showcase of a backend API developed using NestJS, demonstrating capabilities in user management, authentication, and both unit and end-to-end testing. It utilizes TypeORM with PostgreSQL for database interactions and includes comprehensive API documentation using Swagger. 4 | 5 | Work in progress and not yet ready for production use. Just playing around with NestJS and TypeORM when I have time. You can contribute or report bugs using issues. 6 | 7 | ## Getting Started 8 | 9 | ### Prerequisites 10 | 11 | Before you begin, you will need to have the following installed on your machine: 12 | 13 | 1. [Node.js](https://nodejs.org/) (v18.x or higher) 14 | 2. [npm](https://www.npmjs.com/) (v6.x or higher) 15 | 3. [PostgreSQL](https://www.postgresql.org/download/) (v10.x or higher) 16 | 4. [PgAdmin](https://www.pgadmin.org/download/) - a GUI tool for managing PostgreSQL databases 17 | 5. Create two databases with PgAdmin as `development` and `test` 18 | 19 | ### Installation 20 | 21 | 1. Clone the repository: 22 | 23 | ``` 24 | git clone https://github.com/simbolmina/nestjs-auth.git 25 | ``` 26 | 27 | 2. Install the required packages: 28 | 29 | ``` 30 | cd nestjs-auth 31 | npm install 32 | ``` 33 | 34 | 3. Create a new PostgreSQL database and configure the connection details in the `.env` file. Passwords are set as `admin` in this configuation, it is usually `postgres` 35 | 36 | ### Running the Application 37 | 38 | 1. To start the application in development mode, run: 39 | 40 | ``` 41 | npm run start:dev 42 | ``` 43 | 44 | The application will be running at `http://localhost:5000/api/v1`. 45 | 46 | 2. Access the Swagger UI at `http://localhost:5000/api-doc 47 | 48 | ### Running Tests 49 | 50 | 1. To run unit tests, execute: 51 | 52 | ``` 53 | npm run test:watch 54 | ``` 55 | 56 | 2. To run end-to-end tests, execute: 57 | 58 | ``` 59 | npm run test:e2e 60 | ``` 61 | 62 | ### Database Migration Functionality 63 | 64 | The migration feature is being added to facilitate changes in the production database during the later stages of development. To make use of migrations, set `synchronize: false` in the dataSource configuration and refer to the migration commands in `package.json`. 65 | 66 | After making changes to the data structure, first run: 67 | 68 | ``` 69 | npm run migration:generate -- db-config/migrations/NewMigration 70 | ``` 71 | 72 | This command will detect changes in your database. To apply the changes, run: 73 | 74 | ``` 75 | npm run migration:run 76 | ``` 77 | 78 | To revert the changes, use the following command: 79 | 80 | ``` 81 | npm run migration:revert 82 | ``` 83 | 84 | For an example in action, check out this video: [https://www.youtube.com/watch?v=5G81_VIjaO8&t=96s](https://www.youtube.com/watch?v=5G81_VIjaO8&t=96s). 85 | 86 | ## Features and Modules 87 | 88 | The API includes the following key features: 89 | 90 | User Module: Manages user registration, information updates, and retrieval. 91 | Auth Module: Supports authentication workflows using JWT, including login, token refresh, and role-based access control. 92 | Testing: Includes comprehensive unit and end-to-end tests to ensure functionality and stability. 93 | 94 | ## API Documentation 95 | 96 | API documentation is provided through the Swagger UI, which can be accessed at [http://localhost:5000/api-doc](http://localhost:5000/api-doc). For some examples, check out this video: [https://www.youtube.com/watch?v=lZmsY0e2ojQ](https://www.youtube.com/watch?v=lZmsY0e2ojQ). 97 | 98 | The API documentation is automatically generated using Swagger decorators in each endpoint. These decorators enhance the auto-generated Swagger documentation by adding descriptions, potential response codes, and response types. For instance, in the `updateUser` endpoint: 99 | 100 | ```typescript 101 | @UseGuards(JwtAuthGuard, AdminGuard) 102 | @Patch('/:id') 103 | @ApiBody({ 104 | description: 'Allowed data to be updated by user', 105 | type: AdminUpdateUserDto, 106 | }) 107 | @ApiBearerAuth() 108 | @ApiOkResponse({ type: User }) 109 | @ApiOperation({ 110 | summary: 'Update user by ID', 111 | description: 112 | 'This endpoint allows an administrator to update user details for any user in the database. Only administrators can access this endpoint.', 113 | }) 114 | @ApiNotFoundResponse({ description: 'User not found' }) 115 | @ApiUnauthorizedResponse({ description: 'Unauthorized' }) 116 | @ApiForbiddenResponse({ description: 'Forbidden resource' }) 117 | @ApiParam({ name: 'id', description: 'User ID' }) 118 | async updateUser(@Param('id') id: string, @Body() body: Partial) { 119 | return await this.usersService.updateUserByAdmin(id, body); 120 | } 121 | ``` 122 | 123 | This code snippet includes various decorators from the `nestjs/swagger` package: 124 | 125 | - `@ApiBody` is used to describe the request body. 126 | - `@ApiOperation` provides summary and description for the API operation. 127 | - `@ApiOkResponse` describes a possible successful response, including its data type. 128 | - `@ApiParam` describes a URL parameter. 129 | - `@ApiBearerAuth` indicates that this route requires bearer token authentication. 130 | - `@ApiNotFoundResponse`, `@ApiUnauthorizedResponse`, and `@ApiForbiddenResponse` provide descriptions of possible error responses. 131 | 132 | When you run the application, NestJS will automatically generate an OpenAPI (Swagger) specification. You can see this in action by navigating to `http://localhost:5000/api-doc` where you'll find the Swagger UI presenting all your endpoints and their descriptions interactively. 133 | 134 | Remember that having a well-documented API is vital for both front-end developers and other potential users of your API. You should always try to make your API self-descriptive. 135 | 136 | ### .env Configuration 137 | 138 | NestJS uses the `dotenv` module for environment variable management. You can define your configuration in a `.env` file in the root directory of your project. The variables in this file can be accessed via the `ConfigService`. 139 | 140 | Here's a quick setup: 141 | 142 | 1. Install the necessary module: `npm install @nestjs/config` 143 | 2. Import `ConfigModule` in your `app.module.ts`: 144 | 145 | ```typescript 146 | @Module({ 147 | imports: [ConfigModule.forRoot()], 148 | }) 149 | export class AppModule {} 150 | ``` 151 | 152 | 3. Inject and use `ConfigService` to access your environment variables: 153 | 154 | ```typescript 155 | constructor(private configService: ConfigService) {} 156 | 157 | someMethod() { 158 | const dbUser = this.configService.get('DB_USER'); 159 | } 160 | ``` 161 | 162 | For more detailed information, refer to the NestJS configuration documentation: https://docs.nestjs.com/techniques/configuration 163 | 164 | ## License 165 | 166 | This project is open-sourced under the MIT License. See the LICENSE file for more details. 167 | 168 | Adjust the contents as necessary to align with your project's actual configuration and features. 169 | -------------------------------------------------------------------------------- /db-config/data-source.ts: -------------------------------------------------------------------------------- 1 | import { DataSourceOptions } from 'typeorm'; 2 | import { join } from 'path'; 3 | 4 | export const developmentConfig: DataSourceOptions = { 5 | type: 'postgres', 6 | host: 'localhost', 7 | port: 5432, 8 | username: 'postgres', 9 | password: 'admin', 10 | database: 'development', 11 | synchronize: true, 12 | entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')], 13 | migrations: ['dist/db-config/migrations/*.js'], 14 | }; 15 | 16 | export const testConfig: DataSourceOptions = { 17 | type: 'postgres', 18 | host: 'localhost', // Replace with test database host 19 | port: 5432, 20 | username: 'postgres', 21 | password: 'admin', // Replace with test database password 22 | database: 'test', // Replace with test database name 23 | synchronize: true, 24 | entities: ['src/**/*.entity.ts', 'dist/**/*.entity.js'], 25 | migrations: ['dist/db-config/migrations/*.js'], 26 | }; 27 | -------------------------------------------------------------------------------- /db-config/index.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { developmentConfig, testConfig } from './data-source'; 3 | 4 | const env = process.env.NODE_ENV || 'development'; 5 | 6 | const configMap = { 7 | development: developmentConfig, 8 | test: testConfig, 9 | // production: productionConfig, 10 | }; 11 | 12 | export const currentConfig = configMap[env]; 13 | 14 | const dataSource = new DataSource(currentConfig); 15 | 16 | export const initializeDatabase = async () => { 17 | try { 18 | await dataSource.initialize(); 19 | console.log( 20 | `Data Source has been initialized in --${env.toUpperCase()}-- environment!`, 21 | ); 22 | } catch (err) { 23 | console.error('Error during Data Source initialization', err); 24 | } 25 | }; 26 | 27 | export const closeDatabase = async () => { 28 | try { 29 | await dataSource.destroy(); 30 | } catch (err) { 31 | console.error('Error during Data Source closing', err); 32 | } 33 | }; 34 | 35 | export default dataSource; 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:latest 4 | environment: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: admin 7 | POSTGRES_DB: development 8 | ports: 9 | - '5432:5432' 10 | volumes: 11 | - postgres_data:/var/lib/postgresql/data 12 | restart: unless-stopped 13 | 14 | volumes: 15 | postgres_data: 16 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ormconfig.bak: -------------------------------------------------------------------------------- 1 | var dbConfig = { 2 | synchronize: false, 3 | }; 4 | 5 | switch (process.env.NODE_ENV) { 6 | case 'development': 7 | Object.assign(dbConfig, { 8 | type: 'postgres', 9 | host: 'localhost', 10 | port: 5432, 11 | username: 'postgres', 12 | password: 'admin', 13 | database: 'development', 14 | synchronize: true, 15 | logging: false, 16 | entities: ['**/*.entity.js'], 17 | }); 18 | break; 19 | case 'test': 20 | Object.assign(dbConfig, { 21 | type: 'postgres', 22 | host: 'localhost', 23 | port: 5432, 24 | username: 'postgres', 25 | password: 'admin', 26 | database: 'test', 27 | synchronize: true, 28 | logging: false, 29 | entities: ['**/*.entity.ts'], 30 | }); 31 | break; 32 | case 'production': 33 | break; 34 | default: 35 | throw new Error('Unknown environment'); 36 | } 37 | 38 | module.exports = dbConfig; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecommerce", 3 | "version": "0.3.0", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "cross-env NODE_ENV=development nest start", 12 | "dev": "cross-env NODE_ENV=development nest start --watch", 13 | "start:dev": "cross-env NODE_ENV=development nest start --watch", 14 | "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "cross-env NODE_ENV=test jest", 18 | "test:watch": "cross-env NODE_ENV=test jest --watchAll --maxWorkers=1", 19 | "test:cov": "cross-env NODE_ENV=test jest --coverage", 20 | "test:debug": "cross-env NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "cross-env NODE_ENV=test jest --config ./test/jest-e2e.json --maxWorkers=1", 22 | "typeorm": "cross-env NODE_ENV=development npm run build && npx typeorm -d dist/db-config/index.js", 23 | "migration:generate": "npm run typeorm -- migration:generate", 24 | "migration:run": "npm run typeorm -- migration:run", 25 | "migration:revert": "npm run typeorm -- migration:revert" 26 | }, 27 | "dependencies": { 28 | "@nestjs/axios": "^3.0.2", 29 | "@nestjs/cache-manager": "^2.2.2", 30 | "@nestjs/common": "^10.3.7", 31 | "@nestjs/config": "^3.2.2", 32 | "@nestjs/core": "^10.3.7", 33 | "@nestjs/jwt": "^10.2.0", 34 | "@nestjs/mapped-types": "*", 35 | "@nestjs/passport": "^10.0.3", 36 | "@nestjs/platform-express": "^10.3.7", 37 | "@nestjs/swagger": "^7.3.1", 38 | "@nestjs/terminus": "^10.2.3", 39 | "@nestjs/throttler": "^5.1.2", 40 | "@nestjs/typeorm": "^10.0.2", 41 | "cache-manager": "^5.5.1", 42 | "class-transformer": "^0.5.1", 43 | "class-validator": "^0.14.1", 44 | "cookie-parser": "^1.4.6", 45 | "cookie-session": "^2.1.0", 46 | "cross-env": "^7.0.3", 47 | "dotenv": "^16.4.5", 48 | "google-auth-library": "^9.8.0", 49 | "nodemailer": "^6.9.13", 50 | "passport": "^0.7.0", 51 | "passport-jwt": "^4.0.1", 52 | "passport-local": "^1.0.0", 53 | "pg": "^8.11.5", 54 | "reflect-metadata": "^0.2.2", 55 | "rxjs": "^7.8.1", 56 | "slugify": "^1.6.6", 57 | "typeorm": "^0.3.20", 58 | "uuid": "^9.0.1" 59 | }, 60 | "devDependencies": { 61 | "@nestjs/cli": "^10.3.2", 62 | "@nestjs/schematics": "^10.1.1", 63 | "@nestjs/testing": "^10.3.7", 64 | "@types/cache-manager": "^4.0.6", 65 | "@types/cookie-parser": "^1.4.7", 66 | "@types/express": "^4.17.21", 67 | "@types/jest": "29.5.12", 68 | "@types/node": "20.12.7", 69 | "@types/passport-jwt": "^4.0.1", 70 | "@types/supertest": "^6.0.2", 71 | "jest": "29.7.0", 72 | "jest-module-name-mapper": "^0.1.5", 73 | "prettier": "^3.2.5", 74 | "source-map-support": "^0.5.21", 75 | "supertest": "^6.3.4", 76 | "ts-jest": "29.1.2", 77 | "ts-loader": "^9.5.1", 78 | "ts-node": "^10.9.2", 79 | "tsconfig-paths": "4.2.0", 80 | "typescript": "^5.4.5" 81 | }, 82 | "jest": { 83 | "moduleFileExtensions": [ 84 | "js", 85 | "json", 86 | "ts" 87 | ], 88 | "rootDir": "src", 89 | "testRegex": ".*\\.spec\\.ts$", 90 | "transform": { 91 | "^.+\\.(t|j)s$": "ts-jest" 92 | }, 93 | "collectCoverageFrom": [ 94 | "**/*.(t|j)s" 95 | ], 96 | "coverageDirectory": "../coverage", 97 | "testEnvironment": "node" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { 5 | HealthCheckService, 6 | TypeOrmHealthIndicator, 7 | HttpHealthIndicator, 8 | } from '@nestjs/terminus'; 9 | import { AppInfoDto } from './common/dtos/app-info.dto'; 10 | 11 | describe('AppController', () => { 12 | let appController: AppController; 13 | let appService: AppService; 14 | let healthCheckService: HealthCheckService; 15 | let db: TypeOrmHealthIndicator; 16 | let http: HttpHealthIndicator; 17 | 18 | beforeEach(async () => { 19 | const app: TestingModule = await Test.createTestingModule({ 20 | controllers: [AppController], 21 | providers: [ 22 | AppService, 23 | { 24 | provide: HealthCheckService, 25 | useValue: { 26 | check: jest.fn().mockResolvedValue('health check result'), 27 | }, 28 | }, 29 | { 30 | provide: TypeOrmHealthIndicator, 31 | useValue: { 32 | pingCheck: jest.fn(), 33 | }, 34 | }, 35 | { 36 | provide: HttpHealthIndicator, 37 | useValue: { 38 | pingCheck: jest.fn(), 39 | }, 40 | }, 41 | ], 42 | }).compile(); 43 | 44 | appController = app.get(AppController); 45 | appService = app.get(AppService); 46 | healthCheckService = app.get(HealthCheckService); 47 | db = app.get(TypeOrmHealthIndicator); 48 | http = app.get(HttpHealthIndicator); 49 | }); 50 | 51 | describe('getAppInfo', () => { 52 | it('should return app information', () => { 53 | const result: AppInfoDto = { 54 | name: 'eCommerce APIs', 55 | version: '0.3.0', 56 | description: 'This is ecommerce API built with NestJS', 57 | web: { 58 | version: '0.4.0', 59 | lastUpdate: '2023-05-15', 60 | }, 61 | mobile: { 62 | ios: { 63 | version: '0.2.0', 64 | lastUpdate: '2023-05-15', 65 | }, 66 | android: { 67 | version: '0.2.0', 68 | lastUpdate: '2023-05-15', 69 | }, 70 | }, 71 | }; 72 | jest.spyOn(appService, 'getAppInfo').mockImplementation(() => result); 73 | 74 | expect(appController.getAppInfo()).toBe(result); 75 | }); 76 | }); 77 | 78 | describe('check', () => { 79 | it('should return health check result', () => { 80 | const result = 'health check result'; 81 | expect(appController.check()).resolves.toBe(result); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Inject, UseInterceptors } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | import { 5 | HealthCheck, 6 | HealthCheckService, 7 | HttpHealthIndicator, 8 | TypeOrmHealthIndicator, 9 | } from '@nestjs/terminus'; 10 | import { HealthCheckDto } from './common/dtos/healt-check.dto'; 11 | import { AppInfoDto } from './common/dtos/app-info.dto'; 12 | import { Cache } from 'cache-manager'; 13 | import { CACHE_MANAGER, CacheInterceptor } from '@nestjs/cache-manager'; 14 | 15 | @ApiTags('app') 16 | @Controller() 17 | export class AppController { 18 | constructor( 19 | private readonly appService: AppService, 20 | private readonly health: HealthCheckService, 21 | private readonly db: TypeOrmHealthIndicator, 22 | private readonly http: HttpHealthIndicator, 23 | ) {} 24 | 25 | @Get('info') 26 | @ApiOperation({ summary: 'Get app information' }) 27 | @ApiOkResponse({ 28 | description: 'Returns general information about the application', 29 | type: AppInfoDto, 30 | }) 31 | getAppInfo(): AppInfoDto { 32 | return this.appService.getAppInfo(); 33 | } 34 | 35 | @Get('health') 36 | @HealthCheck() 37 | @ApiOperation({ summary: 'Check health status' }) 38 | @ApiOkResponse({ 39 | description: 'Returns the health status of the application', 40 | type: HealthCheckDto, 41 | }) 42 | check() { 43 | return this.health.check([ 44 | async () => this.db.pingCheck('database', { timeout: 300 }), 45 | async () => 46 | this.http.pingCheck('api-docs', 'http://localhost:5000/api-doc'), 47 | ]); 48 | } 49 | 50 | @UseInterceptors(CacheInterceptor) 51 | @Get('hello') 52 | async getHello() { 53 | return this.appService.getHello(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { UsersModule } from './users/users.module'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { DatabaseModule } from './database/database.module'; 7 | import { ValidationModule } from './common/validation/validation.module'; 8 | import { TerminusModule } from '@nestjs/terminus'; 9 | import { AuthModule } from './auth/auth.module'; 10 | import { CacheModule } from '@nestjs/cache-manager'; 11 | import { HttpModule } from '@nestjs/axios'; 12 | import { CommonModule } from './common/common.module'; 13 | import { NotificationsModule } from './notifications/notifications.module'; 14 | import { RequestLoggingMiddleware } from './common/middlewares/logs.middleware'; 15 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 16 | import { APP_GUARD } from '@nestjs/core'; 17 | import { APP_RATE_LIMIT, APP_RATE_TTL } from './common/constants'; 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigModule.forRoot({ 22 | isGlobal: true, 23 | }), 24 | CacheModule.register({ 25 | ttl: 60000, // ttl in ms 26 | max: 1000, 27 | isGlobal: true, 28 | }), 29 | ThrottlerModule.forRoot([ 30 | { 31 | ttl: APP_RATE_TTL, 32 | limit: APP_RATE_LIMIT, 33 | }, 34 | ]), 35 | DatabaseModule, 36 | UsersModule, 37 | ValidationModule, 38 | TerminusModule, 39 | AuthModule, 40 | HttpModule, 41 | CommonModule, 42 | NotificationsModule, 43 | ], 44 | controllers: [AppController], 45 | providers: [ 46 | AppService, 47 | { 48 | provide: APP_GUARD, 49 | useClass: ThrottlerGuard, 50 | }, 51 | ], 52 | }) 53 | export class AppModule implements NestModule { 54 | configure(consumer: MiddlewareConsumer) { 55 | consumer.apply(RequestLoggingMiddleware).forRoutes('*'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppService } from './app.service'; 3 | 4 | describe('AppService', () => { 5 | let service: AppService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AppService], 10 | }).compile(); 11 | 12 | service = module.get(AppService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | 19 | it('should return app info', () => { 20 | const expectedAppInfo = { 21 | name: 'eCommerce APIs', 22 | version: '0.3.0', 23 | description: 'This is ecommerce API built with NestJS', 24 | web: { 25 | version: '0.4.0', 26 | lastUpdate: '2023-05-15', 27 | }, 28 | mobile: { 29 | ios: { 30 | version: '0.2.0', 31 | lastUpdate: '2023-05-15', 32 | }, 33 | android: { 34 | version: '0.2.0', 35 | lastUpdate: '2023-05-15', 36 | }, 37 | }, 38 | }; 39 | 40 | expect(service.getAppInfo()).toEqual(expectedAppInfo); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { AppInfoDto } from './common/dtos/app-info.dto'; 3 | import { CACHE_MANAGER } from '@nestjs/cache-manager'; 4 | import { Cache } from 'cache-manager'; 5 | 6 | @Injectable() 7 | export class AppService { 8 | constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} 9 | 10 | private readonly appInfo: AppInfoDto = { 11 | name: 'eCommerce APIs', 12 | version: '0.3.0', 13 | description: 'This is ecommerce API built with NestJS', 14 | web: { 15 | version: '0.4.0', 16 | lastUpdate: '2024-03-19', 17 | }, 18 | mobile: { 19 | ios: { 20 | version: '0.2.0', 21 | lastUpdate: '2024-03-19', 22 | }, 23 | android: { 24 | version: '0.2.0', 25 | lastUpdate: '2024-03-19', 26 | }, 27 | }, 28 | }; 29 | 30 | getAppInfo() { 31 | return this.appInfo; 32 | } 33 | 34 | async getHello() { 35 | await this.cacheManager.set('cached_item', { key: 32 }); 36 | const cachedItem = await this.cacheManager.get('cached_item'); 37 | console.log(cachedItem); 38 | return 'Hello World!'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { UsersService } from '../users/users.service'; 5 | import { JwtModule, JwtService } from '@nestjs/jwt'; 6 | import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm'; 7 | import { User } from '../users/entities/user.entity'; 8 | import { Repository } from 'typeorm'; 9 | import { ConfigModule, ConfigService } from '@nestjs/config'; 10 | import { PassportModule } from '@nestjs/passport'; 11 | import { UsersController } from '../users/users.controller'; 12 | import { v4 as uuidv4 } from 'uuid'; 13 | import { JwtStrategy } from './strategies/jwt.strategy'; 14 | 15 | describe('AuthController', () => { 16 | let authController: AuthController; 17 | let userService: Partial; 18 | let fakeAuthService: Partial; 19 | let fakeUserRepository: Partial>; 20 | const userArray: User[] = []; 21 | 22 | beforeEach(async () => { 23 | userService = { 24 | findAll: () => { 25 | return Promise.resolve([ 26 | { id: '1', email: 'test@test.com', password: 'test' } as User, 27 | ]); 28 | }, 29 | findOneById: (id: string) => { 30 | return Promise.resolve({ 31 | id, 32 | email: 'test@test.com', 33 | password: 'test', 34 | } as User); 35 | }, 36 | findByEmail: (email: string) => { 37 | return Promise.resolve({ 38 | id: '1', 39 | email, 40 | password: 'password', 41 | } as User); 42 | }, 43 | remove: (id: string) => { 44 | return Promise.resolve({ 45 | id, 46 | email: 'test@test.com', 47 | password: 'test', 48 | } as User); 49 | }, 50 | updateCurrentUser: (id: string, attrs: Partial) => { 51 | return Promise.resolve({ 52 | id, 53 | email: 'test@test.com', 54 | password: 'test', 55 | ...attrs, 56 | } as User); 57 | }, 58 | deactivate: (id: string) => { 59 | return Promise.resolve({ 60 | id, 61 | email: 'test@test.com', 62 | password: 'test', 63 | active: false, 64 | } as User); 65 | }, 66 | }; 67 | fakeAuthService = { 68 | // signup: () => {}, 69 | signin: (email: string, password: string) => { 70 | return Promise.resolve({ 71 | data: { 72 | id: '1', 73 | email, 74 | password, 75 | } as User, 76 | token: 'token', 77 | }); 78 | }, 79 | }; 80 | fakeUserRepository = { 81 | create: jest.fn().mockImplementation((user) => { 82 | user.id = uuidv4(); 83 | user.gender = 'other'; 84 | user.role = 'user'; 85 | return user; 86 | }), 87 | 88 | save: jest.fn().mockImplementation((user: User | User[]) => { 89 | if (Array.isArray(user)) { 90 | userArray.push(...user); 91 | } else { 92 | userArray.push(user); 93 | } 94 | return Promise.resolve(user); 95 | }), 96 | 97 | find: jest.fn().mockImplementation(() => { 98 | return Promise.resolve(userArray); 99 | }), 100 | 101 | findOne: jest.fn().mockImplementation((options) => { 102 | const foundUser = userArray.find( 103 | (user) => user.id === options.where.id, 104 | ); 105 | return Promise.resolve(foundUser); 106 | }), 107 | 108 | remove: jest.fn().mockImplementation((user: User) => { 109 | const index = userArray.findIndex((u) => u.id === user.id); 110 | if (index !== -1) { 111 | const [removedUser] = userArray.splice(index, 1); 112 | return Promise.resolve(removedUser); 113 | } 114 | return Promise.resolve(undefined); 115 | }), 116 | 117 | findOneBy: jest.fn().mockImplementation((options) => { 118 | const keys = Object.keys(options); 119 | const foundUser = userArray.find((user) => { 120 | return keys.every((key) => user[key] === options[key]); 121 | }); 122 | return Promise.resolve(foundUser); 123 | }), 124 | preload: jest.fn().mockImplementation((values) => { 125 | const user = userArray.find((user) => user.id === values.id); 126 | if (!user) { 127 | return undefined; 128 | } 129 | return { ...user, ...values }; 130 | }), 131 | }; 132 | 133 | const module: TestingModule = await Test.createTestingModule({ 134 | imports: [ 135 | ConfigModule.forRoot(), 136 | JwtModule.registerAsync({ 137 | imports: [ConfigModule], 138 | useFactory: async (configService: ConfigService) => ({ 139 | secret: configService.get('ACCESS_TOKEN_SECRET'), 140 | signOptions: { expiresIn: '90d' }, 141 | }), 142 | inject: [ConfigService], 143 | }), 144 | ], 145 | controllers: [AuthController, UsersController], 146 | providers: [ 147 | JwtStrategy, 148 | { provide: UsersService, useValue: userService }, 149 | { provide: AuthService, useValue: fakeAuthService }, 150 | { 151 | provide: JwtService, 152 | useValue: new JwtService({ secret: 'test-secret' }), 153 | }, 154 | { 155 | provide: getRepositoryToken(User), 156 | useValue: fakeUserRepository, 157 | }, 158 | ], 159 | }).compile(); 160 | 161 | authController = module.get(AuthController); 162 | }); 163 | 164 | it('should be defined', () => { 165 | expect(authController).toBeDefined(); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpStatus, Patch, Post, Res } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | import { SignupUserDto } from './dtos/signup-user.dto'; 5 | import { Response } from 'express'; 6 | import { GoogleLoginDto } from './dtos/google-login.dto'; 7 | import { 8 | ChangePasswordDecorator, 9 | ConfirmEmailSetupDecorator, 10 | ConfirmPhoneSetupDecorator, 11 | DisableTwoFactorAuthDecorator, 12 | ForgotPasswordDecorator, 13 | GoogleLoginDecorator, 14 | // LoginFromNewDeviceWithTwoFactorDecorator, 15 | LoginUsersDecorator, 16 | LoginWithTwoFactorDecorator, 17 | LogoutUsersDecorator, 18 | RefreshTokenDecorator, 19 | RegisterUsersDecorator, 20 | ResendTwoFactorAuthDecorator, 21 | ResendTwoFactorAuthForLoginDecorator, 22 | ResetPasswordDecorator, 23 | SetupTwoFactorAuthDecorator, 24 | VerifyEmailSetupDecorator, 25 | VerifyPhoneSetupDecorator, 26 | VerifyTwoFactorAuthDecorator, 27 | VerifyTwoFactorAuthToDisableDecorator, 28 | } from './decorators'; 29 | import { CurrentUser } from 'src/users/decorators/current-user.decorator'; 30 | import { ChangePasswordDto } from './dtos/change-password.dto'; 31 | import { ForgotPasswordDto } from './dtos/forgot-password.dto'; 32 | import { PasswordService } from './password.service'; 33 | import { ResetPasswordDto } from './dtos/reset-password.dto'; 34 | import { AuthenticatedResponseDto } from './dtos/auth-response.dto'; 35 | import { TokenService } from './token.service'; 36 | import { UsersService } from 'src/users/users.service'; 37 | import { TwoFactorAuthenticationService } from './two-factor.service'; 38 | import { ValidateOtpDto } from './dtos/validate-otp.dto'; 39 | import { Resend2faOtpDto } from './dtos/resend-2fa-otp.dto'; 40 | import { LoginWithTwoFactorAuthenticationDto } from './dtos/login-with-2fa.dto'; 41 | // import { User } from '../users/entities/user.entity'; 42 | import { Throttle } from '@nestjs/throttler'; 43 | import { VerificationService } from './verification.service'; 44 | import { DataToBeVerified } from './enums'; 45 | 46 | @ApiTags('auth') 47 | @Controller('auth') 48 | export class AuthController { 49 | constructor( 50 | private readonly authService: AuthService, 51 | private readonly passwordService: PasswordService, 52 | private readonly tokenService: TokenService, 53 | private readonly usersService: UsersService, 54 | private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService, 55 | private readonly verificationService: VerificationService, 56 | ) {} 57 | 58 | @RegisterUsersDecorator() 59 | @Post('register') 60 | async registerUser(@Body() body: SignupUserDto) { 61 | return await this.authService.register(body.email, body.password); 62 | } 63 | 64 | @Throttle({ default: { limit: 5, ttl: 60000 } }) 65 | @LoginUsersDecorator() 66 | @Post('login') 67 | async signin(@CurrentUser() user: any) { 68 | if ('tempAuthToken' in user) { 69 | return { tempAuthToken: user.tempAuthToken }; 70 | } 71 | return await this.authService.login(user); 72 | } 73 | 74 | @LogoutUsersDecorator() 75 | @Post('logout') 76 | logout(@CurrentUser() user: any) { 77 | return this.authService.logout(user); 78 | } 79 | 80 | @LoginWithTwoFactorDecorator() 81 | @Post('login-with-two-factor-authentication') 82 | async loginWith2FA( 83 | @Body() body: LoginWithTwoFactorAuthenticationDto, 84 | @CurrentUser() user: any, 85 | ) { 86 | return await this.authService.loginWithOtp(body, user); 87 | } 88 | 89 | @ResendTwoFactorAuthForLoginDecorator() 90 | @Post('resend-two-factor-authentication-for-login') 91 | async resend2faForLogin(@Body() body: Resend2faOtpDto) { 92 | return await this.twoFactorAuthenticationService.resend2faForLogin(body); 93 | } 94 | 95 | // @LoginFromNewDeviceWithTwoFactorDecorator() 96 | // @Post('login-from-new-device-with-two-factor-authentication') 97 | // async loginWithOtp( 98 | // @Body() body: LoginWithTwoFactorAuthenticationDto, 99 | // ): Promise { 100 | // return this.authService.loginWithOtpFromNewDevice(body); 101 | // } 102 | 103 | @GoogleLoginDecorator() 104 | @Post('google-login') 105 | async googleLogin(@Body() body: GoogleLoginDto) { 106 | return await this.authService.googleLogin(body.credential); 107 | } 108 | 109 | @ChangePasswordDecorator() 110 | @Patch('change-password') 111 | async changeMyPassword( 112 | @CurrentUser() user: any, 113 | @Body() changePasswordDto: ChangePasswordDto, 114 | ) { 115 | this.passwordService.changePassword(user.id, changePasswordDto); 116 | 117 | const updatedUser = await this.usersService.findOneById(user.id); 118 | return await this.authService.login(updatedUser); 119 | } 120 | 121 | @ForgotPasswordDecorator() 122 | @Post('forgot-password') 123 | async forgotPassword( 124 | @Body() body: ForgotPasswordDto, 125 | @Res() response: Response, 126 | ): Promise { 127 | await this.passwordService.forgotPassword(body.email); 128 | return response.status(HttpStatus.OK).json({ 129 | message: 'Password reset email is sent ', 130 | }); 131 | } 132 | 133 | @ResetPasswordDecorator() 134 | @Post('set-new-password') 135 | async resetPassword( 136 | @Body() body: ResetPasswordDto, 137 | ): Promise { 138 | return await this.passwordService.resetPassword( 139 | body.resetToken, 140 | body.newPassword, 141 | ); 142 | } 143 | 144 | // @UseGuards(AuthGuard('refresh')) 145 | @RefreshTokenDecorator() 146 | @Post('refresh-token') 147 | async refresh(@CurrentUser() user: any): Promise { 148 | const accessToken = await this.tokenService.createAccessToken(user); 149 | const refreshToken = await this.tokenService.createRefreshToken(user); 150 | 151 | return { 152 | accessToken, 153 | refreshToken, 154 | }; 155 | } 156 | 157 | @SetupTwoFactorAuthDecorator() 158 | @Post('enable-2fa') 159 | async setup2fa(@CurrentUser() user: any): Promise<{ message: string }> { 160 | return await this.twoFactorAuthenticationService.setup2FA(user); 161 | } 162 | 163 | @VerifyTwoFactorAuthDecorator() 164 | @Post('verify-2fa-to-enable') 165 | async verify2FA( 166 | @CurrentUser() user: any, 167 | @Body() body: ValidateOtpDto, 168 | ): Promise<{ message: string }> { 169 | return await this.twoFactorAuthenticationService.verify2FA( 170 | user, 171 | body, 172 | true, 173 | ); 174 | } 175 | 176 | @ResendTwoFactorAuthDecorator() 177 | @Post('resend-2fa-code') 178 | async resend2faOtp(@CurrentUser() user: any): Promise<{ message: string }> { 179 | return this.twoFactorAuthenticationService.resend2faOtp(user.id); 180 | } 181 | 182 | @DisableTwoFactorAuthDecorator() 183 | @Post('disable-2fa') 184 | async disable2faLogin( 185 | @CurrentUser() user: any, 186 | ): Promise<{ message: string }> { 187 | return this.twoFactorAuthenticationService.setup2FA(user.id); 188 | } 189 | 190 | @VerifyTwoFactorAuthToDisableDecorator() 191 | @Post('verify-2fa-to-disable') 192 | async verify2faForDisable( 193 | @CurrentUser() user: any, 194 | @Body() body: ValidateOtpDto, 195 | ): Promise<{ message: string }> { 196 | return await this.twoFactorAuthenticationService.verify2FA( 197 | user, 198 | body, 199 | false, 200 | ); 201 | } 202 | 203 | @VerifyEmailSetupDecorator() 204 | @Throttle({ default: { limit: 1, ttl: 60000 } }) 205 | @Post('verify-email') 206 | verifyEmailSetup(@CurrentUser() user: any) { 207 | return this.verificationService.setupPhoneOrEmailVerfication( 208 | user, 209 | DataToBeVerified.Email, 210 | ); 211 | } 212 | 213 | @ConfirmEmailSetupDecorator() 214 | @Post('confirm-email') 215 | confirmEmailSetup(@CurrentUser() user: any, @Body() body: ValidateOtpDto) { 216 | return this.verificationService.verifyEmailVerification( 217 | user, 218 | body, 219 | DataToBeVerified.Email, 220 | ); 221 | } 222 | 223 | @VerifyPhoneSetupDecorator() 224 | @Throttle({ default: { limit: 1, ttl: 60000 } }) 225 | @Post('verify-phone') 226 | verifyPhoneSetup(@CurrentUser() user: any) { 227 | return this.verificationService.setupPhoneOrEmailVerfication( 228 | user, 229 | DataToBeVerified.Phone, 230 | ); 231 | } 232 | @ConfirmPhoneSetupDecorator() 233 | @Post('confirm-phone') 234 | confirmPhoneSetup(@CurrentUser() user: any, @Body() body: ValidateOtpDto) { 235 | return this.verificationService.verifyEmailVerification( 236 | user, 237 | body, 238 | DataToBeVerified.Phone, 239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MiddlewareConsumer, 3 | Module, 4 | NestModule, 5 | RequestMethod, 6 | } from '@nestjs/common'; 7 | import { AuthService } from './auth.service'; 8 | import { AuthController } from './auth.controller'; 9 | import { PassportModule } from '@nestjs/passport'; 10 | import { JwtModule } from '@nestjs/jwt'; 11 | import { ConfigModule, ConfigService } from '@nestjs/config'; 12 | import { JwtStrategy } from './strategies/jwt.strategy'; 13 | import { UsersModule } from 'src/users/users.module'; 14 | import { PasswordService } from './password.service'; 15 | import { TokenService } from './token.service'; 16 | import { LocalStrategy } from './strategies/local.strategy'; 17 | import { TypeOrmModule } from '@nestjs/typeorm'; 18 | import { RefreshToken } from './entities/refresh-token.entity'; 19 | import { RefreshTokenStrategy } from './strategies/refresh.strategy'; 20 | import { ValidateLoginMiddleware } from './middlewares/validation.middleware'; 21 | import { CryptoService } from './crypto.service'; 22 | import { TwoFactorAuthenticationService } from './two-factor.service'; 23 | import { CommonModule } from 'src/common/common.module'; 24 | import { NotificationsModule } from 'src/notifications/notifications.module'; 25 | import { VerificationService } from './verification.service'; 26 | 27 | @Module({ 28 | imports: [ 29 | TypeOrmModule.forFeature([RefreshToken]), 30 | ConfigModule, 31 | PassportModule, 32 | JwtModule.registerAsync({ 33 | imports: [ConfigModule], 34 | useFactory: async (configService: ConfigService) => ({ 35 | secret: configService.get('ACCESS_TOKEN_SECRET'), 36 | signOptions: { expiresIn: '30d' }, 37 | }), 38 | inject: [ConfigService], 39 | }), 40 | UsersModule, 41 | CommonModule, 42 | NotificationsModule, 43 | ], 44 | controllers: [AuthController], 45 | providers: [ 46 | AuthService, 47 | JwtStrategy, 48 | LocalStrategy, 49 | RefreshTokenStrategy, 50 | PasswordService, 51 | TokenService, 52 | CryptoService, 53 | TwoFactorAuthenticationService, 54 | VerificationService, 55 | ], 56 | }) 57 | export class AuthModule implements NestModule { 58 | configure(consumer: MiddlewareConsumer) { 59 | consumer 60 | .apply(ValidateLoginMiddleware) 61 | .forRoutes({ path: 'auth/login', method: RequestMethod.POST }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | import { UsersService } from '../users/users.service'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { 7 | BadRequestException, 8 | NotFoundException, 9 | ForbiddenException, 10 | } from '@nestjs/common'; 11 | import { ChangePasswordDto } from './dtos/change-password.dto'; 12 | import { ConfigModule, ConfigService } from '@nestjs/config'; 13 | import { UpdateUserDto } from '../users/dtos/update-user.dto'; 14 | import { TokenService } from './token.service'; 15 | import { PasswordService } from './password.service'; 16 | import { CryptoService } from './crypto.service'; 17 | 18 | const mockVerifyIdToken = jest.fn(); 19 | 20 | jest.mock('google-auth-library', () => { 21 | const mockVerifyIdToken = jest.fn(); 22 | return { 23 | OAuth2Client: jest.fn(() => ({ 24 | verifyIdToken: mockVerifyIdToken, 25 | })), 26 | mockVerifyIdToken, // Expose mockVerifyIdToken so that it can be accessed outside the mock factory 27 | }; 28 | }); 29 | 30 | // Describe a test suite for the AuthService 31 | describe('AuthService', () => { 32 | // Declare variables for the AuthService and a fake UsersService 33 | let authService: AuthService; 34 | let userService: Partial; 35 | let tokenService: Partial; 36 | let passwordService: Partial; 37 | let cryptoService: Partial; 38 | let users: User[] = []; 39 | 40 | // Run this function before each test in the suite 41 | beforeEach(async () => { 42 | users = []; // Create an array to store fake users 43 | 44 | // Create a fake UsersService with mocked 'find' and 'create' methods 45 | userService = { 46 | // Mock the 'find' method to return users with a matching email from the 'users' array 47 | findOneById: (id: string) => { 48 | const user = users.find((user) => user.id === id); 49 | return Promise.resolve(user); 50 | }, 51 | findByEmail: (email: string) => { 52 | const user = users.find((user) => user.email === email); 53 | return Promise.resolve(user); 54 | }, 55 | 56 | // Mock the 'findByGoogleId' method to return users with a matching Google ID from the 'users' array 57 | findByGoogleId: (googleId: string) => { 58 | const user = users.find((user) => user.googleId === googleId); 59 | return Promise.resolve(user); 60 | }, 61 | 62 | // Mock the 'create' method to add a new user to the 'users' array and return it 63 | // create: (email: string, password: string) => { 64 | // const user = { 65 | // email, 66 | // password, 67 | // } as User; 68 | // users.push(user); 69 | // return Promise.resolve(user); 70 | // }, 71 | 72 | create: jest 73 | .fn() 74 | .mockImplementation((email: string, password: string) => { 75 | return Promise.resolve({ email, password: `hashed-${password}` }); // Adjust the implementation as needed 76 | }), 77 | 78 | // Mock the 'createFromGoogle' method to create a new user from Google login 79 | createFromGoogle: (payload: any) => { 80 | const user = { 81 | id: '12345', 82 | email: payload.email, 83 | googleId: payload.sub, 84 | } as User; 85 | users.push(user); 86 | return Promise.resolve(user); 87 | }, 88 | updateCurrentUser: (id: string, attrs: Partial) => { 89 | let user = userService.findOneById(id); 90 | if (!user) { 91 | throw new NotFoundException('user not found'); 92 | } 93 | user = { ...user, ...attrs }; 94 | return Promise.resolve(user); 95 | }, 96 | }; 97 | 98 | cryptoService = { 99 | hashPassword: jest.fn().mockImplementation((password: string) => { 100 | return Promise.resolve(`hashed-${password}`); // Mock hashing logic for testing 101 | }), 102 | validatePassword: jest 103 | .fn() 104 | .mockImplementation((password: string, hashedPassword: string) => { 105 | return Promise.resolve(password === hashedPassword); // Simplistic mock validation logic for testing 106 | }), 107 | }; 108 | 109 | tokenService = { 110 | createAccessToken: jest.fn().mockImplementation((user: any) => { 111 | return 'fakeAccessToken'; // Mock token creation logic 112 | }), 113 | createRefreshToken: jest.fn().mockImplementation((user: any) => { 114 | return 'fakeRefreshToken'; // Mock token creation logic 115 | }), 116 | }; 117 | 118 | // Create a test module with JwtModule and the necessary AuthService and UsersService providers 119 | const module = await Test.createTestingModule({ 120 | imports: [ 121 | ConfigModule.forRoot(), 122 | JwtModule.registerAsync({ 123 | imports: [ConfigModule], 124 | useFactory: async (configService: ConfigService) => ({ 125 | secret: configService.get('ACCESS_TOKEN_SECRET'), 126 | signOptions: { expiresIn: '90d' }, 127 | }), 128 | inject: [ConfigService], 129 | }), 130 | ], 131 | providers: [ 132 | AuthService, 133 | { 134 | provide: UsersService, 135 | useValue: userService, 136 | }, 137 | { 138 | provide: TokenService, 139 | useValue: tokenService, 140 | }, 141 | { 142 | provide: PasswordService, 143 | useValue: passwordService, 144 | }, 145 | { provide: CryptoService, useValue: cryptoService }, 146 | ], 147 | }).compile(); // Compile the test module, which resolves the dependencies and creates an instance 148 | 149 | // Get an instance of AuthService from the compiled test module 150 | authService = module.get(AuthService); 151 | }); 152 | 153 | // Test if an AuthService instance can be created 154 | it('can create an instance of auth service', async () => { 155 | expect(authService).toBeDefined(); 156 | }); 157 | 158 | it('creates a new user and returns access and refresh tokens', async () => { 159 | const mockUser = { email: 'user4@test.com', password: '123456' }; 160 | const expectedTokens = { 161 | accessToken: 'fakeAccessToken', 162 | refreshToken: 'fakeRefreshToken', 163 | }; 164 | 165 | const result = await authService.register( 166 | mockUser.email, 167 | mockUser.password, 168 | ); 169 | 170 | expect(result).toEqual(expectedTokens); 171 | expect(userService.create).toHaveBeenCalledWith( 172 | mockUser.email, 173 | expect.any(String), 174 | ); // Check if userService.create was called correctly 175 | // Here, expect.any(String) is used to indicate that we expect a string, but not matching it exactly due to hashing 176 | expect(tokenService.createAccessToken).toHaveBeenCalledWith( 177 | expect.objectContaining({ email: mockUser.email }), 178 | ); 179 | expect(tokenService.createRefreshToken).toHaveBeenCalledWith( 180 | expect.objectContaining({ email: mockUser.email }), 181 | ); 182 | // No need to verify that result.password is undefined as password should not be in the token generation response 183 | }); 184 | 185 | // Test if the AuthService throws an error when signing up with an email that is already in use 186 | it('throws an error if user signs up with email that is in use', async () => { 187 | const email = 'asdf@asdf.com'; 188 | const password = 'asdf'; 189 | 190 | // Simulate existing user 191 | users.push({ email, password: `hashed-${password}` } as User); 192 | 193 | await expect(authService.register(email, password)).rejects.toThrow( 194 | BadRequestException, 195 | ); 196 | }); 197 | 198 | // // Test if the AuthService throws an error when signing in with an unused email 199 | 200 | // it('throws if signin is called with an unused email', async () => { 201 | // await expect( 202 | // authService.signin('asdflkj@asdlfkj.com', 'passdflkj'), 203 | // ).rejects.toThrow(NotFoundException); 204 | // }); 205 | 206 | // it('throws if an invalid password is provided', async () => { 207 | // await authService.signup('laskdjf@alskdfj.com', 'password'); 208 | // await expect( 209 | // authService.signin('laskdjf@alskdfj.com', 'laksdlfkj'), 210 | // ).rejects.toThrow(BadRequestException); 211 | // }); 212 | 213 | // it('returns a user if correct password is provided', async () => { 214 | // await authService.signup('test@test.com', 'password'); 215 | 216 | // const user = await authService.signin('test@test.com', 'password'); 217 | // expect(user).toBeDefined(); 218 | // }); 219 | 220 | // it('can handle Google login', async () => { 221 | // const token = 'fakeGoogleToken'; 222 | // const payload = { 223 | // sub: '12345', 224 | // email: 'test@gmail.com', 225 | // picture: 'http://example.com/image.jpg', 226 | // displayName: 'Test User', 227 | // given_name: 'Test', 228 | // family_name: 'User', 229 | // email_verified: true, 230 | // }; 231 | 232 | // require('google-auth-library').mockVerifyIdToken.mockResolvedValueOnce({ 233 | // getPayload: () => payload, 234 | // }); 235 | 236 | // const response = await authService.googleLogin(token); 237 | 238 | // expect(response).toBeDefined(); 239 | // expect(response.data.id).toEqual(payload.sub); 240 | // expect(response.data.email).toEqual(payload.email); 241 | // }); 242 | 243 | // it('changes the password and allows the user to sign in with the new password', async () => { 244 | // const userId = '12345'; 245 | // const changePasswordDto = new ChangePasswordDto(); 246 | // changePasswordDto.oldPassword = 'oldPassword'; 247 | // changePasswordDto.newPassword = 'newPassword'; 248 | 249 | // // Create a user before changing the password 250 | // await authService.signup('user@test.com', 'oldPassword'); 251 | 252 | // const result = await passwordService.changePassword( 253 | // userId, 254 | // changePasswordDto, 255 | // ); 256 | 257 | // // Verify that the message is correct 258 | // expect(result.message).toEqual('Password successfully updated'); 259 | 260 | // // Verify that the user is able to sign in with the new password 261 | // const user = await authService.signin('user@test.com', 'newPassword'); 262 | // expect(user).toBeDefined(); 263 | 264 | // // Verify that the findOneById method was called on the UsersService instance 265 | // //expect(userService.findOneById).toHaveBeenCalledWith(userId); 266 | // }); 267 | 268 | // // Test if the AuthService throws an error when changing password with an incorrect old password 269 | // it('throws an error if old password is incorrect when changing password', async () => { 270 | // const userId = '12345'; 271 | // const changePasswordDto = new ChangePasswordDto(); 272 | // changePasswordDto.oldPassword = 'incorrectOldPassword'; 273 | // changePasswordDto.newPassword = 'newPassword'; 274 | 275 | // // Create a user before changing the password 276 | // await authService.signup('user@test.com', 'oldPassword'); 277 | 278 | // await expect( 279 | // passwordService.changePassword(userId, changePasswordDto), 280 | // ).rejects.toThrow(BadRequestException); 281 | // }); 282 | 283 | // it('throws an error if the Google login token is invalid', async () => { 284 | // const invalidToken = 'invalidGoogleToken'; 285 | // require('google-auth-library').mockVerifyIdToken.mockRejectedValueOnce( 286 | // new Error('Invalid token'), 287 | // ); 288 | 289 | // await expect(authService.googleLogin(invalidToken)).rejects.toThrow(Error); 290 | // }); 291 | 292 | // it('throws an error if user tries to sign in with a Google-associated email', async () => { 293 | // const googleUserEmail = 'googleUser@test.com'; 294 | // const password = 'password'; 295 | 296 | // // Add a user with Google-associated email to the fake users array 297 | // users.push({ 298 | // id: 'googleUserId', 299 | // email: googleUserEmail, 300 | // googleId: 'googleUserGoogleId', // This user has a Google ID, so they're a Google user 301 | // password: null, // Google users don't have a password 302 | // } as User); 303 | 304 | // await expect(authService.signin(googleUserEmail, password)).rejects.toThrow( 305 | // new ForbiddenException('Please use Google to login'), 306 | // ); 307 | // }); 308 | 309 | // it('throws an error if user tries to change the password of a non-existent user', async () => { 310 | // const nonExistentUserId = 'nonExistentUser12345'; 311 | // const changePasswordDto = new ChangePasswordDto(); 312 | // changePasswordDto.oldPassword = 'oldPassword'; 313 | // changePasswordDto.newPassword = 'newPassword'; 314 | 315 | // await expect( 316 | // passwordService.changePassword(nonExistentUserId, changePasswordDto), 317 | // ).rejects.toThrow(NotFoundException); 318 | // }); 319 | }); 320 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ForbiddenException, 4 | Injectable, 5 | Logger, 6 | NotFoundException, 7 | UnauthorizedException, 8 | UnprocessableEntityException, 9 | } from '@nestjs/common'; 10 | import { UsersService } from '../users/users.service'; 11 | import { OAuth2Client } from 'google-auth-library'; 12 | import { User, UserStatus } from '../users/entities/user.entity'; 13 | import { AccountInactiveException } from '../common/exceptions/account-inactive.exception'; 14 | import { TokenService } from './token.service'; 15 | import { CryptoService } from './crypto.service'; 16 | import { TwoFactorAuthenticationService } from './two-factor.service'; 17 | import { JwtService } from '@nestjs/jwt'; 18 | import { AuthenticatedResponseDto } from './dtos/auth-response.dto'; 19 | import { LoginWithTwoFactorAuthenticationDto } from './dtos/login-with-2fa.dto'; 20 | import { InjectRepository } from '@nestjs/typeorm'; 21 | import { RefreshToken } from './entities/refresh-token.entity'; 22 | import { Repository } from 'typeorm'; 23 | 24 | // Initialize the OAuth2Client with the Google client ID from the environment variables. 25 | const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); 26 | 27 | @Injectable() 28 | export class AuthService { 29 | private readonly logger = new Logger(AuthService.name); 30 | 31 | constructor( 32 | @InjectRepository(RefreshToken) 33 | private readonly refreshTokenRepo: Repository, 34 | private readonly usersService: UsersService, 35 | private readonly tokenService: TokenService, 36 | private readonly cryptoService: CryptoService, 37 | private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService, 38 | private readonly jwtService: JwtService, 39 | ) {} 40 | 41 | async register(email: string, password: string) { 42 | // Check if the email is already in use 43 | const user = await this.usersService.findByEmail(email); 44 | 45 | // If the user exists and has a Google ID, they should use Google to log in 46 | if (user && user.googleId) { 47 | throw new ForbiddenException( 48 | 'This email is associated with a Google account. Please use Google to sign in.', 49 | ); 50 | } 51 | 52 | // If the user exists but is not associated with Google, throw an exception 53 | if (user) throw new BadRequestException('Email is already in use.'); 54 | 55 | // Hash the password using the CryptoService 56 | const hashedPassword = await this.cryptoService.hashPassword(password); 57 | 58 | // Create and save the new user with the hashed password 59 | const createdUser = await this.usersService.create( 60 | email.toLowerCase(), // Good practice to normalize emails 61 | hashedPassword, 62 | ); 63 | 64 | // Generate access and refresh tokens for the new user 65 | const accessToken = await this.tokenService.createAccessToken(createdUser); 66 | const refreshToken = 67 | await this.tokenService.createRefreshToken(createdUser); 68 | 69 | this.logger.log( 70 | JSON.stringify({ 71 | action: 'register', 72 | userId: createdUser.id, 73 | email: createdUser.email, 74 | }), 75 | ); 76 | // Return the tokens 77 | return { 78 | accessToken, 79 | refreshToken, 80 | }; 81 | } 82 | 83 | async login(user: User) { 84 | // Check if the user's account is active 85 | if ( 86 | user.status === UserStatus.Inactive || 87 | user.status === UserStatus.Deleted || 88 | user.status === UserStatus.Blocked 89 | ) { 90 | throw new AccountInactiveException( 91 | 'Your account is not active. Please contact support.', 92 | ); 93 | } 94 | 95 | if (user.isTwoFactorAuthEnabled) { 96 | this.logger.log( 97 | JSON.stringify({ 98 | action: 'login-2fa-attempt', 99 | userId: user.id, 100 | method: 'email', 101 | }), 102 | ); 103 | const tempAuthToken = this.jwtService.sign( 104 | { userId: user.id }, 105 | { expiresIn: '10m' }, 106 | ); 107 | const setupResult = 108 | await this.twoFactorAuthenticationService.setup2FA(user); 109 | 110 | return { 111 | tempAuthToken: tempAuthToken, 112 | message: setupResult.message, 113 | }; 114 | } else { 115 | // User does not have 2FA enabled, proceed with normal login 116 | const accessToken = await this.tokenService.createAccessToken(user); 117 | const refreshToken = await this.tokenService.createRefreshToken(user); 118 | 119 | this.logger.log( 120 | JSON.stringify({ 121 | action: 'login', 122 | userId: user.id, 123 | method: 'email', 124 | }), 125 | ); 126 | 127 | return { 128 | accessToken, 129 | refreshToken, 130 | message: 'Login successful.', 131 | }; 132 | } 133 | } 134 | 135 | // async loginWithOtp( 136 | // body: LoginWithTwoFactorAuthenticationDto, 137 | // ): Promise { 138 | // // Decode tempAuthToken to get userId 139 | // const decoded = this.jwtService.verify(body.tempAuthToken); 140 | // const user = await this.usersService.findOneById(decoded.userId); 141 | 142 | // // Perform OTP verification 143 | // const isOtpValid = await this.cryptoService.validateOtp( 144 | // body.otp, 145 | // user.twoFactorAuthToken, 146 | // ); 147 | 148 | // if (!isOtpValid) { 149 | // throw new UnauthorizedException('Invalid OTP. Please try again.'); 150 | // } 151 | 152 | // // Generate the real access and refresh tokens upon successful OTP verification 153 | // const accessToken = await this.tokenService.createAccessToken(user); 154 | // const refreshToken = await this.tokenService.createRefreshToken(user); 155 | 156 | // return { 157 | // accessToken, 158 | // refreshToken, 159 | // }; 160 | // } 161 | 162 | async loginWithOtp( 163 | body: LoginWithTwoFactorAuthenticationDto, 164 | user: any, 165 | ): Promise { 166 | const isOtpValid = await this.cryptoService.validateOtp( 167 | body.otp, 168 | user.twoFactorAuthToken, 169 | ); 170 | 171 | if (!isOtpValid) { 172 | throw new UnauthorizedException( 173 | 'Invalid Two Factor Autentication Code. Please try again.', 174 | ); 175 | } 176 | 177 | const accessToken = await this.tokenService.createAccessToken(user); 178 | const refreshToken = await this.tokenService.createRefreshToken(user); 179 | 180 | return { 181 | accessToken, 182 | refreshToken, 183 | }; 184 | } 185 | 186 | async googleLogin(token: string) { 187 | // Verify the Google ID token and extract the user's information 188 | const ticket = await client.verifyIdToken({ 189 | idToken: token, 190 | audience: process.env.GOOGLE_CLIENT_ID, 191 | }); 192 | const payload = ticket.getPayload(); 193 | const googleId = payload['sub']; // Extract Google ID 194 | 195 | // Attempt to find or create a user with the Google ID 196 | let user = await this.usersService.findByGoogleId(googleId); 197 | 198 | if (!user) { 199 | user = await this.usersService.createFromGoogle({ 200 | email: payload['email'], 201 | googleId, 202 | picture: payload['picture'], 203 | displayName: payload['displayName'], 204 | firstName: payload['given_name'], 205 | provider: 'google', 206 | isActivatedWithEmail: payload['email_verified'], 207 | }); 208 | } 209 | 210 | // Generate and return access and refresh tokens for the user 211 | const accessToken = await this.tokenService.createAccessToken(user); 212 | const refreshToken = await this.tokenService.createRefreshToken(user); 213 | 214 | this.logger.log( 215 | JSON.stringify({ 216 | action: 'login', 217 | userId: user.id, 218 | method: 'google', 219 | }), 220 | ); 221 | 222 | return { 223 | accessToken, 224 | refreshToken, 225 | }; 226 | } 227 | 228 | async verifyUser(email: string, password: string): Promise { 229 | // Attempt to find the user by email 230 | const user = await this.usersService.findByEmail(email); 231 | // Check if the user has previously signed up with Google 232 | if (user && user.provider === 'google') { 233 | throw new UnprocessableEntityException( 234 | 'An account with this email address already exists through a different method.', 235 | ); 236 | } 237 | 238 | // If no user found, throw an exception 239 | if (!user) { 240 | throw new NotFoundException('No user found with this email address.'); 241 | } 242 | 243 | // Verify the password against the stored hash using the CryptoService 244 | const isValidPassword = await this.cryptoService.validatePassword( 245 | password, 246 | user.password, 247 | ); 248 | if (!isValidPassword) { 249 | throw new BadRequestException('Incorrect email or password.'); 250 | } 251 | 252 | // Return the verified user 253 | return user; 254 | } 255 | 256 | async logout(user: User): Promise<{ message: string }> { 257 | await this.usersService.updateCurrentUser(user.id, { fcmToken: null }); 258 | await this.refreshTokenRepo.remove(user.refreshTokens); 259 | return { 260 | message: 'Logout successful.', 261 | }; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/auth/crypto.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { randomBytes, scrypt as _scrypt } from 'crypto'; 3 | import { promisify } from 'util'; 4 | 5 | const scrypt = promisify(_scrypt); 6 | 7 | @Injectable() 8 | export class CryptoService { 9 | async hashPassword(password: string): Promise { 10 | const salt = randomBytes(8).toString('hex'); 11 | const hash = (await scrypt(password, salt, 32)) as Buffer; 12 | return `${salt}.${hash.toString('hex')}`; 13 | } 14 | 15 | async validatePassword( 16 | password: string, 17 | storedPassword: string, 18 | ): Promise { 19 | const [salt, storedHash] = storedPassword.split('.'); 20 | const hash = (await scrypt(password, salt, 32)) as Buffer; 21 | return storedHash === hash.toString('hex'); 22 | } 23 | 24 | async generateAndHashOtp6Figures(): Promise<[string, string]> { 25 | const otp = Math.floor(100000 + Math.random() * 900000).toString(); 26 | const salt = randomBytes(8).toString('hex'); 27 | const hashedOtp = (await scrypt(otp, salt, 32)) as Buffer; 28 | return [otp, `${salt}.${hashedOtp.toString('hex')}`]; 29 | } 30 | 31 | async validateOtp(otp: string, storedOtp: string): Promise { 32 | const [salt, storedHash] = storedOtp.split('.'); 33 | const hash = (await scrypt(otp, salt, 32)) as Buffer; 34 | return storedHash === hash.toString('hex'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/auth/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards, applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiBearerAuth, 5 | ApiBody, 6 | ApiCreatedResponse, 7 | ApiForbiddenResponse, 8 | ApiNotFoundResponse, 9 | ApiOkResponse, 10 | ApiOperation, 11 | ApiUnauthorizedResponse, 12 | ApiUnprocessableEntityResponse, 13 | } from '@nestjs/swagger'; 14 | import { ChangePasswordDto } from '../dtos/change-password.dto'; 15 | import { JwtAuthGuard } from 'src/guards/jwt-auth.guard'; 16 | import { commonErrorResponses } from 'src/common/constants'; 17 | import { ForgotPasswordDto } from '../dtos/forgot-password.dto'; 18 | import { ResetPasswordDto } from '../dtos/reset-password.dto'; 19 | import { AuthenticatedResponseDto } from '../dtos/auth-response.dto'; 20 | import { LocalAuthGuard } from 'src/guards/local.guard'; 21 | import { LoginUserDto } from '../dtos/login-user.dto'; 22 | import { RefreshTokenDto } from '../dtos/refresh-token.dto'; 23 | import { LoginWithTwoFactorAuthenticationDto } from '../dtos/login-with-2fa.dto'; 24 | import { Resend2faOtpDto } from '../dtos/resend-2fa-otp.dto'; 25 | import { ValidateOtpDto } from '../dtos/validate-otp.dto'; 26 | import { TwoFactorAuthGuard } from 'src/guards/2FA.guard'; 27 | import { RefreshTokenGuard } from 'src/guards/refresh.guard'; 28 | 29 | export function RegisterUsersDecorator() { 30 | return applyDecorators( 31 | ApiOperation({ 32 | summary: 'User registration', 33 | description: 34 | 'This endpoint allows new users to create an account. Users provide an email and password which are then stored in the database. If the operation is successful, a new JWT token is created for the user and returned in a cookie.', 35 | }), 36 | ApiCreatedResponse({ 37 | description: 'The user has been successfully created.', 38 | type: AuthenticatedResponseDto, 39 | }), 40 | ApiBadRequestResponse(commonErrorResponses.badRequest), 41 | ApiForbiddenResponse(commonErrorResponses.forbidden), 42 | ApiUnprocessableEntityResponse( 43 | commonErrorResponses.unprocessableEntityResponse, 44 | ), 45 | ); 46 | } 47 | 48 | export function LoginUsersDecorator() { 49 | return applyDecorators( 50 | ApiOperation({ 51 | summary: 'User login', 52 | description: 53 | "This endpoint allows existing users to authenticate with the system. Users provide their email and password, and if they match what's in the database, a new JWT token is created for the user and returned in a cookie. If the email does not exist in the database, or if the password does not match, an error message is returned.", 54 | }), 55 | ApiOkResponse({ 56 | description: 'Returns the user and access token', 57 | type: AuthenticatedResponseDto, 58 | }), 59 | ApiBody({ type: LoginUserDto }), 60 | ApiBadRequestResponse(commonErrorResponses.badRequest), 61 | ApiNotFoundResponse(commonErrorResponses.notFound), 62 | UseGuards(LocalAuthGuard), 63 | ); 64 | } 65 | 66 | export function LogoutUsersDecorator() { 67 | return applyDecorators( 68 | ApiOperation({ 69 | summary: 'User logout', 70 | description: 71 | 'This endpoint deletes user fcm token for further notifications and refreshtokens when logged out, requires JWT', 72 | }), 73 | ApiOkResponse({ 74 | description: 'Returns success message', 75 | type: String, 76 | }), 77 | UseGuards(JwtAuthGuard), 78 | ApiBearerAuth(), 79 | ); 80 | } 81 | 82 | export function GoogleLoginDecorator() { 83 | return applyDecorators( 84 | ApiOperation({ 85 | summary: 'Google login', 86 | description: 87 | "This endpoint allows users to authenticate or register using their Google account. The user provides their Google credential and if it's valid, a new JWT token is created for the user and returned in a cookie. If the user does not exist in the database, a new user is created.", 88 | }), 89 | ApiCreatedResponse({ 90 | description: 'The user has been successfully logged in or created.', 91 | type: AuthenticatedResponseDto, 92 | }), 93 | ApiBadRequestResponse(commonErrorResponses.badRequest), 94 | ApiUnauthorizedResponse(commonErrorResponses.invalidKey), 95 | ); 96 | } 97 | 98 | export function ChangePasswordDecorator() { 99 | return applyDecorators( 100 | ApiBearerAuth(), 101 | ApiOperation({ 102 | summary: 'Change current user password', 103 | description: 104 | 'This endpoint allows the currently authenticated user to change their password. The user must provide their current password for verification along with the new password. The user must be authenticated with a valid JWT token.', 105 | }), 106 | ApiOkResponse({ 107 | description: 'Password has been changed', 108 | }), 109 | ApiBadRequestResponse(commonErrorResponses.badRequest), 110 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 111 | ApiBody({ 112 | description: 'Password change data', 113 | type: ChangePasswordDto, 114 | }), 115 | UseGuards(JwtAuthGuard), 116 | ); 117 | } 118 | 119 | export function ForgotPasswordDecorator() { 120 | return applyDecorators( 121 | ApiOperation({ summary: 'Request a password reset link' }), 122 | ApiBody({ type: ForgotPasswordDto, description: 'Email' }), 123 | ApiOkResponse(), 124 | ApiBadRequestResponse(commonErrorResponses.badRequest), 125 | ApiNotFoundResponse(commonErrorResponses.notFound), 126 | ); 127 | } 128 | 129 | export function ResetPasswordDecorator() { 130 | return applyDecorators( 131 | ApiOperation({ summary: 'Reset the password using reset token' }), 132 | ApiBody({ 133 | type: ResetPasswordDto, 134 | description: 'Reset Token and New Password', 135 | }), 136 | ApiCreatedResponse({ 137 | description: 'Returns tokens ', 138 | type: AuthenticatedResponseDto, 139 | }), 140 | ApiNotFoundResponse(commonErrorResponses.invalidKey), 141 | ); 142 | } 143 | 144 | export function RefreshTokenDecorator() { 145 | return applyDecorators( 146 | ApiOperation({ 147 | summary: 'Send refresh token to receive new token and refreshToken', 148 | }), 149 | ApiCreatedResponse({ 150 | description: 'Returns tokens', 151 | type: AuthenticatedResponseDto, 152 | }), 153 | ApiUnauthorizedResponse(commonErrorResponses.invalidKey), 154 | ApiBody({ type: RefreshTokenDto }), 155 | UseGuards(RefreshTokenGuard), 156 | ); 157 | } 158 | 159 | export function LoginWithTwoFactorDecorator() { 160 | return applyDecorators( 161 | ApiOperation({ 162 | summary: 'Login with Two-Factor Authentication', 163 | description: 164 | 'This endpoint allows users with 2FA enabled to login by providing their email and the OTP sent to their email. If the OTP matches and is valid, the user will be authenticated.', 165 | }), 166 | ApiOkResponse({ 167 | description: 'User is logged in successfully, returns access token', 168 | type: AuthenticatedResponseDto, 169 | }), 170 | ApiBadRequestResponse(commonErrorResponses.badRequest), 171 | ApiNotFoundResponse(commonErrorResponses.notFound), 172 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 173 | ApiBody({ type: LoginWithTwoFactorAuthenticationDto }), 174 | UseGuards(TwoFactorAuthGuard), 175 | ); 176 | } 177 | 178 | export function ResendTwoFactorAuthForLoginDecorator() { 179 | return applyDecorators( 180 | ApiOperation({ 181 | summary: 'Resend OTP for Two-Factor Authentication during Login', 182 | description: 183 | 'This endpoint is used when a user needs the OTP resent to their email during the login process. The user must provide a valid temporary authentication token.', 184 | }), 185 | ApiOkResponse({ 186 | description: 'OTP has been resent successfully', 187 | type: Resend2faOtpDto, 188 | }), 189 | ApiBadRequestResponse(commonErrorResponses.badRequest), 190 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 191 | ApiBody({ type: Resend2faOtpDto }), 192 | ); 193 | } 194 | 195 | export function LoginFromNewDeviceWithTwoFactorDecorator() { 196 | return applyDecorators( 197 | ApiOperation({ 198 | summary: 'Login from new device with Two-Factor Authentication', 199 | description: 200 | 'This endpoint allows users with new device to login by providing their email and the OTP sent to their email. If the OTP matches and is valid, the user will be authenticated.', 201 | }), 202 | ApiOkResponse({ 203 | description: 'User is logged in successfully, returns access token', 204 | type: AuthenticatedResponseDto, 205 | }), 206 | ApiBadRequestResponse(commonErrorResponses.badRequest), 207 | ApiNotFoundResponse(commonErrorResponses.notFound), 208 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 209 | ApiBody({ type: LoginWithTwoFactorAuthenticationDto }), 210 | // UseGuards(TwoFactorAuthGuard), 211 | ); 212 | } 213 | 214 | export function SetupTwoFactorAuthDecorator() { 215 | return applyDecorators( 216 | ApiOperation({ 217 | summary: 'Setup Two-Factor Authentication', 218 | description: 219 | 'This endpoint initiates the setup of Two-Factor Authentication by sending an OTP to the user’s email. The user must verify the OTP to complete the setup.', 220 | }), 221 | ApiOkResponse({ 222 | description: '2FA setup initiated, OTP sent to email', 223 | }), 224 | ApiBadRequestResponse(commonErrorResponses.badRequest), 225 | UseGuards(JwtAuthGuard), 226 | ); 227 | } 228 | 229 | export function VerifyTwoFactorAuthDecorator() { 230 | return applyDecorators( 231 | ApiOperation({ 232 | summary: 'Verify OTP for Two-Factor Authentication Setup', 233 | description: 234 | 'This endpoint verifies the OTP provided by the user during the setup of Two-Factor Authentication. If the OTP is valid, 2FA is enabled for the user.', 235 | }), 236 | ApiOkResponse({ 237 | description: '2FA has been set up successfully', 238 | }), 239 | ApiBadRequestResponse(commonErrorResponses.badRequest), 240 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 241 | ApiBody({ type: ValidateOtpDto }), 242 | UseGuards(JwtAuthGuard), 243 | ); 244 | } 245 | 246 | export function ResendTwoFactorAuthDecorator() { 247 | return applyDecorators( 248 | ApiOperation({ 249 | summary: 'Resend OTP for Two-Factor Authentication', 250 | description: 251 | 'This endpoint resends the OTP to the user’s email for Two-Factor Authentication. It can be used if the user did not receive the OTP initially or if the OTP has expired.', 252 | }), 253 | ApiOkResponse({ 254 | description: 'OTP has been resent successfully', 255 | }), 256 | ApiBadRequestResponse(commonErrorResponses.badRequest), 257 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 258 | UseGuards(JwtAuthGuard), 259 | ); 260 | } 261 | 262 | export function DisableTwoFactorAuthDecorator() { 263 | return applyDecorators( 264 | ApiOperation({ 265 | summary: 'Initiate Two-Factor Authentication Disabling', 266 | description: 267 | 'This endpoint initiates the disabling of Two-Factor Authentication by requiring the user to verify their identity with an OTP.', 268 | }), 269 | ApiOkResponse({ 270 | description: '2FA disabling initiated, verify OTP to proceed', 271 | }), 272 | ApiBadRequestResponse(commonErrorResponses.badRequest), 273 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 274 | ApiBearerAuth(), 275 | UseGuards(JwtAuthGuard), 276 | ); 277 | } 278 | 279 | export function VerifyTwoFactorAuthToDisableDecorator() { 280 | return applyDecorators( 281 | ApiOperation({ 282 | summary: 'Verify OTP to Disable Two-Factor Authentication', 283 | description: 284 | 'This endpoint verifies the OTP provided by the user to disable Two-Factor Authentication. If the OTP is valid, 2FA will be disabled for the user.', 285 | }), 286 | ApiOkResponse({ 287 | description: '2FA has been disabled successfully', 288 | }), 289 | ApiBadRequestResponse(commonErrorResponses.badRequest), 290 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 291 | ApiBody({ type: ValidateOtpDto }), 292 | ApiBearerAuth(), 293 | UseGuards(JwtAuthGuard), 294 | ); 295 | } 296 | 297 | export function VerifyEmailSetupDecorator() { 298 | return applyDecorators( 299 | ApiOperation({ 300 | summary: 'Send OTP for Email Verification', 301 | description: 302 | "Sends an OTP to the user's registered email for verification purposes.", 303 | }), 304 | ApiOkResponse({ 305 | description: 'OTP sent successfully to the registered email address.', 306 | }), 307 | ApiBadRequestResponse(commonErrorResponses.badRequest), 308 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 309 | ApiBearerAuth(), 310 | UseGuards(JwtAuthGuard), 311 | ); 312 | } 313 | 314 | export function ConfirmEmailSetupDecorator() { 315 | return applyDecorators( 316 | ApiOperation({ 317 | summary: 'Confirm Email Verification', 318 | description: 319 | "Confirms the email verification by validating the OTP sent to the user's email.", 320 | }), 321 | ApiOkResponse({ 322 | description: 'Email verification confirmed successfully.', 323 | }), 324 | ApiBadRequestResponse(commonErrorResponses.badRequest), 325 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 326 | ApiBody({ type: ValidateOtpDto }), 327 | ApiBearerAuth(), 328 | UseGuards(JwtAuthGuard), 329 | ); 330 | } 331 | 332 | export function VerifyPhoneSetupDecorator() { 333 | return applyDecorators( 334 | ApiOperation({ 335 | summary: 'Send OTP for Phone Verification', 336 | description: 337 | "Sends an OTP to the user's registered phone number for verification purposes.", 338 | }), 339 | ApiOkResponse({ 340 | description: 'OTP sent successfully to the registered phone number.', 341 | }), 342 | ApiBadRequestResponse(commonErrorResponses.badRequest), 343 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 344 | ApiBearerAuth(), 345 | UseGuards(JwtAuthGuard), 346 | ); 347 | } 348 | 349 | export function ConfirmPhoneSetupDecorator() { 350 | return applyDecorators( 351 | ApiOperation({ 352 | summary: 'Confirm Phone Verification', 353 | description: 354 | "Confirms the phone verification by validating the OTP sent to the user's phone number.", 355 | }), 356 | ApiOkResponse({ 357 | description: 'Phone verification confirmed successfully.', 358 | }), 359 | ApiBadRequestResponse(commonErrorResponses.badRequest), 360 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 361 | ApiBody({ type: ValidateOtpDto }), 362 | ApiBearerAuth(), 363 | UseGuards(JwtAuthGuard), 364 | ); 365 | } 366 | -------------------------------------------------------------------------------- /src/auth/dtos/auth-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AuthenticatedResponseDto { 4 | @ApiProperty({ 5 | example: 6 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NGFlNzRjZTU1ZjMxNjI2ZmQ1YmE4YzAiLCJlbWFpbCI6ImFkbWluQG1haWwuY29tIiwiaWF0IjoxNjg5NTc4MDUzLCJleHAiOjE2ODk5MzgwNTN9.04rx2NHdSS4kovTnRjgEs9VWUC6rVulVdnVFjBFcM88', 7 | description: 'The authentication token', 8 | }) 9 | accessToken: string; 10 | 11 | @ApiProperty({ 12 | example: 13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NGFlNzRjZTU1ZjMxNjI2ZmQ1YmE4YzAiLCJlbWFpbCI6ImFkbWluQG1haWwuY29tIiwiaWF0IjoxNjg5NTc4MDUzLCJleHAiOjE2ODk5MzgwNTN9.04rx2NHdSS4kovTnRjgEs9VWUC6rVulVdnVFjBFcM88.tIiwiaWF0IjoxNjg5NTc4MDUzLCJleHAiOjE2ODk5MzgwNTN9', 14 | description: 'The refresh token', 15 | }) 16 | refreshToken: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/auth/dtos/change-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsStrongPassword, IsNotEmpty } from 'class-validator'; 3 | 4 | export class ChangePasswordDto { 5 | @IsNotEmpty() 6 | @IsStrongPassword() 7 | @ApiProperty({ 8 | description: 'Old password of the user', 9 | example: '123456', 10 | }) 11 | oldPassword: string; 12 | 13 | @IsNotEmpty() 14 | @IsStrongPassword() 15 | @ApiProperty({ 16 | description: 'new password of the user', 17 | example: 'abcdefg', 18 | }) 19 | newPassword: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/dtos/forgot-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class ForgotPasswordDto { 5 | @IsEmail() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: 'The email address of the user.', 9 | example: 'example@email.com', 10 | }) 11 | email: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/dtos/google-login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class GoogleLoginDto { 5 | @ApiProperty({ 6 | description: 'The Google ID token from the client', 7 | example: 'eyJhbGciOiJSUzI1NiIsImtpZCI6I...', 8 | }) 9 | @IsString() 10 | credential: string; 11 | 12 | @ApiProperty({ 13 | description: 'The Client ID from the Google Developer Console', 14 | example: '32555940559.apps.googleusercontent.com', 15 | }) 16 | @IsString() 17 | clientId: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/auth/dtos/login-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsStrongPassword } from 'class-validator'; 3 | 4 | export class LoginUserDto { 5 | @ApiProperty({ 6 | description: 'The email of the user', 7 | example: 'example@email.com', 8 | }) 9 | @IsEmail() 10 | email: string; 11 | 12 | @ApiProperty({ 13 | description: 'The password of the user', 14 | example: 'StrongPass0rd!', 15 | minLength: 8, 16 | }) 17 | @IsStrongPassword() 18 | password: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/dtos/login-with-2fa.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class LoginWithTwoFactorAuthenticationDto { 5 | @IsNotEmpty() 6 | @ApiProperty({ description: 'The OTP code sent to the user.' }) 7 | otp: string; 8 | 9 | @IsNotEmpty() 10 | @ApiProperty({ description: 'The OTP code sent to the user.' }) 11 | tempAuthToken: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/dtos/refresh-token.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsString, IsStrongPassword } from 'class-validator'; 3 | 4 | export class RefreshTokenDto { 5 | @ApiProperty({ 6 | description: 'Current refresh token of the user', 7 | example: 8 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NGFlNzRjZTU1ZjMxNjI2ZmQ1YmE4YzAiLCJlbWFpbCI6ImFkbWluQG1haWwuY29tIiwiaWF0IjoxNjg5NTc4MDUzLCJleHAiOjE2ODk5MzgwNTN9.04rx2NHdSS4kovTnRjgEs9VWUC6rVulVdnVFjBFcM88.tIiwiaWF0IjoxNjg5NTc4MDUzLCJleHAiOjE2ODk5MzgwNTN9', 9 | }) 10 | @IsString() 11 | refreshToken: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/dtos/resend-2fa-otp.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class Resend2faOtpDto { 5 | @ApiProperty() 6 | @IsString() 7 | tempAuthToken: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/dtos/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsStrongPassword } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class ResetPasswordDto { 5 | @IsNotEmpty() 6 | @ApiProperty({ description: 'The reset token for password reset.' }) 7 | resetToken: string; 8 | 9 | @IsNotEmpty() 10 | @IsStrongPassword() 11 | @ApiProperty({ description: 'The new password for the user.' }) 12 | newPassword: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/auth/dtos/signup-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsStrongPassword } from 'class-validator'; 3 | 4 | export class SignupUserDto { 5 | @ApiProperty({ 6 | description: 'The email of the user', 7 | example: 'example@email.com', 8 | }) 9 | @IsEmail() 10 | email: string; 11 | 12 | @ApiProperty({ 13 | description: 'The password of the user', 14 | example: 'StrongPassw0rd!', 15 | minLength: 5, 16 | }) 17 | @IsStrongPassword() 18 | password: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/dtos/validate-otp.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class ValidateOtpDto { 5 | @IsNotEmpty() 6 | @ApiProperty({ description: 'The OTP code sent to the user.' }) 7 | otp: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/entities/refresh-token.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../users/entities/user.entity'; 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | Index, 7 | ManyToOne, 8 | } from 'typeorm'; 9 | 10 | @Entity() 11 | export class RefreshToken { 12 | @PrimaryGeneratedColumn('uuid') 13 | id: string; 14 | 15 | @Index() 16 | @ManyToOne(() => User, (user) => user.refreshTokens) 17 | user: User; 18 | 19 | @Column() 20 | token: string; 21 | 22 | @Column('bigint') 23 | expiresIn: number; 24 | 25 | @Column({ type: 'timestamp with time zone', nullable: true }) 26 | toBeDeletedAt: Date | null; 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/enums/index.ts: -------------------------------------------------------------------------------- 1 | export enum DataToBeVerified { 2 | Phone = 'PHONE', 3 | Email = 'EMAIL', 4 | } 5 | -------------------------------------------------------------------------------- /src/auth/middlewares/validation.middleware.ts: -------------------------------------------------------------------------------- 1 | // Custom middleware in NestJS 2 | import { 3 | Injectable, 4 | NestMiddleware, 5 | BadRequestException, 6 | } from '@nestjs/common'; 7 | import { NextFunction, Request, Response } from 'express'; 8 | import { validate } from 'class-validator'; 9 | import { plainToInstance } from 'class-transformer'; 10 | import { LoginUserDto } from '../dtos/login-user.dto'; 11 | 12 | @Injectable() 13 | export class ValidateLoginMiddleware implements NestMiddleware { 14 | async use(req: Request, res: Response, next: NextFunction) { 15 | const errors = await validate(plainToInstance(LoginUserDto, req.body)); 16 | if (errors.length > 0) { 17 | // Extract and format the error messages 18 | const formattedErrors = errors.map((error) => { 19 | // Combine all constraint messages for each property into a single string 20 | return Object.values(error.constraints).join(', '); 21 | }); 22 | 23 | // Throw an exception with the formatted errors 24 | throw new BadRequestException(formattedErrors); 25 | } else { 26 | next(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/password.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NotFoundException, 4 | BadRequestException, 5 | Logger, 6 | } from '@nestjs/common'; 7 | import * as crypto from 'crypto'; 8 | import { UsersService } from '../users/users.service'; 9 | import { ChangePasswordDto } from './dtos/change-password.dto'; 10 | import { AuthenticatedResponseDto } from './dtos/auth-response.dto'; 11 | import { TokenService } from './token.service'; 12 | import { CryptoService } from './crypto.service'; 13 | 14 | @Injectable() 15 | export class PasswordService { 16 | private readonly logger = new Logger(PasswordService.name); 17 | 18 | constructor( 19 | private readonly usersService: UsersService, 20 | private readonly tokenService: TokenService, 21 | private readonly cryptoService: CryptoService, 22 | ) {} 23 | 24 | async changePassword( 25 | userId: string, 26 | changePasswordDto: ChangePasswordDto, 27 | ): Promise { 28 | const user = await this.usersService.findOneById(userId); 29 | if (!user) { 30 | throw new NotFoundException('User not found'); 31 | } 32 | 33 | const { oldPassword, newPassword } = changePasswordDto; 34 | 35 | // Validate the old password 36 | const isValidOldPassword = await this.cryptoService.validatePassword( 37 | oldPassword, 38 | user.password, 39 | ); 40 | if (!isValidOldPassword) { 41 | throw new BadRequestException('Old password is incorrect'); 42 | } 43 | 44 | // Hash the new password 45 | user.password = await this.cryptoService.hashPassword(newPassword); 46 | 47 | // Increment tokenVersion safely 48 | user.tokenVersion = (user.tokenVersion || 0) + 1; // This ensures that tokenVersion is a number and increments it 49 | 50 | // Save the updated user 51 | await this.usersService.updateCurrentUser(user.id, user); 52 | 53 | this.logger.log( 54 | JSON.stringify({ 55 | action: 'change-password', 56 | userId: user.id, 57 | }), 58 | ); 59 | } 60 | 61 | async forgotPassword(email: string): Promise { 62 | // Find the user by their email 63 | const user = await this.usersService.findByEmail(email); 64 | if (!user) { 65 | throw new NotFoundException( 66 | 'User with this email address was not found.', 67 | ); 68 | } 69 | 70 | // Generate a password reset token and expiry time 71 | const resetToken = crypto.randomBytes(20).toString('hex'); 72 | const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hour from now 73 | 74 | // Update the user with the reset token and expiry time 75 | await this.usersService.updateCurrentUser(user.id, { 76 | passwordResetCode: resetToken, 77 | passwordResetExpires: resetTokenExpiry, 78 | }); 79 | 80 | this.logger.log( 81 | JSON.stringify({ 82 | action: 'forgot-password', 83 | userId: user.id, 84 | email: user.email, 85 | }), 86 | ); 87 | 88 | // Note: Email service for sending forgot password email should be implemented here 89 | } 90 | 91 | async resetPassword( 92 | resetToken: string, 93 | newPassword: string, 94 | ): Promise { 95 | // Find the user by their password reset token 96 | const user = await this.usersService.findByResetToken(resetToken); 97 | // Validate the reset token and its expiry time 98 | if ( 99 | !user || 100 | !user.passwordResetExpires || 101 | user.passwordResetExpires < new Date() 102 | ) { 103 | throw new BadRequestException( 104 | 'Password reset token is invalid or has expired.', 105 | ); 106 | } 107 | 108 | // Hash the new password using the CryptoService 109 | user.password = await this.cryptoService.hashPassword(newPassword); 110 | 111 | // Clear the reset token and expiry time from the user's account 112 | user.passwordResetCode = null; 113 | user.passwordResetExpires = null; 114 | 115 | // Update the user's password and reset token fields 116 | await this.usersService.updateCurrentUser(user.id, user); 117 | 118 | // Generate new access and refresh tokens for the user 119 | const accessToken = await this.tokenService.createAccessToken(user); 120 | const refreshToken = await this.tokenService.createRefreshToken(user); 121 | 122 | this.logger.log( 123 | JSON.stringify({ 124 | action: 'reset-password', 125 | userId: user.id, 126 | }), 127 | ); 128 | 129 | // Return the new tokens 130 | return { 131 | accessToken, 132 | refreshToken, 133 | }; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { UsersService } from 'src/users/users.service'; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor( 10 | private readonly usersService: UsersService, 11 | private readonly configService: ConfigService, 12 | ) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromExtractors([ 15 | ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ]), 17 | ignoreExpiration: false, 18 | secretOrKey: configService.get('ACCESS_TOKEN_SECRET'), 19 | }); 20 | } 21 | 22 | async validate(payload: any) { 23 | const user = await this.usersService.findOneById(payload.sub); 24 | if (!user || user.tokenVersion !== payload.tokenVersion) { 25 | throw new UnauthorizedException(); 26 | } 27 | return user; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { AuthService } from '../auth.service'; 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private readonly authService: AuthService) { 9 | super({ usernameField: 'email' }); 10 | } 11 | 12 | async validate(email: string, password: string) { 13 | return await this.authService.verifyUser(email, password); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/strategies/refresh.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { Request } from 'express'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { UsersService } from 'src/users/users.service'; 7 | import { TokenService } from '../token.service'; 8 | 9 | @Injectable() 10 | export class RefreshTokenStrategy extends PassportStrategy( 11 | Strategy, 12 | 'refresh', 13 | ) { 14 | constructor( 15 | private readonly usersService: UsersService, 16 | private readonly configService: ConfigService, 17 | private readonly tokenService: TokenService, 18 | ) { 19 | super({ 20 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 21 | secretOrKeyProvider: async (request, rawJwtToken, done) => { 22 | const secret = configService.get('REFRESH_TOKEN_SECRET'); 23 | done(null, secret); 24 | }, 25 | passReqToCallback: true, 26 | }); 27 | } 28 | 29 | async validate(req: Request, payload: any): Promise { 30 | const refreshToken = req.headers.authorization?.split(' ')[1]; 31 | if (!refreshToken) { 32 | throw new UnauthorizedException('Refresh token not found'); 33 | } 34 | 35 | return await this.tokenService.validateToken(refreshToken); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/auth/token.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | UnauthorizedException, 4 | NotFoundException, 5 | BadRequestException, 6 | Logger, 7 | } from '@nestjs/common'; 8 | import { InjectRepository } from '@nestjs/typeorm'; 9 | import { Repository } from 'typeorm'; 10 | import { JwtService } from '@nestjs/jwt'; 11 | import { ConfigService } from '@nestjs/config'; 12 | import { randomBytes, scrypt } from 'crypto'; 13 | import { promisify } from 'util'; 14 | import { RefreshToken } from './entities/refresh-token.entity'; 15 | import { UsersService } from '../users/users.service'; 16 | import { User } from '../users/entities/user.entity'; 17 | 18 | // Convert scrypt callback function to a promise-based version for async use 19 | const scryptAsync = promisify(scrypt); 20 | 21 | @Injectable() 22 | export class TokenService { 23 | private readonly logger = new Logger(TokenService.name); 24 | private readonly REFRESH_TOKEN_SECRET: string; 25 | private readonly ACCESS_TOKEN_SECRET: string; 26 | 27 | constructor( 28 | @InjectRepository(RefreshToken) 29 | private refreshTokenRepository: Repository, 30 | private readonly usersService: UsersService, 31 | private readonly jwtService: JwtService, 32 | private readonly configService: ConfigService, 33 | ) { 34 | // Retrieve secrets for token signing from the configuration 35 | this.REFRESH_TOKEN_SECRET = this.configService.get( 36 | 'REFRESH_TOKEN_SECRET', 37 | ); 38 | this.ACCESS_TOKEN_SECRET = this.configService.get( 39 | 'ACCESS_TOKEN_SECRET', 40 | ); 41 | } 42 | 43 | async createAccessToken(user: User): Promise { 44 | // Define payload for access token 45 | const payload = { 46 | sub: user.id, 47 | email: user.email, 48 | tokenVersion: user.tokenVersion, 49 | }; 50 | // Sign and return the access token with user information and expiration 51 | return this.jwtService.sign(payload, { 52 | secret: this.ACCESS_TOKEN_SECRET, 53 | expiresIn: '15d', 54 | }); 55 | } 56 | 57 | async createRefreshToken(user: User): Promise { 58 | // Delete all refresh tokens for the user 59 | await this.refreshTokenRepository.delete({ user: user }); 60 | 61 | // Define payload for refresh token 62 | const refreshTokenPayload = { sub: user.id, email: user.email }; 63 | // Sign the JWT refresh token 64 | const refreshToken = this.jwtService.sign(refreshTokenPayload, { 65 | secret: this.REFRESH_TOKEN_SECRET, 66 | expiresIn: '30d', // Set longer validity for refresh token 67 | }); 68 | 69 | // Create new refresh token entity 70 | const refreshTokenEntity = this.refreshTokenRepository.create({ 71 | user: user, 72 | token: refreshToken, // Store the JWT refresh token 73 | expiresIn: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days from now 74 | }); 75 | 76 | // Save the new refresh token in the database 77 | await this.refreshTokenRepository.save(refreshTokenEntity); 78 | 79 | return refreshToken; 80 | } 81 | 82 | async validateToken(providedToken: string): Promise { 83 | // Find the corresponding refresh token in the database 84 | const refreshToken = await this.refreshTokenRepository.findOne({ 85 | where: { token: providedToken }, 86 | relations: ['user'], 87 | }); 88 | 89 | if (!refreshToken) { 90 | // Handle case where no corresponding refresh token is found 91 | throw new UnauthorizedException(); 92 | } 93 | 94 | return refreshToken.user; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/auth/two-factor.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NotFoundException, 4 | BadRequestException, 5 | Logger, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { UsersService } from '../users/users.service'; 9 | import { CryptoService } from './crypto.service'; 10 | import { ValidateOtpDto } from './dtos/validate-otp.dto'; 11 | import { JwtService } from '@nestjs/jwt'; 12 | import { Resend2faOtpDto } from './dtos/resend-2fa-otp.dto'; 13 | import { EmailService } from 'src/notifications/email.service'; 14 | 15 | @Injectable() 16 | export class TwoFactorAuthenticationService { 17 | private readonly logger = new Logger(TwoFactorAuthenticationService.name); 18 | 19 | constructor( 20 | private readonly usersService: UsersService, 21 | private readonly cryptoService: CryptoService, 22 | private readonly emailService: EmailService, 23 | private readonly jwtService: JwtService, 24 | ) {} 25 | 26 | async setup2FA(user: any): Promise<{ message: string }> { 27 | const [otp, hashedOtp] = 28 | await this.cryptoService.generateAndHashOtp6Figures(); 29 | await this.usersService.updateCurrentUser(user.id, { 30 | twoFactorAuthToken: hashedOtp, 31 | twoFactorAuthTokenExpiry: new Date(Date.now() + 3600000), // 1 hour validity 32 | }); 33 | await this.emailService.sendOtpEmail(user.email, otp); 34 | return { message: 'OTP Code Has Been Sent' }; 35 | } 36 | 37 | async verify2FA( 38 | user: any, 39 | verify2FADto: ValidateOtpDto, 40 | requiredValue: boolean, 41 | ): Promise<{ message: string }> { 42 | const { otp } = verify2FADto; 43 | const validatedUser = await this.getUserAndValidateOtp(user.email, otp); 44 | 45 | await this.usersService.update(validatedUser.id, { 46 | isTwoFactorAuthEnabled: requiredValue, 47 | twoFactorAuthToken: null, 48 | twoFactorAuthTokenExpiry: null, 49 | }); 50 | 51 | return { message: '2FA Setup Successful' }; 52 | } 53 | 54 | async getUserAndValidateOtp(email: string, otp: string) { 55 | const user = await this.usersService.findByEmail(email); 56 | if (!user || !user.twoFactorAuthToken) { 57 | throw new NotFoundException('No user or OTP code found for this email.'); 58 | } 59 | 60 | if (new Date(Date.now()) > user.twoFactorAuthTokenExpiry) { 61 | throw new BadRequestException( 62 | 'The OTP code has expired. Please request a new one.', 63 | ); 64 | } 65 | const isOtpValid = await this.cryptoService.validateOtp( 66 | otp, 67 | user.twoFactorAuthToken, 68 | ); 69 | if (!isOtpValid) { 70 | throw new UnauthorizedException('Invalid OTP code. Please try again.'); 71 | } 72 | return user; 73 | } 74 | 75 | async resend2faOtp(userId: string): Promise<{ message: string }> { 76 | const user = await this.usersService.findOneById(userId); 77 | if (!user) { 78 | throw new NotFoundException('User not found.'); 79 | } 80 | if ( 81 | !user.twoFactorAuthToken || 82 | new Date(Date.now()) > user.twoFactorAuthTokenExpiry 83 | ) { 84 | throw new BadRequestException( 85 | 'No ongoing 2FA process found. Please initiate setup first.', 86 | ); 87 | } 88 | 89 | // Generate and hash a new OTP 90 | const [generatedOtp, hashedOtp] = 91 | await this.cryptoService.generateAndHashOtp6Figures(); 92 | 93 | // Send the new OTP email 94 | await this.emailService.sendOtpEmail(user.email, generatedOtp); 95 | 96 | // Update the OTP and expiry time in the user's document 97 | await this.usersService.updateCurrentUser(user.id, { 98 | twoFactorAuthToken: hashedOtp, 99 | twoFactorAuthTokenExpiry: new Date(Date.now() + 60 * 60 * 1000), 100 | }); 101 | 102 | return { message: 'OTP has been resent.' }; 103 | } 104 | 105 | async resend2faForLogin( 106 | body: Resend2faOtpDto, 107 | ): Promise<{ tempAuthToken: string; message: string }> { 108 | const { tempAuthToken } = body; 109 | let userId: string; 110 | 111 | try { 112 | const decoded = this.jwtService.verify(tempAuthToken); // Verify the existing temporary token 113 | userId = decoded.userId; 114 | } catch (error) { 115 | throw new UnauthorizedException('Invalid or expired temporary token.'); 116 | } 117 | 118 | const user = await this.usersService.findOneById(userId); 119 | if (!user) { 120 | throw new NotFoundException('User not found.'); 121 | } 122 | 123 | if ( 124 | !user.twoFactorAuthToken || 125 | new Date() > user.twoFactorAuthTokenExpiry 126 | ) { 127 | throw new BadRequestException( 128 | 'No ongoing 2FA process found or OTP has expired.', 129 | ); 130 | } 131 | 132 | // Generate and hash a new OTP 133 | const [generatedOtp, hashedOtp] = 134 | await this.cryptoService.generateAndHashOtp6Figures(); 135 | 136 | // Send the new OTP email 137 | await this.emailService.sendOtpEmail(user.email, generatedOtp); 138 | 139 | // Update the OTP and expiry time in the user's document 140 | await this.usersService.updateCurrentUser(user.id, { 141 | twoFactorAuthToken: hashedOtp, 142 | twoFactorAuthTokenExpiry: new Date(Date.now() + 10 * 60 * 1000), 143 | }); 144 | 145 | // Generate a new temporary authentication token 146 | const newTempAuthToken = this.jwtService.sign( 147 | { userId: user.id }, 148 | { expiresIn: '10m' }, 149 | ); 150 | 151 | return { 152 | tempAuthToken: newTempAuthToken, 153 | message: 'A new OTP has been sent to your email.', 154 | }; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/auth/verification.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NotFoundException, 4 | BadRequestException, 5 | Logger, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { UsersService } from '../users/users.service'; 9 | import { CryptoService } from './crypto.service'; 10 | import { User } from 'src/users/entities/user.entity'; 11 | import { EmailService } from 'src/notifications/email.service'; 12 | import { DataToBeVerified } from './enums'; 13 | import { ValidateOtpDto } from './dtos/validate-otp.dto'; 14 | import { SmsService } from 'src/notifications/sms.service'; 15 | 16 | @Injectable() 17 | export class VerificationService { 18 | private readonly logger = new Logger(VerificationService.name); 19 | 20 | constructor( 21 | private readonly usersService: UsersService, 22 | private readonly cryptoService: CryptoService, 23 | private readonly emailService: EmailService, 24 | private readonly smsService: SmsService, 25 | ) {} 26 | 27 | async setupPhoneOrEmailVerfication( 28 | user: User, 29 | dataToBeVerified: DataToBeVerified, 30 | ): Promise<{ message: string }> { 31 | const [generatedOtp, hashedOtp] = 32 | await this.cryptoService.generateAndHashOtp6Figures(); 33 | 34 | if (dataToBeVerified === DataToBeVerified.Email) { 35 | if (user.isEmailVerified === true) { 36 | throw new BadRequestException(); 37 | } 38 | await this.usersService.update(user.id, { 39 | verifyEmailToken: hashedOtp, 40 | verifyEmailExpires: new Date(Date.now() + 3600000), 41 | }); 42 | await this.emailService.sendOtpEmail(user.email, generatedOtp); 43 | 44 | return { message: 'OTP Code Has Been Sent' }; 45 | } else { 46 | if (user.isPhoneVerified === true) { 47 | throw new BadRequestException(); 48 | } 49 | await this.usersService.update(user.id, { 50 | verifyPhoneToken: hashedOtp, 51 | verifyPhoneExpires: new Date(Date.now() + 3600000), 52 | }); 53 | await this.smsService.sendSMS(user.phoneNumber, generatedOtp); 54 | 55 | return { message: 'OTP Code Has Been Sent' }; 56 | } 57 | } 58 | 59 | async verifyEmailVerification( 60 | user: any, 61 | verify2FADto: ValidateOtpDto, 62 | dataToBeVerified: DataToBeVerified, 63 | ): Promise<{ message: string }> { 64 | const { otp } = verify2FADto; 65 | 66 | if (dataToBeVerified === DataToBeVerified.Email) { 67 | const validatedUser = await this.getUserAndValidateOtp( 68 | DataToBeVerified.Email, 69 | user, 70 | otp, 71 | ); 72 | await this.usersService.update(validatedUser.id, { 73 | isEmailVerified: true, 74 | verifyEmailToken: null, 75 | verifyEmailExpires: null, 76 | }); 77 | 78 | return { message: 'Email has been verified' }; 79 | } else { 80 | const validatedUser = await this.getUserAndValidateOtp( 81 | DataToBeVerified.Phone, 82 | user, 83 | otp, 84 | ); 85 | await this.usersService.update(validatedUser.id, { 86 | isPhoneVerified: true, 87 | verifyPhoneToken: null, 88 | verifyPhoneExpires: null, 89 | }); 90 | 91 | return { message: 'Phone number has been verified' }; 92 | } 93 | } 94 | 95 | async getUserAndValidateOtp( 96 | valueToValidate: DataToBeVerified, 97 | user: User, 98 | otp: string, 99 | ) { 100 | if (!user) { 101 | throw new NotFoundException(); 102 | } 103 | if (valueToValidate === DataToBeVerified.Email) { 104 | if (!user.verifyEmailToken) { 105 | throw new NotFoundException(); 106 | } 107 | 108 | if (new Date(Date.now()) > user.verifyEmailExpires) { 109 | throw new BadRequestException(); 110 | } 111 | const isOtpValid = await this.cryptoService.validateOtp( 112 | otp, 113 | user.verifyEmailToken, 114 | ); 115 | if (!isOtpValid) { 116 | throw new UnauthorizedException(); 117 | } 118 | return user; 119 | } else { 120 | if (!user.verifyPhoneToken) { 121 | throw new NotFoundException(); 122 | } 123 | 124 | if (new Date(Date.now()) > user.verifyPhoneExpires) { 125 | throw new BadRequestException(); 126 | } 127 | const isOtpValid = await this.cryptoService.validateOtp( 128 | otp, 129 | user.verifyPhoneToken, 130 | ); 131 | if (!isOtpValid) { 132 | throw new UnauthorizedException(); 133 | } 134 | return user; 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | @Module({ 4 | imports: [], 5 | controllers: [], 6 | providers: [], 7 | exports: [], 8 | }) 9 | export class CommonModule {} 10 | -------------------------------------------------------------------------------- /src/common/constants/index.ts: -------------------------------------------------------------------------------- 1 | // Upload Rate 2 | export const UPLOAD_RATE_TTL = 60000; 3 | export const UPLOAD_RATE_LIMIT = 12; 4 | 5 | // App Rate 6 | export const APP_RATE_TTL = 60000; 7 | export const APP_RATE_LIMIT = 60; 8 | 9 | // Port 10 | export const PORT = 5000; 11 | export const commonErrorResponses = { 12 | badRequest: { 13 | description: 'Returns when input is invalid.', 14 | schema: { 15 | example: { 16 | statusCode: 400, 17 | message: ['Error message regarding bad input'], 18 | error: 'Bad Request', 19 | }, 20 | }, 21 | }, 22 | badUpdateRequest: { 23 | description: 'Returns when input is invalid for an update operation.', 24 | schema: { 25 | example: { 26 | statusCode: 400, 27 | message: ['Input format or value is invalid for update'], 28 | error: 'Bad Request', 29 | }, 30 | }, 31 | }, 32 | invalidCredentials: { 33 | description: 'Returns when the provided credentials are invalid.', 34 | schema: { 35 | example: { 36 | statusCode: 401, 37 | message: [ 38 | 'Invalid email or password. Please check your login details and try again.', 39 | ], 40 | error: 'Unauthorized', 41 | }, 42 | }, 43 | }, 44 | unAuthorized: { 45 | description: 46 | 'Returns when there is no token available, or the user does not have permission to access the API.', 47 | schema: { 48 | example: { 49 | statusCode: 401, 50 | message: ['You do not have permission to perform this action.'], 51 | error: 'Unauthorized', 52 | }, 53 | }, 54 | }, 55 | forbidden: { 56 | description: 57 | 'Returns when the user is not an admin or does not own the current document.', 58 | schema: { 59 | example: { 60 | statusCode: 403, 61 | message: ['You do not have access to this resource.'], 62 | error: 'Forbidden', 63 | }, 64 | }, 65 | }, 66 | notFound: { 67 | description: 'Returns when the requested resource (id) is not found.', 68 | schema: { 69 | example: { 70 | statusCode: 404, 71 | message: ['The resource you are trying to access was not found.'], 72 | error: 'Not Found', 73 | }, 74 | }, 75 | }, 76 | tokenNotFound: { 77 | description: 'Returns when the token is not found or is expired/invalid.', 78 | schema: { 79 | example: { 80 | statusCode: 404, 81 | message: ['Token not found or expired.'], 82 | error: 'Not Found', 83 | }, 84 | }, 85 | }, 86 | unprocessableEntityResponse: { 87 | description: 'Returns when the user/email already exists.', 88 | schema: { 89 | example: { 90 | statusCode: 422, 91 | message: ['You cannot register with this email address.'], 92 | error: 'Unprocessable Entity', 93 | }, 94 | }, 95 | }, 96 | invalidKey: { 97 | description: 'Returns when the provided token is invalid.', 98 | schema: { 99 | example: { 100 | statusCode: 422, 101 | message: ['Your key is invalid or has expired.'], 102 | error: 'Unauthorized', 103 | }, 104 | }, 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /src/common/dtos/app-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | class ClientDto { 4 | @ApiProperty() 5 | version: string; 6 | 7 | @ApiProperty() 8 | lastUpdate: string; 9 | } 10 | 11 | class MobileDto { 12 | @ApiProperty({ type: ClientDto }) 13 | ios: ClientDto; 14 | 15 | @ApiProperty({ type: ClientDto }) 16 | android: ClientDto; 17 | } 18 | 19 | export class AppInfoDto { 20 | @ApiProperty() 21 | name: string; 22 | 23 | @ApiProperty() 24 | version: string; 25 | 26 | @ApiProperty() 27 | description: string; 28 | 29 | @ApiProperty({ type: ClientDto }) 30 | web: ClientDto; 31 | 32 | @ApiProperty({ type: MobileDto }) 33 | mobile: MobileDto; 34 | } 35 | -------------------------------------------------------------------------------- /src/common/dtos/healt-check.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | class HealthIndicatorDto { 4 | @ApiProperty({ description: 'The status of the health indicator' }) 5 | status: string; 6 | } 7 | 8 | class HealthIndicatorsDto { 9 | @ApiProperty({ description: 'Health check for database' }) 10 | database: HealthIndicatorDto; 11 | 12 | @ApiProperty({ description: 'Health check for NestJS documentation' }) 13 | nestjsDocs: HealthIndicatorDto; 14 | } 15 | 16 | export class HealthCheckDto { 17 | @ApiProperty({ description: 'The status of the health check' }) 18 | status: string; 19 | 20 | @ApiProperty({ 21 | description: 'The details of the health check', 22 | type: HealthIndicatorsDto, 23 | }) 24 | info: HealthIndicatorsDto; 25 | 26 | @ApiProperty({ description: 'The error details (if any)' }) 27 | error: {}; 28 | 29 | @ApiProperty({ 30 | description: 'The details of the health check', 31 | type: HealthIndicatorsDto, 32 | }) 33 | details: HealthIndicatorsDto; 34 | } 35 | -------------------------------------------------------------------------------- /src/common/dtos/meta.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class MetaDto { 4 | @ApiProperty({ description: 'Current page' }) 5 | page: number; 6 | 7 | @ApiProperty({ description: 'Number of items per page' }) 8 | pageSize: number; 9 | 10 | @ApiProperty({ description: 'Total number of items' }) 11 | totalItems: number; 12 | 13 | @ApiProperty({ description: 'Total number of pages' }) 14 | totalPages: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/common/enums/index.ts: -------------------------------------------------------------------------------- 1 | export enum SortOrder { 2 | ASC = 'asc', 3 | DESC = 'desc', 4 | } 5 | -------------------------------------------------------------------------------- /src/common/exceptions/account-inactive.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class AccountInactiveException extends HttpException { 4 | constructor(message: string) { 5 | super(message, HttpStatus.UNAUTHORIZED); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/common/exceptions/exception-filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | Logger, 8 | } from '@nestjs/common'; 9 | 10 | @Catch() 11 | export class AllExceptionsFilter implements ExceptionFilter { 12 | private readonly logger = new Logger(AllExceptionsFilter.name); 13 | 14 | catch(exception: unknown, host: ArgumentsHost) { 15 | const ctx = host.switchToHttp(); 16 | const response = ctx.getResponse(); 17 | const request = ctx.getRequest(); 18 | const requestId = (request as any).requestId || 'N/A'; 19 | 20 | const status = 21 | exception instanceof HttpException 22 | ? exception.getStatus() 23 | : HttpStatus.INTERNAL_SERVER_ERROR; 24 | 25 | const errorResponse = { 26 | statusCode: status, 27 | timestamp: new Date().toISOString(), 28 | path: request.url, 29 | message: 30 | exception instanceof HttpException 31 | ? exception.getResponse() 32 | : 'Internal server error', 33 | requestId, 34 | }; 35 | 36 | // Log the error with request details 37 | this.logger.error( 38 | `Request ID: ${requestId} - Unhandled Exception:`, 39 | exception, 40 | ); 41 | 42 | response.status(status).json(errorResponse); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/common/interceptors/serialize.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseInterceptors, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { map } from 'rxjs/operators'; 9 | import { plainToInstance } from 'class-transformer'; 10 | 11 | //argument should be a class 12 | interface ClassConstructor { 13 | new (...args: any[]): {}; 14 | } 15 | 16 | //the class satisfy the NestInterceptor interface 17 | export function Serialize(dto: ClassConstructor) { 18 | return UseInterceptors(new SerializeInterceptor(dto)); 19 | } 20 | 21 | export class SerializeInterceptor implements NestInterceptor { 22 | constructor(private dto: any) {} 23 | intercept( 24 | context: ExecutionContext, 25 | handler: CallHandler, 26 | ): Observable | Promise> { 27 | //Run something before a request is handled by the request handler 28 | //console.log('I am running before the handler', context.getArgByIndex); 29 | 30 | return handler.handle().pipe( 31 | map((data: any) => { 32 | if (data && data.user) { 33 | data.user = plainToInstance(this.dto, data.user, { 34 | excludeExtraneousValues: true, 35 | }); 36 | return data; 37 | } 38 | //Run something before the response is sent out 39 | //console.log('I am running before response is sent out', data); 40 | // Add this block to handle objects with a 'data' property 41 | if (data && data.data) { 42 | data.data = plainToInstance(this.dto, data.data, { 43 | excludeExtraneousValues: true, 44 | }); 45 | return data; 46 | } 47 | 48 | return plainToInstance(this.dto, data, { 49 | //ensure that the returned object is an instance of UserDto with Expose decorator 50 | excludeExtraneousValues: true, 51 | }); 52 | }), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/common/middlewares/logs.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | @Injectable() 6 | export class RequestLoggingMiddleware implements NestMiddleware { 7 | private readonly logger = new Logger(RequestLoggingMiddleware.name); 8 | 9 | private maskSensitiveData(data: any): any { 10 | if (typeof data !== 'object' || data === null) { 11 | return data; 12 | } 13 | 14 | const sensitiveFields = [ 15 | 'password', 16 | 'authorization', 17 | 'accessToken', 18 | 'refreshToken', 19 | 'tempAuthToken', 20 | 'otp', 21 | ]; 22 | const maskedData = { ...data }; 23 | 24 | for (const field of sensitiveFields) { 25 | if (maskedData[field]) { 26 | maskedData[field] = '***'; 27 | } 28 | } 29 | 30 | return maskedData; 31 | } 32 | 33 | private shouldLogResponse(req: Request): boolean { 34 | // Add your logic to decide which routes to log responses for 35 | // Example: Exclude logging for GET /api/v3/products 36 | const excludedRoutes = [ 37 | { method: 'GET', path: '/api/v3/users/me' }, 38 | // Add other routes as needed 39 | ]; 40 | 41 | return !excludedRoutes.some( 42 | (route) => 43 | route.method === req.method && req.originalUrl.startsWith(route.path), 44 | ); 45 | } 46 | 47 | use(req: Request, res: Response, next: NextFunction) { 48 | const { method, originalUrl, headers, body } = req; 49 | const start = Date.now(); 50 | const requestId = uuidv4(); 51 | 52 | // Add request ID to request object for tracing 53 | (req as any).requestId = requestId; 54 | 55 | // Mask sensitive fields in headers and body 56 | const maskedHeaders = this.maskSensitiveData({ 57 | ...headers, 58 | authorization: headers.authorization ? '***' : undefined, 59 | }); 60 | const maskedBody = this.maskSensitiveData(body); 61 | 62 | // Log incoming request details 63 | const clientIp = 64 | req.headers['x-forwarded-for'] || req.connection.remoteAddress; 65 | this.logger.log( 66 | `Request ID: ${requestId} - Incoming Request: ${method} ${originalUrl}`, 67 | ); 68 | this.logger.debug(`Request ID: ${requestId} - Client IP: ${clientIp}`); 69 | this.logger.debug( 70 | `Request ID: ${requestId} - Headers: ${JSON.stringify(maskedHeaders)}`, 71 | ); 72 | this.logger.debug( 73 | `Request ID: ${requestId} - Body: ${JSON.stringify(maskedBody)}`, 74 | ); 75 | 76 | // Capture response data without causing circular reference issues 77 | const originalSend = res.send.bind(res); 78 | res.send = (body) => { 79 | const duration = Date.now() - start; 80 | const { statusCode } = res; 81 | 82 | if (this.shouldLogResponse(req)) { 83 | // Convert body to JSON if it's a string 84 | let responseBody; 85 | try { 86 | responseBody = typeof body === 'string' ? JSON.parse(body) : body; 87 | } catch (error) { 88 | responseBody = body; 89 | } 90 | 91 | // Mask sensitive fields in response body for logging 92 | const maskedResponseBody = this.maskSensitiveData(responseBody); 93 | 94 | // Log outgoing response with masked data asynchronously 95 | setImmediate(() => { 96 | this.logger.log( 97 | `Request ID: ${requestId} - Outgoing Response: ${method} ${originalUrl} ${statusCode} - ${duration}ms`, 98 | ); 99 | if (typeof maskedResponseBody === 'string') { 100 | this.logger.debug( 101 | `Request ID: ${requestId} - Response: ${maskedResponseBody}`, 102 | ); 103 | } else { 104 | this.logger.debug( 105 | `Request ID: ${requestId} - Response: ${JSON.stringify(maskedResponseBody)}`, 106 | ); 107 | } 108 | 109 | if (statusCode >= 400) { 110 | this.logger.error( 111 | `Request ID: ${requestId} - Error Response: ${method} ${originalUrl} ${statusCode}`, 112 | ); 113 | } 114 | }); 115 | } 116 | 117 | // Send original response body back to the client 118 | return originalSend(body); 119 | }; 120 | 121 | next(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/common/validation/validation.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { APP_PIPE } from '@nestjs/core'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | 5 | @Global() 6 | @Module({ 7 | providers: [ 8 | { 9 | provide: APP_PIPE, 10 | useValue: new ValidationPipe({ 11 | whitelist: true, 12 | }), 13 | }, 14 | ], 15 | }) 16 | export class ValidationModule {} 17 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { currentConfig } from '../../db-config'; 4 | 5 | @Module({ 6 | imports: [TypeOrmModule.forRoot(currentConfig)], 7 | }) 8 | export class DatabaseModule {} 9 | -------------------------------------------------------------------------------- /src/guards/2FA.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { JwtService } from '@nestjs/jwt'; 8 | import { Observable } from 'rxjs'; 9 | import { UsersService } from '../users/users.service'; 10 | 11 | @Injectable() 12 | export class TwoFactorAuthGuard implements CanActivate { 13 | constructor( 14 | private jwtService: JwtService, 15 | private usersService: UsersService, 16 | ) {} 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const request = context.switchToHttp().getRequest(); 20 | const { tempAuthToken } = request.body; 21 | 22 | if (!tempAuthToken) { 23 | throw new UnauthorizedException('Temporary token is required.'); 24 | } 25 | 26 | try { 27 | const decoded = this.jwtService.verify(tempAuthToken); 28 | request.user = await this.validateUser(decoded.userId, context); 29 | return true; 30 | } catch (error) { 31 | throw new UnauthorizedException('Invalid or expired temporary token.'); 32 | } 33 | } 34 | 35 | async validateUser(userId: string, context: ExecutionContext): Promise { 36 | const user = await this.usersService.findOneById(userId); 37 | if (!user) { 38 | throw new UnauthorizedException('No user found for this ID.'); 39 | } 40 | return user; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/guards/admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext } from '@nestjs/common'; 2 | 3 | export class AdminGuard implements CanActivate { 4 | canActivate(context: ExecutionContext) { 5 | const request = context.switchToHttp().getRequest(); 6 | const user = request.user; 7 | if (!user) return false; 8 | return user.role === 'admin'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') { 6 | handleRequest(err, user, info, context) { 7 | if (err || !user) { 8 | throw err || new UnauthorizedException(); 9 | } 10 | context.switchToHttp().getRequest().user = user; 11 | return user; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/guards/local.guard.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '@nestjs/passport'; 2 | 3 | export class LocalAuthGuard extends AuthGuard('local') {} 4 | -------------------------------------------------------------------------------- /src/guards/refresh.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class RefreshTokenGuard extends AuthGuard('refresh') { 6 | handleRequest(err, user, info) { 7 | if (err || !user) { 8 | // Translate the message based on the current language 9 | throw err || new UnauthorizedException(); 10 | } 11 | return user; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/guards/role.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | ForbiddenException, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { ROLES_KEY } from 'src/users/decorators/roles.decorator'; 9 | import { UserRoles } from 'src/users/entities/user.entity'; 10 | 11 | @Injectable() 12 | export class RolesGuard implements CanActivate { 13 | constructor(private reflector: Reflector) {} 14 | 15 | async canActivate(context: ExecutionContext): Promise { 16 | const requiredRoles = this.reflector.getAllAndOverride( 17 | ROLES_KEY, 18 | [context.getHandler(), context.getClass()], 19 | ); 20 | 21 | if (!requiredRoles) { 22 | return true; 23 | } 24 | 25 | const { user } = context.switchToHttp().getRequest(); 26 | const hasRole = requiredRoles.some((role) => user.role?.includes(role)); 27 | 28 | if (!hasRole) { 29 | throw new ForbiddenException('Bu kaynağa erişim izniniz yok.'); 30 | } 31 | 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 4 | import * as cookieParser from 'cookie-parser'; 5 | import { AllExceptionsFilter } from './common/exceptions/exception-filter'; 6 | import { ValidationPipe } from '@nestjs/common'; 7 | import 'reflect-metadata'; 8 | 9 | async function bootstrap() { 10 | const app = await NestFactory.create(AppModule); 11 | app.setGlobalPrefix('api/v1'); 12 | app.enableCors(); 13 | app.use(cookieParser()); 14 | app.useGlobalFilters(new AllExceptionsFilter()); 15 | 16 | app.useGlobalPipes( 17 | new ValidationPipe({ 18 | whitelist: true, 19 | transform: true, 20 | validateCustomDecorators: true, 21 | }), 22 | ); 23 | 24 | const config = new DocumentBuilder() 25 | .setTitle('Auth API') 26 | .setDescription('Auth API Documentation') 27 | .setVersion('1.0') 28 | .addTag('users') 29 | .setContact('Bilal ARKAN', 'https://discord.gg/xxx', 'simbolmina@gmail.com') 30 | .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }) // 31 | .build(); 32 | 33 | const document = SwaggerModule.createDocument(app, config); 34 | SwaggerModule.setup('api-doc', app, document); 35 | 36 | await app.listen(4000); 37 | } 38 | bootstrap(); 39 | -------------------------------------------------------------------------------- /src/notifications/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpStatus, HttpException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import * as nodemailer from 'nodemailer'; 4 | 5 | @Injectable() 6 | export class EmailService { 7 | private transporter: nodemailer.Transporter; 8 | 9 | constructor(private configService: ConfigService) { 10 | this.transporter = nodemailer.createTransport({ 11 | service: 'gmail', 12 | host: 'smtp.gmail.com', 13 | port: 587, 14 | secure: false, // true for 465, false for other ports 15 | auth: { 16 | user: this.configService.get('EMAIL_USER'), 17 | pass: this.configService.get('EMAIL_PASS'), 18 | }, 19 | }); 20 | } 21 | 22 | async sendOtpEmail(email: string, otp: string): Promise<{ message: string }> { 23 | const mailOptions = { 24 | from: this.configService.get('EMAIL_FROM'), // Sender address 25 | to: email, // List of receivers 26 | subject: `2FA CODE : ${otp}`, // Subject line 27 | html: ` 28 | 29 | 30 | 31 | 54 | 55 | 56 | 61 | 62 | 63 | `, // HTML body content 64 | }; 65 | 66 | try { 67 | await this.transporter.sendMail(mailOptions); 68 | return { message: 'Success: OTP email was sent' }; 69 | } catch (error) { 70 | console.error('EmailService Error:', error); 71 | throw new HttpException( 72 | 'Could not send OTP email', 73 | HttpStatus.INTERNAL_SERVER_ERROR, 74 | ); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/notifications/notifications.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { NotificationsService } from './notifications.service'; 3 | 4 | @Controller('notifications') 5 | export class NotificationsController { 6 | constructor(private readonly notificationsService: NotificationsService) {} 7 | } 8 | -------------------------------------------------------------------------------- /src/notifications/notifications.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { NotificationsService } from './notifications.service'; 3 | import { NotificationsController } from './notifications.controller'; 4 | import { EmailService } from './email.service'; 5 | import { SmsService } from './sms.service'; 6 | 7 | @Module({ 8 | controllers: [NotificationsController], 9 | providers: [NotificationsService, EmailService, SmsService], 10 | exports: [NotificationsService, EmailService, SmsService], 11 | }) 12 | export class NotificationsModule {} 13 | -------------------------------------------------------------------------------- /src/notifications/notifications.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class NotificationsService {} 5 | -------------------------------------------------------------------------------- /src/notifications/sms.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class SmsService { 5 | async sendSMS(phoneNumber: string, message: string): Promise { 6 | // some logic to send sms to phoneNumber 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/users/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator } from '@nestjs/common'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | 4 | export const CurrentUser = createParamDecorator( 5 | (data: unknown, context: ExecutionContext) => { 6 | const request = context.switchToHttp().getRequest(); 7 | return request.user; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /src/users/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards, applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiBearerAuth, 5 | ApiBody, 6 | ApiCreatedResponse, 7 | ApiForbiddenResponse, 8 | ApiNoContentResponse, 9 | ApiNotFoundResponse, 10 | ApiOkResponse, 11 | ApiOperation, 12 | ApiQuery, 13 | ApiUnauthorizedResponse, 14 | } from '@nestjs/swagger'; 15 | import { JwtAuthGuard } from 'src/guards/jwt-auth.guard'; 16 | import { LocalAuthGuard } from 'src/guards/local.guard'; 17 | import { AdminGuard } from 'src/guards/admin.guard'; 18 | import { User, UserRoles } from '../entities/user.entity'; 19 | import { UserDto } from '../dtos/user.dto'; 20 | import { Serialize } from 'src/common/interceptors/serialize.interceptor'; 21 | import { AdminUpdateUserDto, UpdateUserDto } from '../dtos/update-user.dto'; 22 | import { commonErrorResponses } from 'src/common/constants'; 23 | import { RolesGuard } from 'src/guards/role.guard'; 24 | import { Roles } from './roles.decorator'; 25 | import { PaginatedUserDto } from '../dtos/paginated-users.dto'; 26 | import { UpdateMeDto } from '../dtos/update-me.dto'; 27 | 28 | export function GetAllUsersDecorator() { 29 | return applyDecorators( 30 | UseGuards(JwtAuthGuard, AdminGuard), 31 | ApiBearerAuth(), 32 | ApiOperation({ 33 | summary: 'Get all users', 34 | description: 35 | 'This endpoint retrieves all user entries from the database. Only administrators can access this endpoint to view the full list of users.', 36 | }), 37 | ApiOkResponse({ description: 'Returns all users', type: PaginatedUserDto }), 38 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 39 | ApiForbiddenResponse(commonErrorResponses.forbidden), 40 | ); 41 | } 42 | 43 | export function GetCurrentUserDecorator() { 44 | return applyDecorators( 45 | ApiBearerAuth(), 46 | ApiOperation({ 47 | summary: 'Get current user', 48 | description: 49 | 'This endpoint returns the details of the currently authenticated user. The user must be authenticated with a valid JWT token.', 50 | }), 51 | ApiOkResponse({ 52 | description: 'Returns the current user', 53 | type: UserDto, 54 | }), 55 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 56 | UseGuards(JwtAuthGuard), 57 | ); 58 | } 59 | 60 | export function UpdateCurrentUserDecorator() { 61 | return applyDecorators( 62 | Serialize(UserDto), 63 | UseGuards(JwtAuthGuard), 64 | ApiBearerAuth(), 65 | ApiOperation({ 66 | summary: 'Update current user', 67 | description: 68 | 'This endpoint allows the currently authenticated user to update their own user details, such as name, gender, etc. Allowed fields are listed in request body example. Each field can separetly be updated, you don"t have to send whole object. The user must be authenticated with a valid JWT token.', 69 | }), 70 | ApiOkResponse({ 71 | description: 'Returns the updated user', 72 | type: UserDto, 73 | }), 74 | ApiBadRequestResponse({ 75 | description: 'Invalid data provided', 76 | }), 77 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 78 | ApiBody({ 79 | description: 'Allowed data to be updated by user', 80 | type: UpdateMeDto, 81 | }), 82 | ); 83 | } 84 | 85 | export function DeleteCurrentUserDecorator() { 86 | return applyDecorators( 87 | Serialize(UserDto), 88 | UseGuards(JwtAuthGuard), 89 | ApiBearerAuth(), 90 | ApiOperation({ 91 | summary: 'Deactivate current user', 92 | description: 93 | 'This endpoint allows the currently authenticated user to deactivate their account. Deactivated accounts are not deleted and can be reactivated. The user must be authenticated with a valid JWT token.', 94 | }), 95 | ApiNoContentResponse({ 96 | description: 'User has been deactivated', 97 | }), 98 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 99 | ); 100 | } 101 | 102 | export function GetUserByIdDecorator() { 103 | return applyDecorators( 104 | UseGuards(JwtAuthGuard, AdminGuard), 105 | ApiBearerAuth(), 106 | ApiOperation({ 107 | summary: 'Get user by ID', 108 | description: 109 | "This endpoint retrieves the user entry with the given ID from the database. Only administrators can access this endpoint to view other users' details. Details in this api are full user details so admin can access and edit all user details.", 110 | }), 111 | ApiOkResponse({ 112 | description: 'Returns the user with the given ID', 113 | type: User, 114 | }), 115 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 116 | ApiForbiddenResponse(commonErrorResponses.forbidden), 117 | ApiNotFoundResponse(commonErrorResponses.notFound), 118 | ); 119 | } 120 | 121 | export function BanUserDecorator() { 122 | return applyDecorators( 123 | UseGuards(JwtAuthGuard, AdminGuard), // Adjust guards as necessary 124 | ApiBearerAuth(), 125 | ApiOperation({ 126 | summary: 'Ban a user', 127 | description: 128 | 'This endpoint bans a user based on their user ID. Only administrators can access this endpoint to ban users. Ban is in immmedate effect, at next api call of the user', 129 | }), 130 | ApiOkResponse({ 131 | description: 'The user has been successfully banned', 132 | }), 133 | ApiNotFoundResponse(commonErrorResponses.notFound), 134 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 135 | ApiForbiddenResponse(commonErrorResponses.forbidden), 136 | ); 137 | } 138 | 139 | export function GetUserByEmailDecorator() { 140 | return applyDecorators( 141 | UseGuards(JwtAuthGuard, AdminGuard), 142 | ApiBearerAuth(), 143 | ApiOperation({ 144 | summary: 'Get users by email', 145 | description: 146 | 'This endpoint retrieves a user from the database that match the given email. Only administrators can access this endpoint to search for users by their email. Details in this api are full user details so admin can access and edit all user details.', 147 | }), 148 | ApiQuery({ name: 'email', description: 'Email to search for users' }), 149 | ApiOkResponse({ 150 | description: 'Returns users with the given email', 151 | type: User, 152 | }), 153 | ApiNotFoundResponse(commonErrorResponses.notFound), 154 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 155 | ApiForbiddenResponse(commonErrorResponses.forbidden), 156 | ); 157 | } 158 | 159 | export function UpdateUserByIdDecorator() { 160 | return applyDecorators( 161 | UseGuards(JwtAuthGuard, AdminGuard), 162 | ApiBody({ 163 | description: 'Allowed data to be updated by user', 164 | type: UpdateMeDto, 165 | }), 166 | ApiBearerAuth(), 167 | ApiOkResponse({ type: User }), 168 | ApiOperation({ 169 | summary: 'Update user by ID', 170 | description: 171 | 'This endpoint allows an administrator to update user details for any user in the database. Only administrators can access this endpoint.', 172 | }), 173 | ApiNotFoundResponse(commonErrorResponses.notFound), 174 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 175 | ApiForbiddenResponse(commonErrorResponses.forbidden), 176 | ); 177 | } 178 | 179 | export function DeleteUserByIdDecorator() { 180 | return applyDecorators( 181 | UseGuards(JwtAuthGuard, AdminGuard), 182 | ApiBearerAuth(), 183 | ApiOperation({ 184 | summary: 'Deactivate user by ID', 185 | description: 186 | 'This endpoint allows an administrator to deactivate a user account. Deactivated accounts are not deleted and can be reactivated. Only administrators can access this endpoint.', 187 | }), 188 | ApiNoContentResponse({ description: 'User has been deactivated' }), 189 | ApiNotFoundResponse(commonErrorResponses.notFound), 190 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 191 | ApiForbiddenResponse(commonErrorResponses.forbidden), 192 | ); 193 | } 194 | 195 | export function HardDeleteUserByIdDecorator() { 196 | return applyDecorators( 197 | UseGuards(JwtAuthGuard, AdminGuard), 198 | ApiBearerAuth(), 199 | ApiOperation({ 200 | summary: 'Delete user by ID', 201 | description: 202 | 'This endpoint allows an administrator to permanently delete a user account from the database. This operation cannot be undone. Only administrators can access this endpoint.', 203 | }), 204 | ApiNoContentResponse({ description: 'User has been deleted' }), 205 | ApiNotFoundResponse(commonErrorResponses.notFound), 206 | ApiUnauthorizedResponse(commonErrorResponses.unAuthorized), 207 | ApiForbiddenResponse(commonErrorResponses.forbidden), 208 | ); 209 | } 210 | 211 | export function AssignRoleDecorator() { 212 | return applyDecorators( 213 | ApiOperation({ summary: 'Change role of a user : ADMIN' }), 214 | UseGuards(JwtAuthGuard, RolesGuard), 215 | Roles(UserRoles.Admin, UserRoles.User), 216 | ); 217 | } 218 | -------------------------------------------------------------------------------- /src/users/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { UserRoles } from '../entities/user.entity'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: UserRoles[]) => SetMetadata(ROLES_KEY, roles); 6 | -------------------------------------------------------------------------------- /src/users/dtos/paginated-users.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { MetaDto } from 'src/common/dtos/meta.dto'; 3 | import { UserDto } from './user.dto'; 4 | 5 | export class PaginatedUserDto { 6 | @ApiProperty({ type: MetaDto }) 7 | meta: MetaDto; 8 | 9 | @ApiProperty({ type: [UserDto] }) // Use your existing User DTO 10 | data: UserDto[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/users/dtos/update-me.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail } from 'class-validator'; 3 | 4 | export class UpdateMeDto { 5 | @ApiProperty({ 6 | description: 'The email of the user', 7 | example: 'example@email.com', 8 | required: false, 9 | }) 10 | @IsEmail() 11 | email?: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/users/dtos/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsEmail, 4 | IsString, 5 | IsOptional, 6 | IsEnum, 7 | IsNumber, 8 | IsNotEmpty, 9 | IsStrongPassword, 10 | IsDate, 11 | IsBoolean, 12 | } from 'class-validator'; 13 | import { UserRoles } from '../entities/user.entity'; 14 | 15 | export class UpdateUserDto { 16 | @IsNotEmpty() 17 | @IsString() 18 | @IsOptional() 19 | @IsStrongPassword() 20 | password: string; 21 | 22 | @ApiProperty({ 23 | description: 'The email of the user', 24 | example: 'example@email.com', 25 | required: false, 26 | }) 27 | @IsEmail() 28 | @IsOptional() 29 | email?: string; 30 | 31 | @IsNumber() 32 | @IsOptional() 33 | tokenVersion: number; 34 | 35 | @IsNotEmpty() 36 | @IsString() 37 | @IsOptional() 38 | passwordResetCode: string; 39 | 40 | @IsOptional() 41 | @IsDate() 42 | passwordResetExpires: Date; 43 | 44 | @IsNotEmpty() 45 | @IsString() 46 | @IsOptional() 47 | twoFactorAuthToken: string; 48 | 49 | @IsOptional() 50 | @IsDate() 51 | twoFactorAuthTokenExpiry: Date; 52 | 53 | @IsOptional() 54 | @IsBoolean() 55 | isTwoFactorAuthEnabled: boolean; 56 | 57 | @IsOptional() 58 | @IsString() 59 | fcmToken: string; 60 | } 61 | 62 | export class AdminUpdateUserDto extends UpdateUserDto { 63 | @ApiProperty({ 64 | description: 'The role of the user', 65 | example: 'user', 66 | enum: UserRoles, 67 | required: false, 68 | }) 69 | @IsEnum(UserRoles, { message: 'Role must be either user, manager, or admin' }) 70 | @IsOptional() 71 | role?: UserRoles; 72 | } 73 | -------------------------------------------------------------------------------- /src/users/dtos/user-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Transform, Type } from 'class-transformer'; 3 | import { 4 | IsOptional, 5 | IsInt, 6 | Min, 7 | IsEnum, 8 | IsIn, 9 | IsArray, 10 | ArrayNotEmpty, 11 | IsString, 12 | } from 'class-validator'; 13 | import { SortOrder } from 'src/common/enums'; 14 | import { UserStatus } from '../entities/user.entity'; 15 | 16 | export class UsersQueryDto { 17 | @ApiPropertyOptional() 18 | @IsOptional() 19 | role?: string; 20 | 21 | @ApiPropertyOptional() 22 | @IsOptional() 23 | @IsEnum(UserStatus) 24 | @Type(() => String) 25 | @IsString({ each: true }) 26 | status?: string | string[]; 27 | 28 | @ApiPropertyOptional() 29 | @IsOptional() 30 | query?: string; 31 | 32 | @ApiPropertyOptional() 33 | @IsOptional() 34 | // @IsIn(['role', 'createdAt']) 35 | sortBy?: string; 36 | 37 | @ApiPropertyOptional({ enum: SortOrder }) 38 | @IsEnum(SortOrder) 39 | @IsOptional() 40 | sortOrder: SortOrder = SortOrder.ASC; 41 | 42 | @ApiPropertyOptional() 43 | @Type(() => Number) 44 | @IsInt() 45 | @Min(1) 46 | @IsOptional() 47 | page: number = 1; 48 | 49 | @ApiPropertyOptional() 50 | @Type(() => Number) 51 | @IsInt() 52 | @Min(1) 53 | @IsOptional() 54 | limit: number = 10; 55 | } 56 | -------------------------------------------------------------------------------- /src/users/dtos/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UserDto { 5 | @ApiProperty({ 6 | description: 'The id of the user', 7 | example: '02302d6e-4eea-403d-a466-6ba902b004fb', 8 | }) 9 | @Expose() 10 | id: string; 11 | 12 | @ApiProperty({ 13 | description: 'The email of the user', 14 | example: 'example@email.com', 15 | }) 16 | @Expose() 17 | email: string; 18 | 19 | @ApiProperty({ 20 | description: 'The status of the user', 21 | example: 'active', 22 | enum: [ 23 | 'pending', 24 | 'active', 25 | 'inactive', 26 | 'blocked', 27 | 'soft_deleted', 28 | 'deleted', 29 | ], 30 | }) 31 | @Expose() 32 | status: string; 33 | 34 | @ApiProperty({ 35 | description: 'The role of the user', 36 | example: 'user', 37 | enum: ['user', 'manager', 'admin'], 38 | }) 39 | @Expose() 40 | role: string; 41 | } 42 | -------------------------------------------------------------------------------- /src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterInsert, 3 | AfterRemove, 4 | AfterUpdate, 5 | Entity, 6 | Column, 7 | PrimaryGeneratedColumn, 8 | OneToMany, 9 | CreateDateColumn, 10 | UpdateDateColumn, 11 | Index, 12 | DeleteDateColumn, 13 | } from 'typeorm'; 14 | import { RefreshToken } from '../../auth/entities/refresh-token.entity'; 15 | import { Expose } from 'class-transformer'; 16 | 17 | export enum UserStatus { 18 | Pending = 'pending', 19 | Active = 'active', 20 | Inactive = 'inactive', 21 | Blocked = 'blocked', 22 | SoftDeleted = 'soft_deleted', 23 | Deleted = 'deleted', 24 | } 25 | 26 | export enum UserRoles { 27 | User = 'user', 28 | Admin = 'admin', 29 | } 30 | 31 | @Entity('users') 32 | @Index(['googleId', 'email', 'id'], { unique: true }) 33 | export class User { 34 | @Expose() 35 | @PrimaryGeneratedColumn('uuid') 36 | id: string; 37 | 38 | @Expose() 39 | @Column({ unique: true }) 40 | email: string; 41 | 42 | @Expose() 43 | @Column({ unique: true }) 44 | phoneNumber: string; 45 | 46 | @Column({ nullable: true }) 47 | googleId: string | null; 48 | 49 | @Column({ 50 | nullable: false, 51 | type: 'enum', 52 | enum: ['google', 'apple', 'none'], 53 | default: 'none', 54 | }) 55 | provider: string; 56 | 57 | @Column({ nullable: true }) 58 | password: string; 59 | 60 | @Expose() 61 | @Column({ 62 | nullable: false, 63 | type: 'enum', 64 | enum: UserRoles, 65 | default: UserRoles.User, 66 | }) 67 | role: UserRoles; 68 | 69 | @Expose() 70 | @Column({ 71 | type: String, 72 | enum: UserStatus, 73 | default: UserStatus.Active, 74 | }) 75 | status: UserStatus; 76 | 77 | @OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user, { 78 | cascade: true, 79 | }) 80 | refreshTokens: RefreshToken[]; 81 | 82 | @Column({ default: 0 }) 83 | tokenVersion: number; 84 | 85 | @Column({ default: false }) 86 | isEmailVerified: boolean; 87 | 88 | @Column({ default: false }) 89 | isPhoneVerified: boolean; 90 | 91 | @Expose() 92 | @Column({ default: false }) 93 | isTwoFactorAuthEnabled: boolean; 94 | 95 | @Column({ nullable: true }) 96 | twoFactorAuthToken: string; 97 | 98 | @Column({ nullable: true }) 99 | twoFactorAuthTokenExpiry: Date; 100 | 101 | @Column({ nullable: true }) 102 | verifyEmailToken: string; 103 | 104 | @Column({ nullable: true }) 105 | verifyEmailExpires: Date; 106 | 107 | @Column({ nullable: true }) 108 | verifyPhoneToken: string; 109 | 110 | @Column({ nullable: true }) 111 | verifyPhoneExpires: Date; 112 | 113 | @Column({ nullable: true }) 114 | passwordResetCode: string; 115 | 116 | @Column({ nullable: true }) 117 | passwordResetExpires: Date; 118 | 119 | @CreateDateColumn() 120 | createdAt: Date; 121 | 122 | @UpdateDateColumn() 123 | updatedAt: Date; 124 | 125 | @DeleteDateColumn() 126 | deletedAt: Date; 127 | 128 | static fromPlain(plain: Partial): User { 129 | // console.log('plain', plain); 130 | const user = new User(); 131 | // console.log('user', user); 132 | Object.assign(user, plain); 133 | // console.log('user', user); 134 | return user; 135 | } 136 | 137 | @AfterInsert() 138 | logInsert() { 139 | console.log('Inserted User with id', this.id); 140 | } 141 | 142 | @AfterUpdate() 143 | logUpdate() { 144 | console.log('Updated User with id', this.id); 145 | } 146 | 147 | @AfterRemove() 148 | logRemove() { 149 | console.log('Removed User with id', this.id); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/users/interceptors/current-user.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NestInterceptor, 3 | ExecutionContext, 4 | CallHandler, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { UsersService } from '../users.service'; 8 | 9 | @Injectable() 10 | export class CurrentUserInterceptor implements NestInterceptor { 11 | constructor(private usersService: UsersService) {} 12 | 13 | async intercept(context: ExecutionContext, handler: CallHandler) { 14 | const request = context.switchToHttp().getRequest(); 15 | const userId = request.user.id || {}; 16 | if (userId) { 17 | const user = await this.usersService.findOneById(userId); 18 | request.currentUser = user; 19 | } 20 | return handler.handle(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/users/interceptors/user-id.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { map } from 'rxjs/operators'; 9 | 10 | @Injectable() 11 | export class UserIdInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | const request = context.switchToHttp().getRequest(); 14 | const user = request.user; 15 | 16 | console.log('user from interceptor', user); 17 | 18 | if (user && user.id) { 19 | request.body.userId = user.id; 20 | } 21 | 22 | console.log('body from interceptor', request.body); 23 | 24 | return next.handle().pipe( 25 | map((data: any) => { 26 | // console.log('data from interceptor', data); 27 | if (user) { 28 | return { ...data, userId: user.id }; 29 | } else { 30 | return data; 31 | } 32 | }), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/users/middlewares/current-user.middleware.ts: -------------------------------------------------------------------------------- 1 | // import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | // import { Request, Response, NextFunction } from "express"; 3 | // import { UsersService } from "../users.service"; 4 | 5 | // @Injectable() 6 | // export class CurrentUserMiddleware implements NestMiddleware { 7 | // constructor(private usersService: UsersService) {} 8 | // async use(req: Request, res: Response, next: NextFunction) { 9 | // const { id } = req.user || {}; 10 | // if (id) { 11 | // const user = await this.usersService.findOne(id); 12 | // req.user = user; 13 | // } 14 | // next(); 15 | // } 16 | // } 17 | -------------------------------------------------------------------------------- /src/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from './users.service'; 4 | import { AuthService } from '../auth/auth.service'; 5 | import { User } from './entities/user.entity'; 6 | import { SerializeInterceptor } from '../common/interceptors/serialize.interceptor'; 7 | import { NotFoundException } from '@nestjs/common'; 8 | import { UserDto } from './dtos/user.dto'; 9 | import { Response } from 'express'; 10 | import { AuthController } from '../auth/auth.controller'; 11 | 12 | describe('UsersController', () => { 13 | let usersController: UsersController; 14 | let authController: AuthController; 15 | let userService: Partial; 16 | let fakeAuthService: Partial; 17 | 18 | beforeEach(async () => { 19 | userService = { 20 | findAll: () => { 21 | return Promise.resolve([ 22 | { id: '1', email: 'test@test.com', password: 'test' } as User, 23 | ]); 24 | }, 25 | findOneById: (id: string) => { 26 | return Promise.resolve({ 27 | id, 28 | email: 'test@test.com', 29 | password: 'test', 30 | } as User); 31 | }, 32 | findByEmail: (email: string) => { 33 | return Promise.resolve({ 34 | id: '1', 35 | email, 36 | password: 'password', 37 | } as User); 38 | }, 39 | remove: (id: string) => { 40 | return Promise.resolve({ 41 | id, 42 | email: 'test@test.com', 43 | password: 'test', 44 | } as User); 45 | }, 46 | updateCurrentUser: (id: string, attrs: Partial) => { 47 | return Promise.resolve({ 48 | id, 49 | email: 'test@test.com', 50 | password: 'test', 51 | ...attrs, 52 | } as User); 53 | }, 54 | deactivate: (id: string) => { 55 | return Promise.resolve({ 56 | id, 57 | email: 'test@test.com', 58 | password: 'test', 59 | active: false, 60 | } as User); 61 | }, 62 | }; 63 | fakeAuthService = { 64 | // signup: () => {}, 65 | signin: (email: string, password: string) => { 66 | return Promise.resolve({ 67 | data: { 68 | id: '1', 69 | email, 70 | password, 71 | } as User, 72 | token: 'token', 73 | }); 74 | }, 75 | }; 76 | const module: TestingModule = await Test.createTestingModule({ 77 | controllers: [UsersController, AuthController], 78 | providers: [ 79 | { 80 | provide: UsersService, 81 | useValue: userService, 82 | }, 83 | { 84 | provide: AuthService, 85 | useValue: fakeAuthService, 86 | }, 87 | { 88 | provide: SerializeInterceptor, 89 | useValue: new SerializeInterceptor(UserDto), 90 | }, 91 | ], 92 | }).compile(); 93 | 94 | usersController = module.get(UsersController); 95 | authController = module.get(AuthController); 96 | }); 97 | 98 | it('should be defined', () => { 99 | expect(usersController).toBeDefined(); 100 | }); 101 | 102 | it('findUser returns a single user with given id', async () => { 103 | const user = await usersController.findUser('1'); 104 | expect(user).toBeDefined(); 105 | }); 106 | 107 | it('findUser throws an error if user with given id is not found', async () => { 108 | userService.findOneById = () => null; 109 | await expect(usersController.findUser('1')).rejects.toThrow( 110 | NotFoundException, 111 | ); 112 | }); 113 | 114 | it('findAllUsers returns all users', async () => { 115 | await expect(usersController.findAllUsers()).resolves.toEqual([ 116 | { id: '1', email: 'test@test.com', password: 'test' } as User, 117 | ]); 118 | }); 119 | 120 | it('findUsersByEmail returns a list of users with given email', async () => { 121 | const user = await usersController.findUserByEmail('test@test.com'); 122 | //expect(users).toEqual(1); 123 | expect(user.email).toEqual('test@test.com'); 124 | }); 125 | 126 | // it('signin returns user and token', async () => { 127 | // const { data, token } = await controller.signin({ 128 | // email: 'test@test.com', 129 | // password: 'test', 130 | // }); 131 | // expect(data.id).toEqual('1'); 132 | // expect(token).toBeDefined(); 133 | // }); 134 | it('signin returns user and token', async () => { 135 | const mockResponse = { 136 | cookie: jest.fn(), 137 | status: function () { 138 | return this; 139 | }, 140 | json: function () {}, 141 | } as unknown as Response; 142 | 143 | const { data, token } = await authController.signin( 144 | { email: 'test@test.com', password: 'test' }, 145 | mockResponse, 146 | ); 147 | 148 | expect(data.id).toEqual('1'); 149 | expect(token).toBeDefined(); 150 | expect(mockResponse.cookie).toHaveBeenCalledWith('auth_token', token, { 151 | httpOnly: true, 152 | maxAge: 72 * 60 * 60 * 1000, 153 | }); 154 | }); 155 | 156 | it('removeUser deactivates user and returns user', async () => { 157 | const user = await usersController.removeUser('1'); 158 | expect(user).toBeDefined(); 159 | expect(user.active).toEqual(false); 160 | }); 161 | 162 | it('removeUser throws an error if user is not found', async () => { 163 | userService.deactivate = () => Promise.reject(new NotFoundException()); 164 | await expect(usersController.removeUser('nonexistent')).rejects.toThrow( 165 | NotFoundException, 166 | ); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Patch, 6 | Param, 7 | Query, 8 | Delete, 9 | NotFoundException, 10 | Post, 11 | UseGuards, 12 | } from '@nestjs/common'; 13 | import { UsersService } from './users.service'; 14 | import { CurrentUser } from './decorators/current-user.decorator'; 15 | import { User, UserRoles } from './entities/user.entity'; 16 | import { ApiTags } from '@nestjs/swagger'; 17 | import { 18 | AssignRoleDecorator, 19 | BanUserDecorator, 20 | DeleteCurrentUserDecorator, 21 | GetAllUsersDecorator, 22 | GetCurrentUserDecorator, 23 | GetUserByIdDecorator, 24 | HardDeleteUserByIdDecorator, 25 | UpdateCurrentUserDecorator, 26 | UpdateUserByIdDecorator, 27 | } from './decorators'; 28 | import { UpdateMeDto } from './dtos/update-me.dto'; 29 | import { UsersQueryDto } from './dtos/user-query.dto'; 30 | import { Serialize } from 'src/common/interceptors/serialize.interceptor'; 31 | import { PaginatedUserDto } from './dtos/paginated-users.dto'; 32 | import { UserDto } from './dtos/user.dto'; 33 | import { AuthGuard } from '@nestjs/passport'; 34 | 35 | @Serialize(User) 36 | @ApiTags('users') 37 | @Controller('users') 38 | export class UsersController { 39 | constructor(private readonly usersService: UsersService) {} 40 | 41 | @GetAllUsersDecorator() 42 | @Serialize(UserDto) 43 | @Get() 44 | async findAllUsers(@Query() query: UsersQueryDto): Promise { 45 | return await this.usersService.findAll(query); 46 | } 47 | 48 | @GetCurrentUserDecorator() 49 | @UseGuards(AuthGuard('jwt')) 50 | @Get('/me') 51 | getMe(@CurrentUser() user: any): User { 52 | return user; 53 | } 54 | 55 | @UpdateCurrentUserDecorator() 56 | @Patch('/me') 57 | async updateCurrentUser( 58 | @CurrentUser() user: any, 59 | @Body() body: UpdateMeDto, 60 | ): Promise { 61 | return await this.usersService.updateCurrentUser(user.id, body); 62 | } 63 | 64 | @DeleteCurrentUserDecorator() 65 | @Delete('/me') 66 | async removeCurrentUser(@CurrentUser() user: any): Promise { 67 | await this.usersService.deactivate(user.id); 68 | } 69 | 70 | @BanUserDecorator() 71 | @Patch('/block/:userId') 72 | async banUser(@Param('userId') userId: string) { 73 | return await this.usersService.banUser(userId); 74 | } 75 | 76 | @GetUserByIdDecorator() 77 | @Get('/:userId') 78 | async findUser(@Param('userId') userId: string): Promise { 79 | const user = await this.usersService.findOneById(userId); 80 | if (!user) { 81 | throw new NotFoundException('user not found'); 82 | } 83 | return user; 84 | } 85 | 86 | @UpdateUserByIdDecorator() 87 | @Patch('/:userId') 88 | async updateUser( 89 | @Param('userId') userId: string, 90 | @Body() body: Partial, 91 | ) { 92 | return await this.usersService.updateUserByAdmin(userId, body); 93 | } 94 | 95 | @HardDeleteUserByIdDecorator() 96 | @Delete('/:userId/delete') 97 | async deleteUser(@Param('userId') userId: string) { 98 | return this.usersService.remove(userId); 99 | } 100 | 101 | @AssignRoleDecorator() 102 | @Post(':userId/assign') 103 | async assignRole( 104 | @Param('userId') userId: string, 105 | @Body('role') role: UserRoles, 106 | ) { 107 | return await this.usersService.assignRole(userId, role); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UsersController } from './users.controller'; 4 | import { UsersService } from './users.service'; 5 | import { User } from './entities/user.entity'; 6 | import { RefreshToken } from 'src/auth/entities/refresh-token.entity'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User, RefreshToken])], 10 | controllers: [UsersController], 11 | providers: [UsersService], 12 | exports: [UsersService], 13 | }) 14 | export class UsersModule {} 15 | -------------------------------------------------------------------------------- /src/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | import { Repository } from 'typeorm'; 4 | import { User } from './entities/user.entity'; 5 | import { getRepositoryToken } from '@nestjs/typeorm'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import { NotFoundException } from '@nestjs/common'; 8 | 9 | describe('UsersService', () => { 10 | let usersService: UsersService; 11 | let fakeUserRepository: Partial>; 12 | const userArray: User[] = []; 13 | 14 | beforeEach(() => { 15 | userArray.length = 0; 16 | jest.resetAllMocks(); 17 | }); 18 | 19 | beforeEach(async () => { 20 | fakeUserRepository = { 21 | create: jest.fn().mockImplementation((user) => { 22 | user.id = uuidv4(); 23 | user.gender = 'other'; 24 | user.role = 'user'; 25 | return user; 26 | }), 27 | 28 | save: jest.fn().mockImplementation((user: User | User[]) => { 29 | if (Array.isArray(user)) { 30 | userArray.push(...user); 31 | } else { 32 | userArray.push(user); 33 | } 34 | return Promise.resolve(user); 35 | }), 36 | 37 | find: jest.fn().mockImplementation(() => { 38 | return Promise.resolve(userArray); 39 | }), 40 | 41 | findOne: jest.fn().mockImplementation((options) => { 42 | const foundUser = userArray.find( 43 | (user) => user.id === options.where.id, 44 | ); 45 | return Promise.resolve(foundUser); 46 | }), 47 | 48 | remove: jest.fn().mockImplementation((user: User) => { 49 | const index = userArray.findIndex((u) => u.id === user.id); 50 | if (index !== -1) { 51 | const [removedUser] = userArray.splice(index, 1); 52 | return Promise.resolve(removedUser); 53 | } 54 | return Promise.resolve(undefined); 55 | }), 56 | 57 | findOneBy: jest.fn().mockImplementation((options) => { 58 | const keys = Object.keys(options); 59 | const foundUser = userArray.find((user) => { 60 | return keys.every((key) => user[key] === options[key]); 61 | }); 62 | return Promise.resolve(foundUser); 63 | }), 64 | preload: jest.fn().mockImplementation((values) => { 65 | const user = userArray.find((user) => user.id === values.id); 66 | if (!user) { 67 | return undefined; 68 | } 69 | return { ...user, ...values }; 70 | }), 71 | }; 72 | 73 | const module = await Test.createTestingModule({ 74 | providers: [ 75 | UsersService, 76 | { 77 | provide: getRepositoryToken(User), 78 | useValue: fakeUserRepository, 79 | }, 80 | ], 81 | }).compile(); 82 | 83 | usersService = module.get(UsersService); 84 | }); 85 | 86 | it('should be defined', () => { 87 | expect(usersService).toBeDefined(); 88 | }); 89 | 90 | it('should create a user', async () => { 91 | const email = 'test@example.com'; 92 | const password = 'testpassword'; 93 | const user = await usersService.create(email, password); 94 | 95 | expect(user.email).toBe(email); 96 | expect(user.password).toBe(password); 97 | expect(fakeUserRepository.create).toHaveBeenCalledWith( 98 | expect.objectContaining({ email, password }), 99 | ); 100 | expect(fakeUserRepository.save).toHaveBeenCalledWith( 101 | expect.objectContaining({ email, password }), 102 | ); 103 | }); 104 | 105 | it('should find all users', async () => { 106 | const users = await usersService.findAll(); 107 | expect(users.length).toBe(userArray.length); 108 | }); 109 | 110 | it('should find one user by id', async () => { 111 | const email = 'test2@example.com'; 112 | const password = 'testpassword2'; 113 | const user = await usersService.create(email, password); 114 | const foundUser = await usersService.findOneById(user.id); 115 | 116 | expect(foundUser).toBeDefined(); 117 | expect(foundUser.email).toBe(email); 118 | expect(fakeUserRepository.findOneBy).toHaveBeenCalledWith({ 119 | id: user.id, 120 | }); 121 | }); 122 | 123 | it('should update a user', async () => { 124 | const email = 'test3@example.com'; 125 | const password = 'testpassword3'; 126 | let user = await usersService.create(email, password); 127 | 128 | const newGender = 'male'; 129 | const updatedUser = await usersService.updateCurrentUser(user.id, { 130 | gender: newGender, 131 | }); 132 | 133 | expect(updatedUser.gender).toBe(newGender); 134 | }); 135 | 136 | it('should update a user by admin (additional fields)', async () => { 137 | const email = 'test5@example.com'; 138 | const password = 'testpassword5'; 139 | let user = await usersService.create(email, password); 140 | 141 | const newEmail = 'updated2@example.com'; 142 | const newRole = 'admin'; 143 | const newIsVIP = true; 144 | 145 | const updatedUser = await usersService.updateUserByAdmin(user.id, { 146 | email: newEmail, 147 | role: newRole, 148 | isVIP: newIsVIP, 149 | }); 150 | 151 | // Check that the email, role and isVIP were updated 152 | expect(updatedUser.email).toBe(newEmail); 153 | expect(updatedUser.role).toBe(newRole); 154 | expect(updatedUser.isVIP).toBe(newIsVIP); 155 | }); 156 | 157 | it('should remove a user if it exists', async () => { 158 | const email = 'test4@example.com'; 159 | const password = 'testpassword4'; 160 | 161 | const user = await usersService.create(email, password); 162 | await usersService.remove(user.id); 163 | expect(fakeUserRepository.remove).toHaveBeenCalled(); 164 | expect(await usersService.findOneById(user.id)).toBeUndefined(); 165 | }); 166 | 167 | it('should throw an error if a user does not exist', async () => { 168 | const nonExistentUserId = 'non-existent-id'; 169 | await expect(usersService.remove(nonExistentUserId)).rejects.toThrow( 170 | NotFoundException, 171 | ); 172 | expect(fakeUserRepository.remove).not.toHaveBeenCalled(); 173 | }); 174 | 175 | it('should deactivate a user', async () => { 176 | const email = 'test5@example.com'; 177 | const password = 'testpassword5'; 178 | const user = await usersService.create(email, password); 179 | console.log(user); 180 | 181 | const deactivatedUser = await usersService.deactivate(user.id); 182 | expect(deactivatedUser).toBeDefined(); 183 | }); 184 | }); 185 | 186 | // // fakeUserRepository = { 187 | // // create: jest.fn().mockImplementation((user) => { 188 | // // user.id = uuidv4(); 189 | // // user.gender = 'other'; 190 | // // user.role = 'user'; 191 | // // return user; 192 | // // }), 193 | 194 | // // save: jest.fn().mockImplementation((user: User | User[]) => { 195 | // // if (Array.isArray(user)) { 196 | // // userArray.push(...user); 197 | // // } else { 198 | // // userArray.push(user); 199 | // // } 200 | // // return Promise.resolve(user); 201 | // // }), 202 | 203 | // // find: () => { 204 | // // return Promise.resolve(userArray); 205 | // // }, 206 | // // findOne: (id) => { 207 | // // const foundUser = userArray.find((user) => user.id === id); 208 | // // return Promise.resolve(foundUser); 209 | // // }, 210 | // // remove: jest.fn().mockImplementation((user: User) => { 211 | // // const index = userArray.findIndex((u) => u.id === user.id); 212 | // // if (index !== -1) { 213 | // // const [removedUser] = userArray.splice(index, 1); 214 | // // return Promise.resolve(removedUser); 215 | // // } 216 | // // return Promise.resolve(undefined); 217 | // // }), 218 | 219 | // // findOneBy: (options) => { 220 | // // const keys = Object.keys(options); 221 | // // const foundUser = userArray.find((user) => { 222 | // // return keys.every((key) => user[key] === options[key]); 223 | // // }); 224 | // // return Promise.resolve(foundUser); 225 | // // }, 226 | // // }; 227 | 228 | // // describe('UsersService', () => { 229 | // // let usersService: UsersService; 230 | // // let fakeUserRepository: Partial>; 231 | 232 | // // const userArray: User[] = []; 233 | 234 | // // beforeEach(async () => { 235 | // // fakeUserRepository = { 236 | // // create: (user) => { 237 | // // userArray.push(user); 238 | // // return user; 239 | // // }, 240 | // // // save: (user: User) => { 241 | // // // userArray.push(user); 242 | // // // return Promise.resolve(user); 243 | // // // }, 244 | // // find: () => { 245 | // // return Promise.resolve(userArray); 246 | // // }, 247 | // // findOne: (options) => { 248 | // // const foundUser = userArray.find(user => user.id === options.where.id); 249 | // // return Promise.resolve(foundUser); 250 | // // }, 251 | // // // remove: (user: User) => { 252 | // // // const index = userArray.indexOf(user); 253 | // // // if (index > -1) { 254 | // // // userArray.splice(index, 1); 255 | // // // } 256 | // // // return Promise.resolve(user); 257 | // // // }, 258 | // // }; 259 | 260 | // // const module = await Test.createTestingModule({ 261 | // // providers: [ 262 | // // UsersService, 263 | // // { 264 | // // provide: getRepositoryToken(User), 265 | // // useValue: fakeUserRepository, 266 | // // }, 267 | // // ], 268 | // // }).compile(); 269 | 270 | // // usersService = module.get(UsersService); 271 | // // }); 272 | // // }); 273 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { Repository } from 'typeorm'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { User, UserRoles, UserStatus } from './entities/user.entity'; 5 | import { AdminUpdateUserDto, UpdateUserDto } from './dtos/update-user.dto'; 6 | import { RefreshToken } from 'src/auth/entities/refresh-token.entity'; 7 | import { UsersQueryDto } from './dtos/user-query.dto'; 8 | import { SortOrder } from 'src/common/enums'; 9 | import { Cache } from 'cache-manager'; 10 | import { CACHE_MANAGER } from '@nestjs/cache-manager'; 11 | import { PaginatedUserDto } from './dtos/paginated-users.dto'; 12 | 13 | export type GoogleProfile = { 14 | email: string; 15 | googleId: string; 16 | picture: string; 17 | displayName: string; 18 | isActivatedWithEmail: boolean; 19 | firstName: string; 20 | provider: string; 21 | }; 22 | 23 | @Injectable() 24 | export class UsersService { 25 | private readonly userCachePrefix = 'user_'; // Prefix for user cache keys 26 | 27 | constructor( 28 | @InjectRepository(User) private repo: Repository, 29 | @InjectRepository(RefreshToken) 30 | private refreshTokenRepo: Repository, 31 | @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, 32 | ) {} 33 | 34 | // Creates a new user with the given email and password 35 | async create(email: string, password: string): Promise { 36 | const user = this.repo.create({ email, password }); 37 | return await this.repo.save(user); 38 | } 39 | 40 | // Retrieves all users from the database 41 | async findAll(queryDto: UsersQueryDto): Promise { 42 | const { page, limit, sortBy, sortOrder, ...filters } = queryDto; 43 | 44 | // Create query builder 45 | const queryBuilder = this.repo.createQueryBuilder('user'); 46 | 47 | // Apply filters, excluding status for special handling 48 | Object.keys(filters).forEach((key) => { 49 | if (key !== 'status' && filters[key]) { 50 | queryBuilder.andWhere(`user.${key} = :${key}`, { [key]: filters[key] }); 51 | } 52 | }); 53 | 54 | // Handle status as a special case, allowing for array-based filtering 55 | if (filters.status) { 56 | if (Array.isArray(filters.status)) { 57 | queryBuilder.andWhere('user.status IN (:...status)', { 58 | status: filters.status, 59 | }); 60 | } else { 61 | queryBuilder.andWhere('user.status = :status', { 62 | status: filters.status, 63 | }); 64 | } 65 | } 66 | 67 | // Apply sorting 68 | const sortField = sortBy || 'createdAt'; // Default sorting field 69 | const sortOrderValue = sortOrder === SortOrder.ASC ? 'ASC' : 'DESC'; 70 | queryBuilder.orderBy(`user.${sortField}`, sortOrderValue); 71 | 72 | // Pagination 73 | const skip = ((page || 1) - 1) * (limit || 10); 74 | queryBuilder.skip(skip).take(limit); 75 | 76 | // Execute the query 77 | const [results, totalItems] = await queryBuilder.getManyAndCount(); 78 | 79 | // Calculate total pages 80 | const totalPages = Math.ceil(totalItems / (limit || 10)); 81 | 82 | return { 83 | data: results, 84 | meta: { 85 | page: page || 1, 86 | pageSize: limit || 10, 87 | totalItems, 88 | totalPages, 89 | }, 90 | }; 91 | } 92 | 93 | // Finds a user by their email address 94 | async findByEmail(email: string): Promise { 95 | return await this.repo.findOneBy({ email }); 96 | } 97 | 98 | // Finds a single user by their unique identifier 99 | async findOneById(id: string): Promise { 100 | const cacheKey = `${this.userCachePrefix}${id}`; 101 | try { 102 | // Attempt to retrieve the user from cache 103 | const cachedItem: User | null = await this.cacheManager.get(cacheKey); 104 | if (cachedItem) { 105 | console.log(`Cache hit for user ID: ${id}`); // Logging cache hit 106 | return cachedItem; 107 | } 108 | console.log(`Cache miss for user ID: ${id}`); // Logging cache miss 109 | 110 | // Cache miss, retrieve the user from the database 111 | const user = await this.repo.findOneBy({ id }); 112 | if (user) { 113 | // Cache the user data for future requests, consider dynamic TTL based on context 114 | await this.cacheManager.set(cacheKey, user, 10000); 115 | } 116 | return user; 117 | } catch (error) { 118 | console.error( 119 | `Cache error for user ID: ${id}, falling back to database. Error: ${error}`, 120 | ); 121 | // On cache error, fallback to database read 122 | return await this.repo.findOneBy({ id }); 123 | } 124 | } 125 | 126 | // Finds a user by their Google ID 127 | async findByGoogleId(googleId: string): Promise { 128 | return await this.repo.findOneBy({ googleId }); 129 | } 130 | 131 | // Creates a new user from a Google profile 132 | async createFromGoogle(profile: GoogleProfile): Promise { 133 | const user = this.repo.create(profile); 134 | return await this.repo.save(user); 135 | } 136 | 137 | // Updates current user information 138 | async updateCurrentUser( 139 | id: string, 140 | attrs: Partial, 141 | ): Promise { 142 | const user = await this.repo.preload({ id, ...attrs }); 143 | if (!user) { 144 | throw new NotFoundException('User not found'); 145 | } 146 | await this.repo.save(user); 147 | 148 | // Invalidate the cache upon user update to ensure consistency 149 | const cacheKey = `${this.userCachePrefix}${id}`; 150 | await this.cacheManager.del(cacheKey); 151 | console.log(`Cache invalidated for user ID: ${id}`); // Logging cache invalidation 152 | 153 | return user; 154 | } 155 | 156 | // Allows admin to update a user's information 157 | async updateUserByAdmin( 158 | id: string, 159 | attrs: Partial, 160 | ): Promise { 161 | const user = await this.repo.preload({ id, ...attrs }); 162 | if (!user) { 163 | throw new NotFoundException('User not found'); 164 | } 165 | return await this.repo.save(user); 166 | } 167 | 168 | async update(id: string, updateData: Partial): Promise { 169 | return await this.repo.update(id, updateData); 170 | } 171 | 172 | // Removes a user from the database 173 | async remove(id: string): Promise { 174 | const user = await this.findOneById(id); 175 | if (!user) { 176 | throw new NotFoundException('User not found'); 177 | } 178 | return await this.repo.remove(user); 179 | } 180 | 181 | // Deactivates a user's account 182 | async deactivate(id: string): Promise { 183 | const user = await this.repo.findOne({ where: { id } }); 184 | if (!user) { 185 | throw new NotFoundException('User not found'); 186 | } 187 | user.status = UserStatus.Inactive; // Set the user's status to inactive 188 | await this.repo.save(user); // Save the changes to the database 189 | } 190 | 191 | // Finds a user by their password reset token 192 | async findByResetToken(passwordResetCode: string): Promise { 193 | return await this.repo.findOneBy({ passwordResetCode }); 194 | } 195 | 196 | async banUser(id: string): Promise { 197 | // Retrieve the user from the database 198 | const user = await this.repo.findOneBy({ id }); 199 | 200 | if (!user) { 201 | throw new NotFoundException('User not found'); 202 | } 203 | 204 | // Update the user's status and token version 205 | user.status = UserStatus.Blocked; 206 | user.tokenVersion += 1; 207 | await this.repo.save(user); 208 | 209 | // Delete refresh tokens 210 | await this.refreshTokenRepo.delete({ user: user }); 211 | } 212 | 213 | async assignRole(userId: string, role: UserRoles): Promise { 214 | const user = await this.repo.findOneBy({ id: userId }); 215 | if (!user) { 216 | throw new Error('User not found'); 217 | } 218 | user.role = role; 219 | await this.repo.save(user); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import dataSource, { closeDatabase, initializeDatabase } from '../db-config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import { AppModule } from './../src/app.module'; 5 | import * as request from 'supertest'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeAll(async () => { 11 | await initializeDatabase(); 12 | }); 13 | 14 | beforeEach(async () => { 15 | const moduleFixture: TestingModule = await Test.createTestingModule({ 16 | imports: [AppModule], 17 | }).compile(); 18 | 19 | app = moduleFixture.createNestApplication(); 20 | await app.init(); 21 | }); 22 | 23 | afterEach(async () => { 24 | await app.close(); 25 | }); 26 | 27 | afterAll(async () => { 28 | await closeDatabase(); 29 | }); 30 | 31 | it('/info (GET)', (done) => { 32 | request(app.getHttpServer()) 33 | .get('/info') 34 | .expect(200) 35 | .end((err, res) => { 36 | if (err) return done(err); 37 | 38 | expect(res.body).toHaveProperty('name'); 39 | expect(res.body).toHaveProperty('version'); 40 | expect(res.body).toHaveProperty('description'); 41 | expect(res.body).toHaveProperty('web'); 42 | expect(res.body).toHaveProperty('mobile'); 43 | 44 | done(); 45 | }); 46 | }); 47 | 48 | it('/health (GET)', (done) => { 49 | request(app.getHttpServer()) 50 | .get('/health') 51 | .expect(200) 52 | .end((err, res) => { 53 | if (err) return done(err); 54 | 55 | expect(res.body).toHaveProperty('status'); 56 | expect(res.body).toHaveProperty('info'); 57 | expect(res.body).toHaveProperty('error'); 58 | 59 | done(); 60 | }); 61 | }); 62 | }); 63 | 64 | // describe('AppController (e2e)', () => { 65 | // let app; 66 | 67 | // beforeEach(async () => { 68 | // // await dataSource.query('DROP TABLE IF EXISTS "brands" CASCADE'); 69 | 70 | // const moduleFixture: TestingModule = await Test.createTestingModule({ 71 | // imports: [AppModule], 72 | // }).compile(); 73 | 74 | // app = moduleFixture.createNestApplication(); 75 | // await app.init(); 76 | // }); 77 | 78 | // afterEach(async () => { 79 | // await dataSource.close(); 80 | // }); 81 | 82 | // // afterAll(async () => { 83 | // // await global.teardown(); 84 | // // }); 85 | 86 | // it('/info (GET)', async () => { 87 | // return request(app.getHttpServer()) 88 | // .get('/info') 89 | // .expect(200) 90 | // .then((response) => { 91 | // expect(response.body).toHaveProperty('message'); 92 | // expect(response.body.message).toBe("This action returns app's info"); 93 | // }); 94 | // }); 95 | 96 | // it('/health (GET)', async () => { 97 | // return request(app.getHttpServer()) 98 | // .get('/health') 99 | // .expect(200) 100 | // .then((response) => { 101 | // expect(response.body).toHaveProperty('status'); 102 | // expect(response.body.status).toBe('up'); 103 | // }); 104 | // }); 105 | // }); 106 | -------------------------------------------------------------------------------- /test/auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { AppModule } from '../src/app.module'; 4 | import * as request from 'supertest'; 5 | // import { getConnection } from 'typeorm'; 6 | // import { User } from '../src/users/entities/user.entity'; 7 | 8 | describe('Authentication System (e2e)', () => { 9 | let app: INestApplication; 10 | 11 | beforeEach(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule], 14 | }).compile(); 15 | 16 | app = moduleFixture.createNestApplication(); 17 | await app.init(); 18 | }); 19 | 20 | afterAll(async () => { 21 | await app.close(); 22 | }); 23 | 24 | it('handles a signup request', () => { 25 | const email = `test.email.${ 26 | Math.floor(Math.random() * 9000) + 1000 27 | }@test.coom`; 28 | // return request(global.app.getHttpServer()) 29 | return request(app.getHttpServer()) 30 | .post('/auth/signup') 31 | .send({ email, password: 'password' }) 32 | .expect(201) 33 | .then((res) => { 34 | const { user, token } = res.body; 35 | // console.log(res.body); 36 | expect(user).toBeDefined(); 37 | expect(token).toBeDefined(); 38 | expect(user.id).toBeDefined(); 39 | expect(user.email).toEqual(email); 40 | }); 41 | }); 42 | 43 | //re-check this one when it works 44 | it('signup as a new user then get the currently logged in user', async () => { 45 | const email = `test.email.${ 46 | Math.floor(Math.random() * 9000) + 1000 47 | }@test.coom`; 48 | const res = request(app.getHttpServer()) 49 | .post('/auth/signup') 50 | .send({ email, password: 'password' }) 51 | .expect(201); 52 | 53 | const { token } = (await res).body; 54 | 55 | const { body } = await request(app.getHttpServer()) 56 | .get('/auth/whoami') 57 | .set('Authorization', `Bearer ${token}`) 58 | .expect(200); 59 | 60 | expect(body.id).toBeDefined(); 61 | expect(body.email).toEqual(email); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/global.test.ts: -------------------------------------------------------------------------------- 1 | // global.setup.ts 2 | import { INestApplication } from '@nestjs/common'; 3 | import { Connection } from 'typeorm'; 4 | import { testSetup, testTeardown } from './test.setup'; 5 | 6 | declare global { 7 | namespace NodeJS { 8 | interface Global { 9 | app: INestApplication; 10 | connection: Connection; 11 | } 12 | } 13 | } 14 | 15 | beforeEach(async () => { 16 | const testApp = await testSetup(); 17 | global.app = testApp.app; 18 | global.connection = testApp.connection; 19 | }); 20 | 21 | afterEach(async () => { 22 | await testTeardown(global.connection); 23 | }); 24 | -------------------------------------------------------------------------------- /test/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$": [ 8 | "ts-jest", 9 | { 10 | "tsconfig": "tsconfig.test.json" 11 | } 12 | ] 13 | }, 14 | "testTimeout": 15000, 15 | "moduleNameMapper": { 16 | "^db-config/(.*)$": "/src/db-config/$1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/test-db.config.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; 4 | 5 | @Injectable() 6 | export class TestDbConfig implements TypeOrmOptionsFactory { 7 | constructor(private readonly configService: ConfigService) {} 8 | 9 | createTypeOrmOptions(): TypeOrmModuleOptions { 10 | return { 11 | name: 'test', // Use a different connection name for test environment 12 | type: 'postgres', 13 | host: this.configService.get('DB_HOST'), 14 | port: parseInt(this.configService.get('DB_PORT'), 10), 15 | database: this.configService.get('DB_DATABASE'), 16 | username: this.configService.get('DB_USERNAME'), 17 | password: this.configService.get('DB_PASSWORD'), 18 | entities: [__dirname + '/../**/*.entity{.ts,.js}'], 19 | synchronize: true, 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/test.setup.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Connection } from 'typeorm'; 4 | import { AppModule } from '../src/app.module'; 5 | import { TestDbConfig } from './test-db.config'; 6 | 7 | export const testSetup = async () => { 8 | const moduleRef: TestingModule = await Test.createTestingModule({ 9 | imports: [ 10 | AppModule, 11 | TypeOrmModule.forRootAsync({ 12 | name: 'test', 13 | useClass: TestDbConfig, 14 | }), 15 | ], 16 | }).compile(); 17 | 18 | const app = moduleRef.createNestApplication(); 19 | await app.init(); 20 | 21 | const connection = app.get(Connection); 22 | await connection.runMigrations(); // Run migrations to set up the test database 23 | 24 | return { app, connection }; 25 | }; 26 | 27 | export const testTeardown = async (connection: Connection) => { 28 | await connection.dropDatabase(); // Drop the test database 29 | await connection.close(); // Close the connection 30 | }; 31 | -------------------------------------------------------------------------------- /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 | "allowJs": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "db-config/*": ["src/db-config/*"] 7 | } 8 | } 9 | } 10 | --------------------------------------------------------------------------------