feat: add logout functionality

This commit is contained in:
2026-10-07 15:36:44 +03:30
parent f3fe1f55e4
commit 195eca1015
9 changed files with 99 additions and 12 deletions
+7 -2
View File
@@ -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}`));
}
}
+6 -2
View File
@@ -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
+39 -7
View File
@@ -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) {