13. 관리자용 게이트웨이 핸들러 구현

Hyeseong·2023년 3월 4일
0

실시간설문조사앱

목록 보기
3/6
post-thumbnail

🏛Architecture

지난 시간

지난 시간에는 Redis에서 poll 데이터에 participants를 추가하고 제거하는 방법을 다루었으며, 클라이언트가 동일한 poll에 속한 다른 클라이언트와 통신하도록 했습니다.

오늘은 일부 이벤트에 대한 액세스를 관리자만 사용할 수 있도록 제한하는 방법을 살펴 볼것입니다.

1️⃣ Admin Guard 추가

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';

/**
 * Guard that determines if the client is an admin
 */
@Injectable()
export class GatewayAdminGuard implements CanActivate {
  /**
   * Method that checks if the client is an admin
   * @param context The execution context for the guard
   * @returns A Promise containing a boolean indicating if the client is an admin
   */
  async canActivate(context: ExecutionContext): Promise<boolean> {
    throw new Error('Method not implemented.');
  }
}
  • canActivate 메소드 내에서 await 해야 할 로직이 있으므로 canActivate를 비동기로 명세합니다.
    사실 다른 가드에서는 단순히 JSON Web Token 페이로드를 확인하고 추출했지만, 여기에서는 데이터베이스에 액세스하여 poll admin의 ID를 가져와 추출된 JWT의 ID와 비교 할 것입니다.

  • 그러기 위해서는 먼저 PollServiceJwtService를 클래스에 inject해줘야 합니다.

	private readonly logger = new Logger(GatewayAdminGuard.name);
	constructor(
      private readonly pollService: PollService,
      private readonly jwtService: JwtService,
      ) {}
  • poll.service 모듈에서 poll 객체를 가져올 메서드가 없으므로 이를 구현 하도록 합니다.
  async getPoll(pollID: string): Promise<Poll> {
    return this.pollsRepository.getPoll(pollID);
  }

이를 통해 현재 소켓의 id를 얻을 수 있고 poll의 adminID와 비교할 수 있습니다. 일치하지 않으면 WsUnauthorizedException이 발생합니다.

또한 JWT를 파싱할 때 몇 가지 type 보정을 받으려고 합니다.

export type AuthPayload = {
  userID: string;
  pollID: string;
  name: string;
};

canActivate 메서드의 마지막 부분을 살펴 볼게요.

  • 내용 : WebSocket 연결에서 클라이언트가 요구하는 권한 토큰을 가지고 있는지 확인합니다. 만약 토큰이 없거나 유효하지 않으면, WsUnauthorizedException 예외를 발생시킵니다. 토큰이 유효하다면, 토큰의 페이로드를 확인하고, 그것이 제대로 된 권한을 가진 클라이언트인지 확인합니다. 만약 권한이 없다면, 다시 WsUnauthorizedException 예외를 발생시킵니다.
// We define a type alias called SocketWithAuth to extend the regular Socket from socket.io.
const socket: SocketWithAuth = context.switchToWs().getClient();

// The token variable is set to the value of the token property of the handshake.auth object,
// or, if that property is undefined, to the value of the token property of the handshake.headers object.
const token =
socket.handshake.auth.token || socket.handshake.headers['token'];

// If the token variable is falsy (undefined, null, 0, false, NaN, or an empty string), then an error is thrown.
// The error message is logged and a WsUnauthorizedException is thrown.
if (!token) {
this.logger.error('No authorization token provided');
throw new WsUnauthorizedException('No token provided');
}

// If the token variable is truthy, we try to verify it using the verify method of the jwtService.
// The verify method returns a decoded payload if the token is valid, or throws an error if the token is invalid.
try {
const payload = this.jwtService.verify<AuthPayload & { sub: string }>(
token,
);

// If the token is valid, we log a debug message with the token payload.
this.logger.debug(Validating admin using token payload, payload);

// We destructure the sub and pollID properties from the token payload.
const { sub, pollID } = payload;

// We retrieve the poll from the pollsService using the getPoll method.
const poll = await this.pollsService.getPoll(pollID);

// If the sub property of the token payload is not equal to the adminID property of the poll, then an error is thrown.
// The error message is logged and a WsUnauthorizedException is thrown.
if (sub !== poll.adminID) {
throw new WsUnauthorizedException('Admin privileges required');
}

// If everything is successful, we return true.
return true;

} catch {
// If there is an error verifying the token, an error is thrown.
// The error message is logged and a WsUnauthorizedException is thrown.
throw new WsUnauthorizedException('Admin privileges required');
}

2️⃣ remove_participant 이벤트 핸들러 추가

관리자만 접근 가능한 remove_participant 이벤트가 허용되는 PollsGateway에 추가하겠습니다.

/**
 * This function removes a participant from a poll and emits an event to notify other clients about the update.
 * 
 * @param id - The ID of the participant to remove.
 * @param client - The client that initiated the request.
 */
  @UseGuards(GatewayAdminGuard)
  @SubscribeMessage('remove_participant')
  async removeParticipant(
    @MessageBody('id') id: string,
    @ConnectedSocket() client: SocketWithAuth,
  ) {}

1) 데코레이터 2개를 추가합니다.

  • 첫 번째는 GatewayAdminGuard 가드를 사용하는 것이고
  • 두 번째는 소켓 이벤트 이름을 핸들러에 매핑하는 것입니다.

