feat: add logout functionality
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
POST {{baseUrl}}/auth/logout/
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
|
||||
{
|
||||
"refreshToken": "{{$global.refreshToken}}"
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface AccessTokenPayload {
|
||||
sub: number;
|
||||
phone: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Partial<AccessTokenPayload>>(
|
||||
user.id,
|
||||
this.jwtConfiguration.access.expiresIn,
|
||||
this.jwtConfiguration.access.secret,
|
||||
{ phone: user.phone },
|
||||
{ phone: user.phone, jti: accessJti },
|
||||
),
|
||||
|
||||
// Sign Refresh Token
|
||||
|
||||
@@ -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<RefreshTokenPayload>(
|
||||
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<RefreshTokenPayload>(
|
||||
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<AccessTokenPayload>(
|
||||
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');
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user