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 { 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}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user