From e9d6c9b0a95f97cbd1f8d9288385f7c270501a54 Mon Sep 17 00:00:00 2001 From: crazyoptimist Date: Fri, 14 Jun 2024 14:57:07 -0500 Subject: [PATCH] feat: implement token refresh and logout the right way!! --- .env.example | 2 + deployments/compose.yaml | 2 + ...1718393905518-Add_Refresh_Token_To_User.ts | 15 ++++++ src/modules/auth/auth.controller.ts | 22 +++++++- src/modules/auth/auth.service.ts | 51 +++++++++++++++---- src/modules/auth/dto/token-refresh.dto.ts | 10 ++++ .../transformer/password.transformer.ts | 2 + src/modules/main/app.module.ts | 2 + src/modules/user/user.entity.ts | 9 ++++ src/modules/user/user.service.ts | 12 +++++ 10 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 src/migrations/1718393905518-Add_Refresh_Token_To_User.ts create mode 100644 src/modules/auth/dto/token-refresh.dto.ts diff --git a/.env.example b/.env.example index 6c7c7f8..b62be6a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # JWT AUTH ACCESS_TOKEN_SECRET=3uper**3ecret**you**may**never**guess ACCESS_TOKEN_EXPIRATION=3600s +REFRESH_TOKEN_SECRET=replace**it**once**stolen**or**leaked +REFRESH_TOKEN_EXPIRATION=604800s # DATABASE DB_TYPE=postgres diff --git a/deployments/compose.yaml b/deployments/compose.yaml index 0ad48a1..80b003e 100644 --- a/deployments/compose.yaml +++ b/deployments/compose.yaml @@ -10,6 +10,8 @@ services: environment: - ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET} - ACCESS_TOKEN_EXPIRATION=${ACCESS_TOKEN_EXPIRATION} + - REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET} + - REFRESH_TOKEN_EXPIRATION=${REFRESH_TOKEN_EXPIRATION} - DB_HOST=postgresql - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} diff --git a/src/migrations/1718393905518-Add_Refresh_Token_To_User.ts b/src/migrations/1718393905518-Add_Refresh_Token_To_User.ts new file mode 100644 index 0000000..e200a1d --- /dev/null +++ b/src/migrations/1718393905518-Add_Refresh_Token_To_User.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRefreshTokenToUser1718393905518 implements MigrationInterface { + name = 'AddRefreshTokenToUser1718393905518'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD "refresh_token" character varying(255)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "refresh_token"`); + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index fc3049e..25513cc 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -6,6 +6,7 @@ import { SignupDto } from './dto/signup.dto'; import { UserService } from '@modules/user/user.service'; import { IRequest } from '@modules/user/user.interface'; import { NoAuth } from '@modules/common/decorator/no-auth.decorator'; +import { TokenRefreshDto } from './dto/token-refresh.dto'; @Controller('api/auth') @ApiTags('authentication') @@ -22,7 +23,7 @@ export class AuthController { @ApiResponse({ status: 401, description: 'Unauthorized' }) async login(@Body() dto: LoginDto): Promise { const user = await this.authService.validateUser(dto); - return this.authService.createToken(user); + return this.authService.createTokenPair(user); } @Post('signup') @@ -32,7 +33,24 @@ export class AuthController { @ApiResponse({ status: 401, description: 'Unauthorized' }) async signup(@Body() signupDto: SignupDto): Promise { const user = await this.userService.create(signupDto); - return this.authService.createToken(user); + return this.authService.createTokenPair(user); + } + + @Post('refresh') + @NoAuth() + @ApiResponse({ status: 201, description: 'Successful Token Refresh' }) + @ApiResponse({ status: 400, description: 'Bad Request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async refresh(@Body() dto: TokenRefreshDto): Promise { + const user = await this.authService.validateRefreshToken(dto); + return this.authService.createTokenPair(user); + } + + @Post('logout') + @ApiResponse({ status: 201, description: 'Successful Logout' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async logout(@Request() request: IRequest): Promise { + return await this.userService.deleteRefreshToken(request.user?.id); } @Get('me') diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index ba68754..2976176 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -7,6 +7,7 @@ import { UserService } from '@modules/user/user.service'; import { IUser } from '@modules/user/user.interface'; import { LoginDto } from './dto/login.dto'; import { JwtPayload } from './passport/jwt.strategy'; +import { TokenRefreshDto } from './dto/token-refresh.dto'; @Injectable() export class AuthService { @@ -17,31 +18,63 @@ export class AuthService { ) {} async validateUser(dto: LoginDto): Promise { - const user = await this.userService.findByEmail(dto.email); + let user = await this.userService.findByEmail(dto.email); if (!user) { throw new UnauthorizedException('User not found.'); } const isPasswordValid = Hash.compare(dto.password, user.password); if (!isPasswordValid) { - throw new UnauthorizedException('Wrong password.'); + throw new UnauthorizedException('Invalid password'); } - // Exclude() decorator handles this already, - // but just to be sure - const { password, ...result } = user; + delete user.password; - return result; + return user; } - async createToken(user: IUser) { + async createTokenPair(user: IUser) { const payload: JwtPayload = { sub: user.id, }; + const accessToken = this.jwtService.sign(payload); + const refreshToken = this.jwtService.sign(payload, { + secret: this.configService.get('REFRESH_TOKEN_SECRET'), + expiresIn: this.configService.get('REFRESH_TOKEN_EXPIRATION'), + }); + const expiresIn = this.configService.get('ACCESS_TOKEN_EXPIRATION'); + + await this.userService.updateRefreshToken(user.id, refreshToken); + return { - accessToken: this.jwtService.sign(payload), - expiresIn: this.configService.get('ACCESS_TOKEN_EXPIRATION'), + accessToken, + refreshToken, + expiresIn, }; } + + async validateRefreshToken(dto: TokenRefreshDto): Promise { + const { sub, exp } = this.jwtService.verify(dto.refreshToken, { + secret: this.configService.get('REFRESH_TOKEN_SECRET'), + }); + + const user = await this.userService.findOne(sub); + + const isMatchedRefreshToken = Hash.compare( + dto.refreshToken, + user.refreshToken, + ); + if (!isMatchedRefreshToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + + // exp is in second + const isExpiredRefreshToken = Date.now() > exp * 1000; + if (isExpiredRefreshToken) { + throw new UnauthorizedException('Expired refresh token'); + } + + return user; + } } diff --git a/src/modules/auth/dto/token-refresh.dto.ts b/src/modules/auth/dto/token-refresh.dto.ts new file mode 100644 index 0000000..106ba13 --- /dev/null +++ b/src/modules/auth/dto/token-refresh.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class TokenRefreshDto { + @ApiProperty({ + required: true, + }) + @IsString() + refreshToken: string; +} diff --git a/src/modules/common/transformer/password.transformer.ts b/src/modules/common/transformer/password.transformer.ts index 67b5e81..9a9b105 100644 --- a/src/modules/common/transformer/password.transformer.ts +++ b/src/modules/common/transformer/password.transformer.ts @@ -2,10 +2,12 @@ import { ValueTransformer } from 'typeorm'; import { Hash } from '../../../utils/hash.util'; export class PasswordTransformer implements ValueTransformer { + // Hash password when saving to database to(value: string) { return Hash.make(value); } + // Get hashed password as is from(value: string) { return value; } diff --git a/src/modules/main/app.module.ts b/src/modules/main/app.module.ts index 8940a84..7fa4b77 100644 --- a/src/modules/main/app.module.ts +++ b/src/modules/main/app.module.ts @@ -23,6 +23,8 @@ import { CaslModule } from '@modules/infrastructure/casl/casl.module'; validationSchema: Joi.object({ ACCESS_TOKEN_SECRET: Joi.string().min(16).required(), ACCESS_TOKEN_EXPIRATION: Joi.string().alphanum().default('900s'), + REFRESH_TOKEN_SECRET: Joi.string().min(16).required(), + REFRESH_TOKEN_EXPIRATION: Joi.string().alphanum().default('86400s'), DB_TYPE: Joi.string().valid('postgres', 'mysql').default('postgres'), DB_HOST: Joi.string().hostname().required(), DB_PORT: Joi.number().integer().min(1).max(65535).default(5432), diff --git a/src/modules/user/user.entity.ts b/src/modules/user/user.entity.ts index 9586a54..fbf3d2a 100644 --- a/src/modules/user/user.entity.ts +++ b/src/modules/user/user.entity.ts @@ -44,6 +44,15 @@ export class User { @Exclude() password: string; + @Column({ + name: 'refresh_token', + length: 255, + transformer: new PasswordTransformer(), + nullable: true, + }) + @Exclude() + refreshToken: string; + @ManyToMany(() => Role, { eager: true }) @JoinTable({ name: 'users_roles', diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 6fc98e5..666f8ff 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -149,4 +149,16 @@ export class UserService { async delete(id: number) { return await this.userRepository.delete(id); } + + async updateRefreshToken(id: number, refreshToken: string) { + return await this.userRepository.update(id, { + refreshToken, + }); + } + + async deleteRefreshToken(id: number) { + return await this.userRepository.manager.connection + .query(`UPDATE users SET refresh_token = NULL WHERE id = $1`, [id]) + .catch((e) => console.log(e)); + } }