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
+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;