Spring 채팅 구현하기 - with Spring security

Kevin·2023년 8월 13일
0

Spring

목록 보기
9/11
post-thumbnail

나는 기존 개발하던 프로젝트에서 Spring security와 JWT를 사용하여서, 인증 인가를 구현하고 있다.

이에 맞춰서 채팅 기능 또한 Spring security와 JWT를 사용하여서 인가를 구현해야겠다는 생각이 들었다.

당연한 이야기지만 채팅 또한 누구나 멋대로 작성하게 둔다고 하면 안되기 때문이다.

이번에도 마찬가지로 코드와 같이 살펴보자.

이 글은 기본적으로 Spring security와 Spring Message + Stomp에 대한 기본 이해가 있다는 것을 전제로 작성했습니다.


SocktConfig

WebSocketConfig.class


@Configuration
@EnableWebSocketMessageBroker // Stomp 프로토콜을 사용하도록 정의
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final ChatPreHandler chatPreHandler;

    private final StompExceptionHandler stompExceptionHandler;

	  //... 
		
		// 아래 부분을 추가 해준다.
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(chatPreHandler);
    }
}
  • 직접 작성한 Handler를 인터셉터로 추가해준다.
    • 이 때 Handler는 아래에서 자세히 이야기 하겠지만, Message가 컨트롤러 메서드에 도달하기 전 Message와 Stomp Header를 인터셉트하는 핸들러이다.

Handler

ChatPreHandler.class


@Configuration
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class ChatPreHandler implements ChannelInterceptor {

    private final TokenGenerator tokenGenerator;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {

        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader("Authorization"));

        StompCommand command = headerAccessor.getCommand();

        if (command.equals(StompCommand.CONNECT)) {
            if (authorizationHeader == null) {
                throw new MalformedJwtException("jwt");
            }
            String accessToken = authorizationHeader.replaceAll("[\\[\\]]", "");

            boolean isTokenValid = TokenGenerator.isValidAccessToken(accessToken);

            if (isTokenValid) {
                Authentication authentication = TokenGenerator.getAuthentication(accessToken);
                this.setAuthentication(authentication, message, headerAccessor);
            }
        }

        if (command.equals(StompCommand.ERROR)) {
            throw new MessageDeliveryException("error");
        }

        return message;
    }

    private void setAuthentication(Authentication authentication, Message<?> message, StompHeaderAccessor headerAccessor) {
        SecurityContextHolder.getContext().setAuthentication(authentication);
        headerAccessor.setUser(authentication);
    }
}
  • 위 핸들러에서의 Flow는 다음과 같다.
    • Stomp 프로토콜을 이용해서 클라이언트에게 전달 된 메세지가 Spring Controller에 전달되기 전에ChannelInterceptor를 상속받은 ChatPreHandler가 인터셉트한다.
    • Header에서 AccessToken을 꺼낸 후 StompCommand가 만약 CONNECT일 때, 즉 맨 처음 연결을 할 때만 AccessToken이 유효한 Token인지를 검증 후에 만약 유효하다면 Header에 User를 저장하고, 유효하지 않다면 Token 예외를 터뜨린다.

StompExceptionHandler.class

@Component
public class StompExceptionHandler extends StompSubProtocolErrorHandler {

    private static final byte[] EMPTY_PAYLOAD = new byte[0];

    public StompExceptionHandler() {
        super();
    }

    /**
     * 클라이언트 메시지 처리 중에 발생한 오류를 처리
     *
     * @param clientMessage 클라이언트 메시지
     * @param ex 발생한 예외
     * @return 오류 메시지를 포함한 Message 객체
     */
    @Override
    public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage,
                                                              Throwable ex) {

        final Throwable exception = converterThrowException(ex);

        if (exception instanceof HttpClientErrorException) {
            return handleUnauthorizedException(clientMessage, exception);
        }

        return super.handleClientMessageProcessingError(clientMessage, ex);

    }

    // 메세지 전송간 캐치되는 에러를 핸들링
    private Throwable converterThrowException(final Throwable exception) {
        if (exception instanceof MessageDeliveryException) {
            return exception.getCause();
        }
        return exception;
    }

    // 권한 문제 핸들링
    private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage,
                                                        Throwable ex) {

        return prepareErrorMessage(clientMessage, ex.getMessage(), HttpStatus.UNAUTHORIZED.name());

    }

    /**
     * 오류 메시지를 포함한 Message 객체를 생성
     *
     * @param message 오류 메시지
     * @return 오류 메시지를 포함한 Message 객체
     */
    private Message<byte[]> prepareErrorMessage(final Message<byte[]> clientMessage,
                                                final String message, final String errorCode) {

        final StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
        accessor.setMessage(errorCode);
        accessor.setLeaveMutable(true);

        setReceiptIdForClient(clientMessage, accessor);

        return MessageBuilder.createMessage(
                message != null ? message.getBytes(StandardCharsets.UTF_8) : EMPTY_PAYLOAD,
                accessor.getMessageHeaders()
        );
    }

    // accessor 헤더에 receiptId 저장하기
    private void setReceiptIdForClient(final Message<byte[]> clientMessage,
                                       final StompHeaderAccessor accessor) {

        if (Objects.isNull(clientMessage)) {
            return;
        }

        final StompHeaderAccessor clientHeaderAccessor = MessageHeaderAccessor.getAccessor(
                clientMessage, StompHeaderAccessor.class);

        final String receiptId =
                Objects.isNull(clientHeaderAccessor) ? null : clientHeaderAccessor.getReceipt();

        if (receiptId != null) {
            accessor.setReceiptId(receiptId);
        }
    }

    @Override
    protected Message<byte[]> handleInternal(StompHeaderAccessor errorHeaderAccessor,
                                             byte[] errorPayload, Throwable cause, StompHeaderAccessor clientHeaderAccessor) {

        return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
    }
}
  • ChatPreHandler에서는 채팅 전 JWT를 통한 AccessToken을 인가했다면, StompExceptionHandler에서는 Stomp 프로토콜을 이용한 채팅간 에러들을 핸들링한다.
  • 위 핸들러에서의 “receiptId"는 클라이언트가 서버로부터 메시지를 전송했을 때 그 메시지를 식별하고 해당 메시지에 대한 확인 메시지를 받았을 때 어떤 메시지에 대한 것인지를 구분하기 위해 사용되는 고유한 식별자이다.
    • 즉 Stomp 프로토콜에서 "ACK" 메시지를 추적하거나 확인하는 데 사용되는 매커니즘 중 하나이다.

부록

채팅 메세지 하나에 Security를 이용한 인가, DB 저장, Stomp를 이용한 송 수신 기능들이 존재하기에 이거는 너무 Heavy하지 않은가라는 생각에 나의 개발 멘토님께 개인적으로 여쭤본 결과 아래와 같이 답변을 주셨다.

답변 : 넵! 적절합니다. 만약 조금 가볍게 만들고 싶으시다면 인터셉터에서 DB접근을 하기보다는 JWT의 유효성 여부만 검사하고 세부 구현은 서비스에서 비즈니스 로직으로 구현하시는것도 좋은 방법인 것 같습니다. 추가적으로 JWT 인증 같은 경우에는 모든 요청에 해당되는 공통 보안 사항이기 때문에 인터셉터 보다는 필터가 더 적절할 수 있을 것 같습니다! 인터셉터와 필터의 용도 차이에 대해서 찾아보시면 좋을 것 같아요.

인터셉터와 필터의 정확한 차이에 대해서 공부를 해보도록 하자.

profile
Hello, World! \n

0개의 댓글