diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 38d1a47..754b6c6 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -11,6 +11,7 @@ import { RequirePasswordDTO } from './dtos/responses/require-password.dto'; import { TokensResponseDTO } from './dtos/responses/tokens.dto'; import { LogoutDTO } from './dtos/logout.dto'; import { ActiveUser } from '@/common/decorators/active-user.decorator'; +import { AccessToken } from './decorators/access-token.decorator'; import { User } from '@/modules/users/entities/user.entity'; @Controller('auth') @@ -150,7 +151,11 @@ export class AuthController { ], }, ]) - public async logout(@ActiveUser() user: User, @Body() logoutDto: LogoutDTO) { - return await this.authService.logout(user.id, logoutDto); + public async logout( + @ActiveUser() user: User, + @Body() logoutDto: LogoutDTO, + @AccessToken() accessToken: string, + ) { + return await this.authService.logout(user.id, logoutDto, accessToken); } } diff --git a/src/modules/auth/decorators/access-token.decorator.ts b/src/modules/auth/decorators/access-token.decorator.ts new file mode 100644 index 0000000..727fdd8 --- /dev/null +++ b/src/modules/auth/decorators/access-token.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { ExtractJwt } from 'passport-jwt'; +import type { Request } from 'express'; + +export const AccessToken = createParamDecorator( + (_: unknown, ctx: ExecutionContext): string | undefined => { + const request: Request = ctx.switchToHttp().getRequest(); + + return ExtractJwt.fromAuthHeaderAsBearerToken()(request) ?? undefined; + }, +); diff --git a/src/modules/auth/http/logout.post.endpoints.http b/src/modules/auth/http/logout.post.endpoints.http new file mode 100644 index 0000000..d21d35a --- /dev/null +++ b/src/modules/auth/http/logout.post.endpoints.http @@ -0,0 +1,7 @@ +POST {{baseUrl}}/auth/logout/ +Content-Type: application/json +Authorization: Bearer {{$global.accessToken}} + +{ + "refreshToken": "{{$global.refreshToken}}" +} \ No newline at end of file diff --git a/src/modules/auth/interfaces/jwt.interface.ts b/src/modules/auth/interfaces/jwt.interface.ts index 463677b..c170619 100644 --- a/src/modules/auth/interfaces/jwt.interface.ts +++ b/src/modules/auth/interfaces/jwt.interface.ts @@ -1,6 +1,8 @@ export interface AccessTokenPayload { sub: number; phone: string; + jti: string; + exp: number; } export interface RefreshTokenPayload { diff --git a/src/modules/auth/providers/auth-redis.provider.ts b/src/modules/auth/providers/auth-redis.provider.ts index a9594d4..8b2cc9e 100644 --- a/src/modules/auth/providers/auth-redis.provider.ts +++ b/src/modules/auth/providers/auth-redis.provider.ts @@ -21,4 +21,14 @@ export class AuthRedisProvider { async revokeRefreshToken(jti: string) { await this.redisService.del(`rt:${jti}`); } + + async blacklistAccessToken(jti: string, ttl: number) { + if (ttl <= 0) return; + + await this.redisService.set(`at:bl:${jti}`, '1', ttl); + } + + async isAccessTokenBlacklisted(jti: string) { + return !!(await this.redisService.get(`at:bl:${jti}`)); + } } diff --git a/src/modules/auth/providers/auth.service.ts b/src/modules/auth/providers/auth.service.ts index 8586667..f9884a3 100644 --- a/src/modules/auth/providers/auth.service.ts +++ b/src/modules/auth/providers/auth.service.ts @@ -44,7 +44,11 @@ export class AuthService { return await this.refreshTokensProvider.refreshTokens(refreshTokenDto); } - public async logout(userId: number, logoutDto: LogoutDTO) { - return await this.logoutProvider.logout(userId, logoutDto); + public async logout( + userId: number, + logoutDto: LogoutDTO, + accessToken: string, + ) { + return await this.logoutProvider.logout(userId, logoutDto, accessToken); } } diff --git a/src/modules/auth/providers/generate-token.provider.ts b/src/modules/auth/providers/generate-token.provider.ts index c405022..a3cbee1 100644 --- a/src/modules/auth/providers/generate-token.provider.ts +++ b/src/modules/auth/providers/generate-token.provider.ts @@ -3,6 +3,7 @@ import { User } from '@/modules/users/entities/user.entity'; import { Inject, Injectable } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; +import { randomUUID } from 'crypto'; import { AccessTokenPayload, RefreshTokenPayload, @@ -51,13 +52,15 @@ export class GenerateTokenProvider { } public async generateTokens(user: User, jti: string) { + const accessJti = randomUUID(); + const [access, refresh] = await Promise.all([ // Sign Access Token this.signToken>( user.id, this.jwtConfiguration.access.expiresIn, this.jwtConfiguration.access.secret, - { phone: user.phone }, + { phone: user.phone, jti: accessJti }, ), // Sign Refresh Token diff --git a/src/modules/auth/providers/logout.provider.ts b/src/modules/auth/providers/logout.provider.ts index 3654857..80fd22b 100644 --- a/src/modules/auth/providers/logout.provider.ts +++ b/src/modules/auth/providers/logout.provider.ts @@ -2,7 +2,10 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import type { ConfigType } from '@nestjs/config'; import jwtConfig from '@/config/jwt.config'; -import { RefreshTokenPayload } from '../interfaces/jwt.interface'; +import { + AccessTokenPayload, + RefreshTokenPayload, +} from '../interfaces/jwt.interface'; import { AuthRedisProvider } from './auth-redis.provider'; import { LogoutDTO } from '../dtos/logout.dto'; import { AppResponse } from '@/common/responses'; @@ -33,21 +36,50 @@ export class LogoutProvider { private readonly i18nService: I18nService, ) {} - public async logout(userId: number, logoutDto: LogoutDTO) { - const payload = await this.jwtService.verifyAsync( - logoutDto.refreshToken, + public async logout( + userId: number, + logoutDto: LogoutDTO, + accessToken: string, + ) { + if (!accessToken) { + throw new UnauthorizedException('auth.errors.unauthorized'); + } + + const refreshPayload = + await this.jwtService.verifyAsync( + logoutDto.refreshToken, + { + secret: this.jwtConfiguration.refresh.secret, + issuer: this.jwtConfiguration.issuer, + audience: this.jwtConfiguration.audience, + }, + ); + + if (refreshPayload.sub !== userId || !refreshPayload.jti) { + throw new UnauthorizedException('auth.errors.unauthorized'); + } + + // The guard already validated this token; verify again to be safe + const accessPayload = await this.jwtService.verifyAsync( + accessToken, { - secret: this.jwtConfiguration.refresh.secret, + secret: this.jwtConfiguration.access.secret, issuer: this.jwtConfiguration.issuer, audience: this.jwtConfiguration.audience, }, ); - if (payload.sub !== userId || !payload.jti) { + if (accessPayload.sub !== userId || !accessPayload.jti) { throw new UnauthorizedException('auth.errors.unauthorized'); } - await this.authRedisProvider.revokeRefreshToken(payload.jti); + // Only blacklist until the access token would have expired anyway + const ttl = accessPayload.exp - Math.floor(Date.now() / 1000); + + await Promise.all([ + this.authRedisProvider.revokeRefreshToken(refreshPayload.jti), + this.authRedisProvider.blacklistAccessToken(accessPayload.jti, ttl), + ]); const message = this.i18nService.translate('auth.messages.loggedOut'); diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index 00a4db0..879e61d 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -6,6 +6,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { AccessTokenPayload } from '../interfaces/jwt.interface'; import { User } from '@/modules/users/entities/user.entity'; import { UsersService } from '@/modules/users/providers/users.service'; +import { AuthRedisProvider } from '../providers/auth-redis.provider'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -20,6 +21,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { * Inject Users Service */ private readonly usersService: UsersService, + + /** + * Inject AuthRedis Provider + */ + private readonly authRedisProvider: AuthRedisProvider, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -31,6 +37,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: AccessTokenPayload): Promise { + if ( + !payload.jti || + (await this.authRedisProvider.isAccessTokenBlacklisted(payload.jti)) + ) { + throw new UnauthorizedException('auth.errors.unauthorized'); + } + const user = await this.usersService.findOneByPhone(payload.phone); if (!user) {