From 73f7af3146c28599e184fc23add5b55bd9e23894 Mon Sep 17 00:00:00 2001 From: radmehr Date: Sat, 9 May 2026 09:00:25 +0330 Subject: [PATCH] feat: add refresh token and global exception filter --- package-lock.json | 88 +++++++++++++++++++ package.json | 1 + src/app.module.ts | 17 +++- src/common/exceptions/app.exception.ts | 11 +++ src/common/filters/global-exception.filter.ts | 55 ++++++++++++ .../redis/constants/redis.constants.ts | 1 + .../modules/redis/providers/redis.service.ts | 41 +++++++++ src/common/modules/redis/redis.module.ts | 25 ++++++ src/config/environment.validation.ts | 2 + src/config/redis.config.ts | 6 ++ src/i18n/en/auth.json | 10 +++ src/i18n/en/common.json | 7 ++ src/i18n/en/users.json | 3 +- src/i18n/fa/auth.json | 10 +++ src/i18n/fa/common.json | 7 ++ src/i18n/fa/users.json | 3 +- src/main.ts | 13 ++- src/modules/auth/auth.controller.ts | 10 ++- src/modules/auth/auth.module.ts | 6 ++ src/modules/auth/dtos/refresh-token.dto.ts | 7 ++ .../auth/http/login.post.endpoints.http | 5 +- .../auth/http/refresh.post.endpoints.http | 6 ++ .../auth/http/verify-otp.post.endpoints.http | 6 +- .../auth/providers/auth-redis.provider.ts | 24 +++++ src/modules/auth/providers/auth.service.ts | 11 +++ .../auth/providers/generate-token.provider.ts | 13 +++ src/modules/auth/providers/login.provider.ts | 18 ++-- .../auth/providers/refresh-tokens.provider.ts | 77 ++++++++++++++++ src/modules/auth/strategies/jwt.strategy.ts | 2 +- src/modules/otp/providers/otp.service.ts | 27 +++--- .../users/http/users.get.endpoints.http | 2 +- src/modules/users/providers/users.service.ts | 40 +++++++-- 32 files changed, 512 insertions(+), 42 deletions(-) create mode 100644 src/common/exceptions/app.exception.ts create mode 100644 src/common/filters/global-exception.filter.ts create mode 100644 src/common/modules/redis/constants/redis.constants.ts create mode 100644 src/common/modules/redis/providers/redis.service.ts create mode 100644 src/common/modules/redis/redis.module.ts create mode 100644 src/config/redis.config.ts create mode 100644 src/i18n/en/common.json create mode 100644 src/i18n/fa/common.json create mode 100644 src/modules/auth/dtos/refresh-token.dto.ts create mode 100644 src/modules/auth/http/refresh.post.endpoints.http create mode 100644 src/modules/auth/providers/auth-redis.provider.ts create mode 100644 src/modules/auth/providers/refresh-tokens.provider.ts diff --git a/package-lock.json b/package-lock.json index ab030b6..f678fda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "ioredis": "^5.10.1", "joi": "^18.0.2", "nestjs-i18n": "^10.6.0", "passport": "^0.7.0", @@ -1378,6 +1379,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4417,6 +4424,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4809,6 +4825,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6203,6 +6228,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7406,11 +7455,23 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://mirror-npm.runflare.com/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://mirror-npm.runflare.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -8732,6 +8793,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9230,6 +9312,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index ae7d2c1..dd199ee 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "ioredis": "^5.10.1", "joi": "^18.0.2", "nestjs-i18n": "^10.6.0", "passport": "^0.7.0", diff --git a/src/app.module.ts b/src/app.module.ts index bd23b60..c749cc8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,8 +9,13 @@ import { HeaderResolver, I18nJsonLoader, I18nModule } from 'nestjs-i18n'; import { AuthModule } from './modules/auth/auth.module'; import { OtpModule } from './modules/otp/otp.module'; import { HashingModule } from './common/modules/hashing/hashing.module'; +import { RedisModule } from './common/modules/redis/redis.module'; import path from 'path'; import jwtConfig from './config/jwt.config'; +import redisConfig from './config/redis.config'; +import { APP_FILTER } from '@nestjs/core'; +import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; +import { ScheduleModule } from '@nestjs/schedule'; const ENV = process.env.NODE_ENV; @@ -35,7 +40,7 @@ const ENV = process.env.NODE_ENV; ConfigModule.forRoot({ isGlobal: true, envFilePath: !ENV ? '.env' : `.env.${ENV}`, - load: [appConfig, databaseConfig, jwtConfig], + load: [appConfig, databaseConfig, jwtConfig, redisConfig], validationSchema: environmentValidation, }), @@ -55,6 +60,9 @@ const ENV = process.env.NODE_ENV; }), }), + // Schedule + ScheduleModule.forRoot(), + /** * Local Modules */ @@ -62,6 +70,13 @@ const ENV = process.env.NODE_ENV; AuthModule, OtpModule, HashingModule, + RedisModule, + ], + providers: [ + { + provide: APP_FILTER, + useClass: GlobalExceptionFilter, + }, ], }) export class AppModule {} diff --git a/src/common/exceptions/app.exception.ts b/src/common/exceptions/app.exception.ts new file mode 100644 index 0000000..6e44fa1 --- /dev/null +++ b/src/common/exceptions/app.exception.ts @@ -0,0 +1,11 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class AppException extends HttpException { + constructor( + public messageKey: string, + status: HttpStatus, + public params?: Record, + ) { + super({ message: messageKey, params }, status); + } +} diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..7c24442 --- /dev/null +++ b/src/common/filters/global-exception.filter.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { I18nService } from 'nestjs-i18n'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + constructor( + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, + ) {} + + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const isHttpException = exception instanceof HttpException; + + const status = isHttpException ? exception.getStatus() : 500; + + const exceptionResponse = isHttpException + ? exception.getResponse() + : 'common.errors.internalServerError'; + + const messageKey = + typeof exceptionResponse === 'string' + ? exceptionResponse + : (exceptionResponse as any).message; + + const message = this.i18nService.translate(messageKey as string, { + args: (exceptionResponse as any).params, + }); + + response.status(status).json({ + success: false, + statusCode: status, + message, + path: request.url, + timestamp: new Date().toISOString(), + ...(process.env.NODE_ENV !== 'production' && { + raw: exceptionResponse, + stack: exception instanceof Error ? exception.stack : undefined, + }), + }); + } +} diff --git a/src/common/modules/redis/constants/redis.constants.ts b/src/common/modules/redis/constants/redis.constants.ts new file mode 100644 index 0000000..bd3b02b --- /dev/null +++ b/src/common/modules/redis/constants/redis.constants.ts @@ -0,0 +1 @@ +export const REDIS = 'REDIS_CLIENT'; diff --git a/src/common/modules/redis/providers/redis.service.ts b/src/common/modules/redis/providers/redis.service.ts new file mode 100644 index 0000000..4f391fb --- /dev/null +++ b/src/common/modules/redis/providers/redis.service.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { Inject, Injectable } from '@nestjs/common'; +import { REDIS } from '../constants/redis.constants'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService { + constructor( + /** + * Inject Redis + */ + @Inject(REDIS) + private readonly redis: Redis, + ) {} + + get client() { + return this.redis; + } + + async set(key: string, val: string, ttl?: number) { + if (ttl) { + await this.redis.set(key, val, 'EX', ttl); + } else { + await this.redis.set(key, val); + } + } + + async get(key: string) { + return await this.redis.get(key); + } + + async del(key: string) { + return await this.redis.del(key); + } + + async onModuleDestroy() { + await this.redis.quit(); + } +} diff --git a/src/common/modules/redis/redis.module.ts b/src/common/modules/redis/redis.module.ts new file mode 100644 index 0000000..cd9fe94 --- /dev/null +++ b/src/common/modules/redis/redis.module.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { Module } from '@nestjs/common'; +import { RedisService } from './providers/redis.service'; +import { REDIS } from './constants/redis.constants'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Module({ + providers: [ + { + provide: REDIS, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + return new Redis({ + host: configService.get('redis.host'), + port: configService.get('redis.port'), + }); + }, + }, + RedisService, + ], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/src/config/environment.validation.ts b/src/config/environment.validation.ts index 36b9324..890d35e 100644 --- a/src/config/environment.validation.ts +++ b/src/config/environment.validation.ts @@ -17,4 +17,6 @@ export default Joi.object({ JWT_REFRESH_TTL: Joi.number().required(), JWT_ISSUER: Joi.string().required(), JWT_AUDIENCE: Joi.string().required(), + REDIS_HOST: Joi.string().required(), + REDIS_PORT: Joi.number().port().default(6379), }); diff --git a/src/config/redis.config.ts b/src/config/redis.config.ts new file mode 100644 index 0000000..ac234db --- /dev/null +++ b/src/config/redis.config.ts @@ -0,0 +1,6 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('redis', () => ({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), +})); diff --git a/src/i18n/en/auth.json b/src/i18n/en/auth.json index 52797da..86f4ebc 100644 --- a/src/i18n/en/auth.json +++ b/src/i18n/en/auth.json @@ -3,5 +3,15 @@ "phone": "Phone", "password": "Password", "otp": "Otp" + }, + "errors": { + "wrongPhoneOrPass": "Entered phone number or password is wrong.", + "unauthorized": "Unauthorized access", + "tokenReuse": "Token reuse detected.", + "invalidOTP": "OTP is invalid or expired." + }, + "messages": { + "otpSent": "OTP sent to {phone}.", + "sendPass": "Please send password." } } diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json new file mode 100644 index 0000000..62bbc6e --- /dev/null +++ b/src/i18n/en/common.json @@ -0,0 +1,7 @@ +{ + "errors": { + "internalServerError": "Internal Server Error", + "tooManyRequests": "Too many requests! Try again after {minutes} min.", + "requestTimeout": "Unable to process your request at the moment." + } +} diff --git a/src/i18n/en/users.json b/src/i18n/en/users.json index 8cdf42f..5496e7b 100644 --- a/src/i18n/en/users.json +++ b/src/i18n/en/users.json @@ -6,6 +6,7 @@ "password": "Password" }, "errors": { - "passwordPattern": "Password should contain atleast 8 characters, including letters, numbers and symbols." + "passwordPattern": "Password should contain atleast 8 characters, including letters, numbers and symbols.", + "userNotExist": "User does not exist." } } diff --git a/src/i18n/fa/auth.json b/src/i18n/fa/auth.json index a6c719f..5f84bc2 100644 --- a/src/i18n/fa/auth.json +++ b/src/i18n/fa/auth.json @@ -3,5 +3,15 @@ "phone": "شماره موبایل", "password": "رمز عبور", "otp": "کد تایید" + }, + "errors": { + "wrongPhoneOrPass": "شماره موبایل یا رمز عبور اشتباه است.", + "unauthorized": "دسترسی غیر مجاز", + "tokenReuse": "توکن قبلا استفاده شده.", + "invalidOTP": "کد تایید اشتباه است یا منقضی شده." + }, + "messages": { + "otpSent": "کد تایید به شماره {phone} ارسال شد.", + "sendPass": "لطفا رمز عبور را ارسال کنید." } } diff --git a/src/i18n/fa/common.json b/src/i18n/fa/common.json new file mode 100644 index 0000000..ac16648 --- /dev/null +++ b/src/i18n/fa/common.json @@ -0,0 +1,7 @@ +{ + "errors": { + "internalServerError": "خطایی رخ داد", + "tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.", + "requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد." + } +} diff --git a/src/i18n/fa/users.json b/src/i18n/fa/users.json index 25dc92c..4a2f91f 100644 --- a/src/i18n/fa/users.json +++ b/src/i18n/fa/users.json @@ -6,6 +6,7 @@ "password": "رمز عبور" }, "errors": { - "passwordPattern": "رمز عبور باید حداقل شامل 8 کاراکتر شامل حروف، اعداد و نشانه ها باشد." + "passwordPattern": "رمز عبور باید حداقل شامل 8 کاراکتر شامل حروف، اعداد و نشانه ها باشد.", + "userNotExist": "کاربر مورد نظر وجود ندارد." } } diff --git a/src/main.ts b/src/main.ts index 1cbf649..a5dc91a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { I18nValidationPipe, I18nValidationExceptionFilter } from 'nestjs-i18n'; +import { I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; async function bootstrap() { @@ -17,7 +17,16 @@ async function bootstrap() { }), ); - app.useGlobalFilters(new I18nValidationExceptionFilter()); + app.useGlobalFilters( + new I18nValidationExceptionFilter({ + errorFormatter: (errors) => { + return errors.map((err) => ({ + field: err.property, + errors: Object.values(err.constraints ?? {}), + })); + }, + }), + ); const config = new DocumentBuilder() .setTitle('Borna API') diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 2d8f0d9..8e08488 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -3,8 +3,9 @@ import { LoginDTO } from './dtos/login.dto'; import { AuthService } from './providers/auth.service'; import { VerifyOtpDTO } from './dtos/verify-otp.dto'; import { Public } from './decorators/public.decorator'; +import { RefreshTokenDTO } from './dtos/refresh-token.dto'; -@Controller('users') +@Controller('auth') export class AuthController { constructor( /** @@ -26,4 +27,11 @@ export class AuthController { public async verifyOTP(@Body() verifyOtpDto: VerifyOtpDTO) { return await this.authService.verifyOTP(verifyOtpDto); } + + @Public() + @Post('refresh') + @HttpCode(200) + public async refreshToken(@Body() refreshTokenDto: RefreshTokenDTO) { + return await this.authService.refreshToken(refreshTokenDto); + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1f9aee3..4f9e2b2 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -12,12 +12,16 @@ import { VerifyOTPProvider } from './providers/verify-otp.provider'; import { JwtStrategy } from './strategies/jwt.strategy'; import { APP_GUARD } from '@nestjs/core'; import { GlobalAuthGuard } from './guards/global-auth.guard'; +import { RefreshTokensProvider } from './providers/refresh-tokens.provider'; +import { RedisModule } from '@/common/modules/redis/redis.module'; +import { AuthRedisProvider } from './providers/auth-redis.provider'; @Module({ imports: [ UsersModule, OtpModule, HashingModule, + RedisModule, JwtModule.registerAsync({ inject: [ConfigService], useFactory: (configService: ConfigService) => ({ @@ -37,6 +41,8 @@ import { GlobalAuthGuard } from './guards/global-auth.guard'; LoginProvider, VerifyOTPProvider, JwtStrategy, + RefreshTokensProvider, + AuthRedisProvider, { provide: APP_GUARD, useClass: GlobalAuthGuard, diff --git a/src/modules/auth/dtos/refresh-token.dto.ts b/src/modules/auth/dtos/refresh-token.dto.ts new file mode 100644 index 0000000..48b4ddf --- /dev/null +++ b/src/modules/auth/dtos/refresh-token.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RefreshTokenDTO { + @IsNotEmpty() + @IsString() + refreshToken: string; +} diff --git a/src/modules/auth/http/login.post.endpoints.http b/src/modules/auth/http/login.post.endpoints.http index 1b344ed..b1fa493 100644 --- a/src/modules/auth/http/login.post.endpoints.http +++ b/src/modules/auth/http/login.post.endpoints.http @@ -1,6 +1,7 @@ -POST http://localhost:3000/users/login +POST http://localhost:3000/auth/login Content-Type: application/json +lang: fa { - "phone": "09121111111" + "phone": "09121111114" } \ No newline at end of file diff --git a/src/modules/auth/http/refresh.post.endpoints.http b/src/modules/auth/http/refresh.post.endpoints.http new file mode 100644 index 0000000..0a2aa8f --- /dev/null +++ b/src/modules/auth/http/refresh.post.endpoints.http @@ -0,0 +1,6 @@ +POST http://localhost:3000/auth/refresh +Content-Type: application/json + +{ + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjksImp0aSI6IjljOTY5OWRhLWI0NTgtNDc0Ny04YTRkLTQyODQxMDllOTVkZCIsImlhdCI6MTc3ODMwNDMyNSwiZXhwIjoxNzc5MTY4MzI1LCJhdWQiOiJsb2NhbGhvc3QiLCJpc3MiOiJsb2NhbGhvc3QifQ.oiHuj3vJRQl7rG8H0fFEfU9cG91KrLGdXqNAYFGTXVA" +} diff --git a/src/modules/auth/http/verify-otp.post.endpoints.http b/src/modules/auth/http/verify-otp.post.endpoints.http index 4605cb8..3d426ee 100644 --- a/src/modules/auth/http/verify-otp.post.endpoints.http +++ b/src/modules/auth/http/verify-otp.post.endpoints.http @@ -1,7 +1,7 @@ -POST http://localhost:3000/users/verify-otp +POST http://localhost:3000/auth/verify-otp Content-Type: application/json { - "phone": "09121111111", - "otp": "83793" + "phone": "09121111114", + "otp": "99696" } \ No newline at end of file diff --git a/src/modules/auth/providers/auth-redis.provider.ts b/src/modules/auth/providers/auth-redis.provider.ts new file mode 100644 index 0000000..a9594d4 --- /dev/null +++ b/src/modules/auth/providers/auth-redis.provider.ts @@ -0,0 +1,24 @@ +import { RedisService } from '@/common/modules/redis/providers/redis.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AuthRedisProvider { + constructor( + /** + * Injecting Redis Service + */ + private readonly redisService: RedisService, + ) {} + + async storeRefreshToken(jti: string, userId: number, ttl: number) { + await this.redisService.set(`rt:${jti}`, userId.toString(), ttl); + } + + async hasRefreshToken(jti: string) { + return !!(await this.redisService.get(`rt:${jti}`)); + } + + async revokeRefreshToken(jti: string) { + await this.redisService.del(`rt:${jti}`); + } +} diff --git a/src/modules/auth/providers/auth.service.ts b/src/modules/auth/providers/auth.service.ts index 077aa13..717ffaf 100644 --- a/src/modules/auth/providers/auth.service.ts +++ b/src/modules/auth/providers/auth.service.ts @@ -3,6 +3,8 @@ import { LoginDTO } from '../dtos/login.dto'; import { LoginProvider } from './login.provider'; import { VerifyOtpDTO } from '../dtos/verify-otp.dto'; import { VerifyOTPProvider } from './verify-otp.provider'; +import { RefreshTokenDTO } from '../dtos/refresh-token.dto'; +import { RefreshTokensProvider } from './refresh-tokens.provider'; @Injectable() export class AuthService { @@ -16,6 +18,11 @@ export class AuthService { * Inject Verify OTP Provider */ private readonly verifyOTPProvider: VerifyOTPProvider, + + /** + * Inject RefreshToken Provider + */ + private readonly refreshTokensProvider: RefreshTokensProvider, ) {} public async logIn(loginDto: LoginDTO) { @@ -25,4 +32,8 @@ export class AuthService { public async verifyOTP(verifyOtpDto: VerifyOtpDTO) { return await this.verifyOTPProvider.verifyOTP(verifyOtpDto); } + + public async refreshToken(refreshTokenDto: RefreshTokenDTO) { + return await this.refreshTokensProvider.refreshTokens(refreshTokenDto); + } } diff --git a/src/modules/auth/providers/generate-token.provider.ts b/src/modules/auth/providers/generate-token.provider.ts index ca59382..c86a8b3 100644 --- a/src/modules/auth/providers/generate-token.provider.ts +++ b/src/modules/auth/providers/generate-token.provider.ts @@ -7,6 +7,7 @@ import { AccessTokenPayload, RefreshTokenPayload, } from '../interfaces/jwt.interface'; +import { AuthRedisProvider } from './auth-redis.provider'; @Injectable() export class GenerateTokenProvider { @@ -21,6 +22,11 @@ export class GenerateTokenProvider { */ @Inject(jwtConfig.KEY) private readonly jwtConfiguration: ConfigType, + + /** + * Inject AuthRedis Provider + */ + private readonly authRedisProvider: AuthRedisProvider, ) {} public async signToken( @@ -60,6 +66,13 @@ export class GenerateTokenProvider { this.jwtConfiguration.refresh.secret, { jti }, ), + + // Store Refresh JTI in redis + this.authRedisProvider.storeRefreshToken( + jti, + user.id, + this.jwtConfiguration.refresh.expiresIn, + ), ]); return { access, refresh }; diff --git a/src/modules/auth/providers/login.provider.ts b/src/modules/auth/providers/login.provider.ts index de49d2c..ec22406 100644 --- a/src/modules/auth/providers/login.provider.ts +++ b/src/modules/auth/providers/login.provider.ts @@ -5,6 +5,7 @@ import { OtpService } from '@/modules/otp/providers/otp.service'; import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider'; import { GenerateTokenProvider } from './generate-token.provider'; import { randomUUID } from 'crypto'; +import { I18nService } from 'nestjs-i18n'; @Injectable() export class LoginProvider { @@ -28,6 +29,11 @@ export class LoginProvider { * Inject GenerateToken Provider */ private readonly generateTokenProvider: GenerateTokenProvider, + + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, ) {} public async logIn(loginDto: LoginDTO) { @@ -38,9 +44,13 @@ export class LoginProvider { // TODO: Send OTP const otpCode = await this.otpService.createOTP(phone); + const message = this.i18nService.translate('auth.messages.otpSent', { + args: { phone }, + }); + return { newUser: true, - message: `Otp sent to ${phone}`, + message, // Just for development code: otpCode, }; @@ -49,7 +59,7 @@ export class LoginProvider { if (!password) { return { newUser: false, - message: 'Please send password', + message: this.i18nService.translate('auth.messages.sendPass'), }; } @@ -63,8 +73,6 @@ export class LoginProvider { return await this.generateTokenProvider.generateTokens(user, jti); } - throw new UnauthorizedException( - 'Entered phone number or password is wrong.', - ); + throw new UnauthorizedException('auth.errors.wrongPhoneOrPass'); } } diff --git a/src/modules/auth/providers/refresh-tokens.provider.ts b/src/modules/auth/providers/refresh-tokens.provider.ts new file mode 100644 index 0000000..4fc660b --- /dev/null +++ b/src/modules/auth/providers/refresh-tokens.provider.ts @@ -0,0 +1,77 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthRedisProvider } from './auth-redis.provider'; +import { RefreshTokenDTO } from '../dtos/refresh-token.dto'; +import { JwtService } from '@nestjs/jwt'; +import type { ConfigType } from '@nestjs/config'; +import jwtConfig from '@/config/jwt.config'; +import { RefreshTokenPayload } from '../interfaces/jwt.interface'; +import { UsersService } from '@/modules/users/providers/users.service'; +import { randomUUID } from 'crypto'; +import { GenerateTokenProvider } from './generate-token.provider'; + +@Injectable() +export class RefreshTokensProvider { + constructor( + /** + * Inject JwtService + */ + private readonly jwtService: JwtService, + + /** + * Inject jwtConfig + */ + @Inject(jwtConfig.KEY) + private readonly jwtConfiguration: ConfigType, + + /** + * Inject Users Service + */ + private readonly usersService: UsersService, + + /** + * Inject AuthRedis Provider + */ + private readonly authRedisProvider: AuthRedisProvider, + + /** + * Inject GenerateToken Provider + */ + private readonly generateTokenProvider: GenerateTokenProvider, + ) {} + + public async refreshTokens(refreshTokenDto: RefreshTokenDTO) { + try { + const payload = await this.jwtService.verifyAsync( + refreshTokenDto.refreshToken, + { + secret: this.jwtConfiguration.refresh.secret, + issuer: this.jwtConfiguration.issuer, + audience: this.jwtConfiguration.audience, + }, + ); + + const user = await this.usersService.findOneById(payload.sub); + + if (!payload.jti) { + throw new UnauthorizedException('auth.errors.unauthorized'); + } + + const existsInRedis = await this.authRedisProvider.hasRefreshToken( + payload.jti, + ); + + if (!existsInRedis) { + throw new UnauthorizedException('auth.errors.tokenReuse'); + } + + await this.authRedisProvider.revokeRefreshToken(payload.jti); + + const newJti = randomUUID(); + return await this.generateTokenProvider.generateTokens(user, newJti); + } catch (err) { + throw new UnauthorizedException('auth.errors.unauthorized', { + cause: err, + }); + } + } +} diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index c4dc1c1..529243d 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { const user = await this.usersService.findOneByPhone(payload.phone); if (!user) { - throw new UnauthorizedException(); + throw new UnauthorizedException('auth.errors.unauthorized'); } return user; diff --git a/src/modules/otp/providers/otp.service.ts b/src/modules/otp/providers/otp.service.ts index 53a9b14..9e76498 100644 --- a/src/modules/otp/providers/otp.service.ts +++ b/src/modules/otp/providers/otp.service.ts @@ -1,15 +1,15 @@ import { - HttpException, HttpStatus, Injectable, RequestTimeoutException, UnauthorizedException, } from '@nestjs/common'; -import { LessThan, Repository } from 'typeorm'; +import { LessThan, MoreThanOrEqual, Repository } from 'typeorm'; import { OTP } from '../otp.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { Cron } from '@nestjs/schedule'; import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider'; +import { AppException } from '@/common/exceptions/app.exception'; @Injectable() export class OtpService { @@ -44,9 +44,10 @@ export class OtpService { }); if (lastOTP && Date.now() - lastOTP.createdAt.getTime() < 60_000) { - throw new HttpException( - 'Too many requests! Try again after 1 min.', + throw new AppException( + 'common.errors.tooManyRequests', HttpStatus.TOO_MANY_REQUESTS, + { minutes: 1 }, ); } @@ -68,26 +69,22 @@ export class OtpService { try { otp = await this.otpRepository.findOne({ - where: { phone, used: false }, + where: { phone, used: false, expiresAt: MoreThanOrEqual(new Date()) }, order: { createdAt: 'DESC' }, }); } catch (err) { - throw new RequestTimeoutException( - 'Unable to process your request at the moment. Please try again later.', - { cause: err, description: 'Error connecting to the database' }, - ); + throw new RequestTimeoutException('common.errors.requestTimeout', { + cause: err, + description: 'Error connecting to the database', + }); } - if (!otp) throw new UnauthorizedException('Invalid OTP'); - - if (otp.expiresAt < new Date()) { - throw new UnauthorizedException('OTP expired'); - } + if (!otp) throw new UnauthorizedException('auth.errors.invalidOTP'); const isOTPCorrect = await this.hashingProvider.compare(code, otp.code); if (!isOTPCorrect) { - throw new UnauthorizedException('Invalid OTP'); + throw new UnauthorizedException('auth.errors.invalidOTP'); } otp.used = true; diff --git a/src/modules/users/http/users.get.endpoints.http b/src/modules/users/http/users.get.endpoints.http index affe635..24c8592 100644 --- a/src/modules/users/http/users.get.endpoints.http +++ b/src/modules/users/http/users.get.endpoints.http @@ -1,2 +1,2 @@ GET http://localhost:3000/users/profile -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQsInBob25lIjoiMDkxMjYxMTcwMTgiLCJpYXQiOjE3NzIxMjc1MTgsImV4cCI6MTc3MjEzMTExOCwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.oaxzSd0DDakCDMiW129Ou3MJh0oT0zJTFerZEjnQ7Ug +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjYsInBob25lIjoiMDkxMjExMTExMTEiLCJpYXQiOjE3Nzc5NzU3NjQsImV4cCI6MTc3Nzk3OTM2NCwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.m3sIOApct0GYBNQ7TuWjf7wo2ZCvu-3vPYeWEHTuHMc diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index b3fd433..b71bad7 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -1,4 +1,8 @@ -import { Injectable, RequestTimeoutException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + RequestTimeoutException, +} from '@nestjs/common'; import { Repository } from 'typeorm'; import { User } from '../user.entity'; import { InjectRepository } from '@nestjs/typeorm'; @@ -13,14 +17,32 @@ export class UsersService { private readonly userRepository: Repository, ) {} + public async findOneById(id: number) { + let user: User | null; + try { + user = await this.userRepository.findOneById(id); + } catch (err) { + throw new RequestTimeoutException('common.errors.requestTimeout', { + cause: err, + description: 'Error connecting to the database', + }); + } + + if (!user) { + throw new BadRequestException('users.errors.userNotExist'); + } + + return user; + } + public async findOneByPhone(phone: string) { try { return await this.userRepository.findOneBy({ phone }); } catch (err) { - throw new RequestTimeoutException( - 'Unable to process your request at the moment. Please try again later.', - { cause: err, description: 'Error connecting to the database' }, - ); + throw new RequestTimeoutException('common.errors.requestTimeout', { + cause: err, + description: 'Error connecting to the database', + }); } } @@ -30,10 +52,10 @@ export class UsersService { try { await this.userRepository.save(newUser); } catch (err) { - throw new RequestTimeoutException( - 'Unable to process your request at the moment. Please try again later.', - { cause: err, description: 'Error connecting to the database' }, - ); + throw new RequestTimeoutException('common.errors.requestTimeout', { + cause: err, + description: 'Error connecting to the database', + }); } return newUser;