-
Spring Boot - WebSocket & JWT & Spring Security 토큰 인증SpringBoot/WebSocket 2021. 6. 18. 19:16
개발 환경 : Spring Boot, Maven, Web Socket ( Sock JS ), Spring Secuirty , Json Web Token, Vue
jwt 인증 헤더 : 'Authorization'
JWT 인증 기반의 프로젝트에서의 인증
JWT를 기반으로 하는 제 프로젝트의 인증은 클라이언트에서 보내는 모든 요청에 JWT를 넣어 전송하고, 서버에서 이를 검증해 통신을 주고받게 사용합니다.
문제점 발생
위의 개발환경에서 채팅 기능을 개발하던 중 소켓을 연결할 때 JWT 토큰이 인식되지 않아 401 UNAUTHORIZED 로 정상적으로 동작하지 않는 이슈가 발생했습니다.
왜 발생했을까 ?
왜 그런가? 생각했을 때 떠오른 것은 401이 난다는 건 인증이 제대로 처리가 안됐나? 였습니다.
하지만 다른 기능들은 모두 정상적으로 동작하고, 401이 의도된 상황에서만 발생하는 것을 확인할 수 있었고
결정적으로 Spring Security의 접근 권한을 지웠을 때에도 웹 소켓 연결에서 401이 발생함을 볼 수 있었습니다.
찾아본 결과 웹 소켓의 경우는 헤더의 토큰을 검사하던 프로토콜이 HTTP와 달라 발생하는 것임을 알게 되었습니다.
해결 방법
저는 총 두 가지 방법을 엮어 해결했습니다.
1. STOMP Client에서 Header 추가
채팅을 위한 Vue 컴포넌트의 connect 메서드
connect() { this.socket = new SockJS('http://localhost:8080/ws'); let options = {debug: false, protocols: Stomp.VERSIONS.supportedProtocols()}; this.stompClient = Stomp.over(this.socket, options); console.log(`소켓 연결을 시도합니다.`); // 이렇게 별도의 헤더를 생성한 뒤 let headers = {Authorization: sessionStorage.getItem('access_token')}; // STOMP Client의 header 부분에 집어넣어줍니다. this.stompClient.connect(headers, (frame) => { this.connected = true console.log('소켓 연결 성공', frame); this.stompClient.subscribe('/exchange/chat-exchange/msg.' + this.chatRequestDto.roomId, (tick) => { console.log(tick.body); this.chatLogs.push(JSON.parse(tick.body)); }) }, (error) => { console.log('연결실패'); console.log(error) this.connected = false }) },
여러 가지 방법을 시도해보았을 때 저 방법만이 헤더 추가가 가능했습니다.
개발자 도구에서 웹 소켓 Request 했을 때 Network를 보면 아래 사진처럼 헤더가 보이지 않습니다.
Headers 탭 옆의 Messages 탭에 들어갑니다.
그러면 아래와 같이 클라이언트에서 넣었던 header인 'Authorization'이 정상적으로 들어감을 확인할 수 있습니다.
헤더를 집어넣는 것까지는 해결했으나 웹 소켓을 열려고 한다면 실패함을 확인할 수 있었는데요.
헤더에 넣은 JWT 토큰을 꺼내어 인증을 완료하기 위해서 한 가지 단계가 더 필요합니다.
2. Interceptor
HandshakeInterceptor vs ChannelInterceptor
HandshakeInterceptor
둘은 모두 interceptor로 비슷하게 동작하지만
HandshakeInterceptor를 열어보면
public interface HandshakeInterceptor { boolean beforeHandshake(ServerHttpRequest var1, ServerHttpResponse var2, WebSocketHandler var3, Map<String, Object> var4) throws Exception; void afterHandshake(ServerHttpRequest var1, ServerHttpResponse var2, WebSocketHandler var3, @Nullable Exception var4); }
ServerHttpRequest, ServerHttpResponse, WebSocketHandler를 사용하는 것을 알 수 있고,
WebSocketHandler를 열어보면
public interface WebSocketHandler { void afterConnectionEstablished(WebSocketSession var1) throws Exception; void handleMessage(WebSocketSession var1, WebSocketMessage<?> var2) throws Exception; void handleTransportError(WebSocketSession var1, Throwable var2) throws Exception; void afterConnectionClosed(WebSocketSession var1, CloseStatus var2) throws Exception; boolean supportsPartialMessages(); }
HandleMessage 메서드를 사용할 수 있습니다.
그렇지만 HandshakeInterceptor에서는 Connect, DisConnect 프레임을 직접 가져와서 사용할 수는 없다고 알게 되었습니다. 그렇기에 웹 소켓을 연결하기 전 JWT 인증을 마친 뒤 정상적으로 동작하는 것이 목표인 프로젝트라 채널과 메시지 자체를 얻어와 처리할 수 있는 ChannelInterceptor를 사용하게 되었습니다.
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) { } }
ChannelInterceptor 구현하기
@Component @RequiredArgsConstructor public class StompHandler implements ChannelInterceptor { private final JwtTokenProvider jwtTokenProvider; @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); System.out.println("message:" + message); System.out.println("헤더 : " + message.getHeaders()); System.out.println("토큰" + accessor.getNativeHeader("Authorization")); if (StompCommand.CONNECT.equals(accessor.getCommand())) { jwtTokenProvider.validateToken(Objects.requireNonNull(accessor.getFirstNativeHeader("Authorization")).substring(7)); } return message; } }
StompHeaderAccessor.wrap으로 message를 감싸면 STOMP의 헤더에 직접 접근할 수 있습니다.
위에서 작성한 클라이언트에서 보낸 JWT가 들어있는 헤더 Authorization을
StompHeaderAccessor.getNativeHeader("Authorization") 메서드를 통해 받아올 수 있고
받아온 헤더의 값은 JWT가 됩니다. 받은 JWT를 검증해 정상적으로 소켓을 사용할 수 있도록 동작합니다.
WebSocketConfig
@Configuration @RequiredArgsConstructor @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompHandler stompHandler; @Value("${spring.rabbitmq.username}") private String username; @Value("${spring.rabbitmq.password}") private String password; @Override public void registerStompEndpoints(StompEndpointRegistry registry){ registry .addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry){ registry .setApplicationDestinationPrefixes("/app") .enableStompBrokerRelay("/exchange/chat-exchange") .setRelayPort(61613) .setClientLogin(username) .setClientPasscode(password); } @Override public void configureClientInboundChannel(ChannelRegistration registration){ registration.interceptors(stompHandler); } }
마지막으로 위에서 구현한 StompHandler를 WebSocketConfig에서 사용할 수 있도록 코드를 추가해주어 인터셉터로 등록하시면 JWT & WebSocket 인증이 마무리가 됩니다.
@Override
public void configureClientInboundChannel(ChannelRegistration registration){
registration.interceptors(stompHandler);
}위 과정과 같이 설정해두시면 JWT 인증 기반 웹 소켓 사용이 가능하게 됩니다.
Spring Security에서 웹 소켓 연결할 때 401 UNAUTHORIZE가 리턴되는 현상이 발생했는데 이는
일단 간편하게 Security config에서 웹소켓 엔드포인트를 permitAll로 설정해 처리했습니다.. 후에 포스팅으로 작성해보겠습니다.