2) 함수 내부에서 파라미터를 정의합니다.

  • 첫 번째 인수는 이벤트와 함께 보내는 페이로드를 정의합니다. 이 경우 JSON 페이로드의 필드로 id 필드가 있는 body가 필요합니다. 관리자는 제거하려는 participant의 ID를 알려줘야 합니다.
  • 그런 다음 @ConnectedSocket() 데코레이터를 사용하여 이 메시지를 보내는 실제 클라이언트에 대한 정보도 얻습니다.

아래 코드 블록으로 함수 본문을 구현합니다.

this.logger.debug(
      `Attempting to remove participant ${id} from poll ${client.pollID}`,
    );

    const updatedPoll = await this.pollsService.removeParticipant({
      pollID: client.pollID,
      userID: id,
    });

//아래 code는 io 개체를 사용하여 pollID room을 수신하는 모든 클라이언트에게 poll_updated라는 이벤트를 보내게됩니다. updatedPoll 변수는 이벤트에 대한 데이터 페이로드로 전달되며 participant가 제거된 후 poll의 업데이트된 상태를 나타냅니다.
    this.io.to(client.pollID).emit('poll_updated', updatedPoll);
  }

3️⃣ 버그 수정

handleConnecthandleDisconnection 메서드의 내부 로직중 room의 size가 undefined 될 수 있으므로 아래와 같이 코드를 수정하여 fallback을 0로 만들어 줍니다.

// correct in handleConnect and handleDisconnection
 const clientCount = this.io.adapter.rooms?.get(roomName)?.size ?? 0;

4️⃣ WsCatchAllFilter 수정

ws-catch-all-filter모듈에서 TypeException을 필터링 하기 위한 로직을 추가 하겠습니다.

    if (exception instanceof WsTypeException) {
      socket.emit('exception', exception.getError());
      return;
    }
import {
  ArgumentsHost,
  BadRequestException,
  Catch,
  ExceptionFilter,
} from '@nestjs/common';
import { SocketWithAuth } from 'src/polls/types';
import {
  WsBadRequestException,
  WsTypeException,
  WsUnknownException,
} from './ws-exceptions';

/**
 * Exception filter for WebSocket connections that handles all types of exceptions.
 */
@Catch()
export class WsCatchAllFilter implements ExceptionFilter {
  /**
   * Method that handles exceptions and sends them to the WebSocket client.
   *
   * @param exception The exception that was thrown.
   * @param host The arguments host object containing the WebSocket client.
   */
  catch(exception: Error, host: ArgumentsHost) {
    const socket: SocketWithAuth = host.switchToWs().getClient(); // Get the WebSocket client from the host

    if (exception instanceof BadRequestException) {
      // If the exception is a BadRequestException
      const exceptionData = exception.getResponse(); // Get the exception data
      const exceptionMessage = // Extract the exception message from the data
        exceptionData['message'] ?? exceptionData ?? exception.name;

      const wsException = new WsBadRequestException(exceptionMessage); // Create a WebSocket-specific exception
      socket.emit('exception', wsException.getError()); // Send the exception to the client
      return; // End the method execution
    }

    if (exception instanceof WsTypeException) {
      // If the exception is a WsTypeException
      socket.emit('exception', exception.getError()); // Send the exception to the client
      return; // End the method execution
    }

    const wsException = new WsUnknownException(exception.message); // Create a generic WebSocket exception
    socket.emit('exception', wsException.getError()); // Send the exception to the client
  }
}

요약

1) WsCatchAllFilter는 모든 유형의 예외를 처리하는 WebSocket 연결에 대한 예외 필터입니다. catch 메서드를 구현해야 하는 @nestjs/common 패키지에서 제공하는 ExceptionFilter 인터페이스를 구현합니다.

2) catch 메서드는 예외와 호스트라는 두 가지 매개변수를 사용합니다. exception은 발생한 예외이고 host는 WebSocket 클라이언트를 포함하는 ArgumentsHost의 인스턴스입니다.

3) 이 메서드는 ArgumentsHost 클래스에서 제공하는 switchToWs 및 getClient 메서드를 사용하여 호스트에서 WebSocket 클라이언트를 가져오는 것으로 시작합니다.

4) 다음으로 메서드는 예외 유형을 확인합니다. BadRequestException 인스턴스인 경우 getResponse 메소드를 사용하여 응답 데이터에서 예외 메시지를 추출하여 WebSocket 특정 예외로 클라이언트에 보냅니다. WsTypeException의 인스턴스라면 예외를 그대로 클라이언트에 보냅니다. 예외가 다른 유형인 경우 예외 메시지와 함께 일반 WebSocket 예외를 클라이언트에 보냅니다.

5️⃣ POSTMAN을 이용한 테스트

  1. 서버를 정상적으로 모두 기동합니다.(nestjs & redis)
  2. redis client tool과 postman을 사용합니다.
  3. poll을 생성하고 participant2~3까지 해당 poll에 넣습니다.
  4. 관리자와 participant2~3를 소켓 리스닝하도록 만듭니다.
  5. 그럼 아래와 같이 poll 상태가 만들어집니다.
  6. participant2에서 관리자를 삭제할 경우 불가하다는 메시지를 아래 처럼 받게됩니다.
  7. 관리자가 삭제할 경우 정상적으로 다른 참가자를 삭제 할 수 있습니다.
profile
어제보다 오늘 그리고 오늘 보다 내일...

0개의 댓글