ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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를 보면 아래 사진처럼 헤더가 보이지 않습니다.

    Web socket Request Network

    Headers 탭 옆의 Messages 탭에 들어갑니다. 

    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 인증 기반 웹 소켓 사용이 가능하게 됩니다. 

     

    웹 소켓 JWT 문제를 해결한 뒤 실제 사용되는 모습 

    Spring Security에서 웹 소켓 연결할 때 401 UNAUTHORIZE가 리턴되는 현상이 발생했는데 이는 

    일단 간편하게 Security config에서 웹소켓 엔드포인트를 permitAll로 설정해 처리했습니다.. 후에 포스팅으로 작성해보겠습니다. 

     

    댓글

Designed by Tistory.