feat: add refresh token and global exception filter

This commit is contained in:
2026-05-09 09:00:25 +03:30
parent 06f659101d
commit 73f7af3146
32 changed files with 512 additions and 42 deletions
+88
View File
@@ -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",
+1
View File
@@ -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",
+16 -1
View File
@@ -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 {}
+11
View File
@@ -0,0 +1,11 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class AppException extends HttpException {
constructor(
public messageKey: string,
status: HttpStatus,
public params?: Record<string, any>,
) {
super({ message: messageKey, params }, status);
}
}
@@ -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<Request>();
const response = ctx.getResponse<Response>();
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,
}),
});
}
}
@@ -0,0 +1 @@
export const REDIS = 'REDIS_CLIENT';
@@ -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();
}
}
+25
View File
@@ -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<string>('redis.host'),
port: configService.get<number>('redis.port'),
});
},
},
RedisService,
],
exports: [RedisService],
})
export class RedisModule {}
+2
View File
@@ -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),
});
+6
View File
@@ -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),
}));
+10
View File
@@ -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."
}
}
+7
View File
@@ -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."
}
}
+2 -1
View File
@@ -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."
}
}
+10
View File
@@ -3,5 +3,15 @@
"phone": "شماره موبایل",
"password": "رمز عبور",
"otp": "کد تایید"
},
"errors": {
"wrongPhoneOrPass": "شماره موبایل یا رمز عبور اشتباه است.",
"unauthorized": "دسترسی غیر مجاز",
"tokenReuse": "توکن قبلا استفاده شده.",
"invalidOTP": "کد تایید اشتباه است یا منقضی شده."
},
"messages": {
"otpSent": "کد تایید به شماره {phone} ارسال شد.",
"sendPass": "لطفا رمز عبور را ارسال کنید."
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"errors": {
"internalServerError": "خطایی رخ داد",
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد."
}
}
+2 -1
View File
@@ -6,6 +6,7 @@
"password": "رمز عبور"
},
"errors": {
"passwordPattern": "رمز عبور باید حداقل شامل 8 کاراکتر شامل حروف، اعداد و نشانه ها باشد."
"passwordPattern": "رمز عبور باید حداقل شامل 8 کاراکتر شامل حروف، اعداد و نشانه ها باشد.",
"userNotExist": "کاربر مورد نظر وجود ندارد."
}
}
+11 -2
View File
@@ -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')
+9 -1
View File
@@ -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);
}
}
+6
View File
@@ -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,
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDTO {
@IsNotEmpty()
@IsString()
refreshToken: string;
}
@@ -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"
}
@@ -0,0 +1,6 @@
POST http://localhost:3000/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjksImp0aSI6IjljOTY5OWRhLWI0NTgtNDc0Ny04YTRkLTQyODQxMDllOTVkZCIsImlhdCI6MTc3ODMwNDMyNSwiZXhwIjoxNzc5MTY4MzI1LCJhdWQiOiJsb2NhbGhvc3QiLCJpc3MiOiJsb2NhbGhvc3QifQ.oiHuj3vJRQl7rG8H0fFEfU9cG91KrLGdXqNAYFGTXVA"
}
@@ -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"
}
@@ -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}`);
}
}
@@ -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);
}
}
@@ -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<typeof jwtConfig>,
/**
* Inject AuthRedis Provider
*/
private readonly authRedisProvider: AuthRedisProvider,
) {}
public async signToken<T>(
@@ -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 };
+13 -5
View File
@@ -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');
}
}
@@ -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<typeof jwtConfig>,
/**
* 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<RefreshTokenPayload>(
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,
});
}
}
}
+1 -1
View File
@@ -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;
+12 -15
View File
@@ -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;
@@ -1,2 +1,2 @@
GET http://localhost:3000/users/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQsInBob25lIjoiMDkxMjYxMTcwMTgiLCJpYXQiOjE3NzIxMjc1MTgsImV4cCI6MTc3MjEzMTExOCwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.oaxzSd0DDakCDMiW129Ou3MJh0oT0zJTFerZEjnQ7Ug
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjYsInBob25lIjoiMDkxMjExMTExMTEiLCJpYXQiOjE3Nzc5NzU3NjQsImV4cCI6MTc3Nzk3OTM2NCwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.m3sIOApct0GYBNQ7TuWjf7wo2ZCvu-3vPYeWEHTuHMc
+31 -9
View File
@@ -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<User>,
) {}
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;