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 { TokensResponseDTO } from './dtos/responses/tokens.dto';
import { LogoutDTO } from './dtos/logout.dto'; import { LogoutDTO } from './dtos/logout.dto';
import { ActiveUser } from '@/common/decorators/active-user.decorator'; import { ActiveUser } from '@/common/decorators/active-user.decorator';
import { AccessToken } from './decorators/access-token.decorator';
import { User } from '@/modules/users/entities/user.entity'; import { User } from '@/modules/users/entities/user.entity';
@Controller('auth') @Controller('auth')
@@ -150,7 +151,11 @@ export class AuthController {
], ],
}, },
]) ])
public async logout(@ActiveUser() user: User, @Body() logoutDto: LogoutDTO) { public async logout(
return await this.authService.logout(user.id, logoutDto); @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 { export interface AccessTokenPayload {
sub: number; sub: number;
phone: string; phone: string;
jti: string;
exp: number;
} }
export interface RefreshTokenPayload { export interface RefreshTokenPayload {
@@ -21,4 +21,14 @@ export class AuthRedisProvider {
async revokeRefreshToken(jti: string) { async revokeRefreshToken(jti: string) {
await this.redisService.del(`rt:${jti}`); 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); return await this.refreshTokensProvider.refreshTokens(refreshTokenDto);
} }
public async logout(userId: number, logoutDto: LogoutDTO) { public async logout(
return await this.logoutProvider.logout(userId, logoutDto); 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 { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import { import {
AccessTokenPayload, AccessTokenPayload,
RefreshTokenPayload, RefreshTokenPayload,
@@ -51,13 +52,15 @@ export class GenerateTokenProvider {
} }
public async generateTokens(user: User, jti: string) { public async generateTokens(user: User, jti: string) {
const accessJti = randomUUID();
const [access, refresh] = await Promise.all([ const [access, refresh] = await Promise.all([
// Sign Access Token // Sign Access Token
this.signToken<Partial<AccessTokenPayload>>( this.signToken<Partial<AccessTokenPayload>>(
user.id, user.id,
this.jwtConfiguration.access.expiresIn, this.jwtConfiguration.access.expiresIn,
this.jwtConfiguration.access.secret, this.jwtConfiguration.access.secret,
{ phone: user.phone }, { phone: user.phone, jti: accessJti },
), ),
// Sign Refresh Token // Sign Refresh Token
+39 -7
View File
@@ -2,7 +2,10 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import type { ConfigType } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import jwtConfig from '@/config/jwt.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 { AuthRedisProvider } from './auth-redis.provider';
import { LogoutDTO } from '../dtos/logout.dto'; import { LogoutDTO } from '../dtos/logout.dto';
import { AppResponse } from '@/common/responses'; import { AppResponse } from '@/common/responses';
@@ -33,21 +36,50 @@ export class LogoutProvider {
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
) {} ) {}
public async logout(userId: number, logoutDto: LogoutDTO) { public async logout(
const payload = await this.jwtService.verifyAsync<RefreshTokenPayload>( userId: number,
logoutDto.refreshToken, 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, issuer: this.jwtConfiguration.issuer,
audience: this.jwtConfiguration.audience, audience: this.jwtConfiguration.audience,
}, },
); );
if (payload.sub !== userId || !payload.jti) { if (accessPayload.sub !== userId || !accessPayload.jti) {
throw new UnauthorizedException('auth.errors.unauthorized'); 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'); 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 { AccessTokenPayload } from '../interfaces/jwt.interface';
import { User } from '@/modules/users/entities/user.entity'; import { User } from '@/modules/users/entities/user.entity';
import { UsersService } from '@/modules/users/providers/users.service'; import { UsersService } from '@/modules/users/providers/users.service';
import { AuthRedisProvider } from '../providers/auth-redis.provider';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -20,6 +21,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
* Inject Users Service * Inject Users Service
*/ */
private readonly usersService: UsersService, private readonly usersService: UsersService,
/**
* Inject AuthRedis Provider
*/
private readonly authRedisProvider: AuthRedisProvider,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -31,6 +37,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
async validate(payload: AccessTokenPayload): Promise<User> { 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); const user = await this.usersService.findOneByPhone(payload.phone);
if (!user) { if (!user) {