Spring 프로젝트 주제로 웹소켓 기반의 타이핑 게임을 진행하던 중 웹소켓과 JWT 토큰 인증 관련 문제를 직면하고 정리하고자 글을 작성하게 되었습니다.
JWT 인증 기반의 인증
제 프로젝트에서 JWT를 기반으로 하는 인증은 클라이언트에서 보내는 모든 요청에 JWT를 넣어 전송하고, 서버에서 이를 검증해 통신을 주고 받도록 되어 있습니다.
문제점
: 위의 개발 환경에서 실시간 게임방 관련 기능을 개발하던 중 소켓 연결 시 JWT 토큰이 인식되지 않아 401 UNAUTHORIZED 가 발생하여 인증 과정이 정상적으로 동작하지 않는 이슈가 발생했습니다.
왜 그런가 ? 라고 생각했을 때 가장 먼저 떠오른 것은 401 HTTP 응답 코드를 보고 인증이 제대로 처리가 되지 않은걸까? 였습니다. 그러나 기존의 다른 기능들은 모두 정상 동작했고 결정적으로 Spring Security의 접근 권한을 지워도 웹소켓 연결에서 401이 발생하는 것을 볼 수 있었습니다. 그래서 찾아본 결과 웹소켓의 경우는 헤더의 토큰을 검사하는 프로토콜이 HTTP와 달라 발생하게 되는 것을 알게 되었습니다
해결 방법
ChannelInterceptor 를 도입하게 되었습니다. 아래는 ChannelInterceptor의 구현체입니다.
public interface ChannelInterceptor {
@Nullable
default Message<?> preSend(Message<?> message, MessageChannel channel) {
return message;
}
default void postSend(Message<?> message, MessageChannel channel, boolean sent) {
}
default void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, @Nullable Exception ex) {
}
default boolean preReceive(MessageChannel channel) {
return true;
}
@Nullable
default Message<?> postReceive(Message<?> message, MessageChannel channel) {
return message;
}
default void afterReceiveCompletion(@Nullable Message<?> message, MessageChannel channel, @Nullable Exception ex) {
}
}
@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
log.info(">>>>>> headerAccessor : {}", headerAccessor);
assert headerAccessor != null;
log.info(">>>>> headAccessorHeaders : {}", headerAccessor.getCommand());
if (Objects.equals(headerAccessor.getCommand(), StompCommand.CONNECT)
|| Objects.equals(headerAccessor.getCommand(), StompCommand.SEND)) { // 문제 발생 예상 지/점
String token = removeBrackets(String.valueOf(headerAccessor.getNativeHeader("Authorization")));
token = jwtTokenProvider.resolveToken(token);
log.info(">>>>>> Token resolved : {}", token);
try {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
Long accountId = ((UserDetailsImpl)authentication.getPrincipal()).getId();
headerAccessor.addNativeHeader("AccountId", String.valueOf(accountId));
log.info(">>>>>> AccountId is set to header : {}", accountId);
} catch (Exception e) {
log.warn(">>>>> Authentication Failed in FilterChannelInterceptor : ", e);
}
}
return message;
}
private String removeBrackets(String token) {
if (token.startsWith("[") && token.endsWith("]")) {
return token.substring(1, token.length() - 1);
}
return token;
}
}
- MessageHeaderAccessor.getAccessor를 통해 STOMP의 헤더에 직접 접근합니다.
- StompCommand 를 통해 연결(CONNECT), 전송(SEND) 시점에 토큰을 확인하는 과정을 거칩니다.
- StompHeaderAccessor.getNativeHeader("Authorization") 메서드를 통해 토큰을 가져와 검증합니다.
- 검증을 통해 정상적으로 소켓을 사용할 수 있도록 동작합니다.
[ WevSocketConfig ]
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final FilterChannelInterceptor filterChannelInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/from");
registry.setApplicationDestinationPrefixes("/to");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(filterChannelInterceptor);
}
}
- 마지막으로 위에서 구현한 FilterChannelInterceptor를 WebSocketConfig에 추가합니다.
- 추가해주어 인터셉터로 등록하면 JWT & WebSocket 인증이 정상 동작합니다.
<< 참고 자료 >>