나는 기존 개발하던 프로젝트에서 Spring security와 JWT를 사용하여서, 인증 인가를 구현하고 있다.
이에 맞춰서 채팅 기능 또한 Spring security와 JWT를 사용하여서 인가를 구현해야겠다는 생각이 들었다.
당연한 이야기지만 채팅 또한 누구나 멋대로 작성하게 둔다고 하면 안되기 때문이다.
이번에도 마찬가지로 코드와 같이 살펴보자.
이 글은 기본적으로 Spring security와 Spring Message + Stomp에 대한 기본 이해가 있다는 것을 전제로 작성했습니다.
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);
}
}
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);
}
}
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());
}
}
채팅 메세지 하나에 Security를 이용한 인가, DB 저장, Stomp를 이용한 송 수신 기능들이 존재하기에 이거는 너무 Heavy하지 않은가라는 생각에 나의 개발 멘토님께 개인적으로 여쭤본 결과 아래와 같이 답변을 주셨다.
답변 : 넵! 적절합니다. 만약 조금 가볍게 만들고 싶으시다면 인터셉터에서 DB접근을 하기보다는 JWT의 유효성 여부만 검사하고 세부 구현은 서비스에서 비즈니스 로직으로 구현하시는것도 좋은 방법인 것 같습니다. 추가적으로 JWT 인증 같은 경우에는 모든 요청에 해당되는 공통 보안 사항이기 때문에 인터셉터 보다는 필터가 더 적절할 수 있을 것 같습니다! 인터셉터와 필터의 용도 차이에 대해서 찾아보시면 좋을 것 같아요.
인터셉터와 필터의 정확한 차이에 대해서 공부를 해보도록 하자.