Spring Boot + STOMP + JWT Socket 인증하기

Sieun Sim·2020년 5월 31일
19

서버개발캠프

목록 보기
19/21

JWT 인증 기반의 프로젝트에서 Socket 인증?

앞서 우리 프로젝트는 클라이언트가 모든 요청에 JWT를 붙여 보내고 Gateway 차원에서 파싱해 이후의 기능 서버들 단에서는 자유롭게 통신을 주고받는 것으로 정했었다. 그런데 WebSocket의 경우 헤더의 토큰을 검사하던 HTTP 프로토콜과는 완전히 달라 인증을 어떻게 할 지 고민이었다.

STOMP

STOMP를 소켓을 pub/sub 구조로 만들어주는 라이브러리로만 생각했는데, 사실 하나의 프로토콜이다. Simple Text Oriented Messaging Protocol 의 약자로, HTTP를 사용한 프레임에 구현되어있다고 보면 된다. 아래 예시의

**SEND**
destination:/queue/trade
content-type:application/json
content-length:44

{"action":"BUY","ticker":"MMM","shares",44}^@

SEND는 HTTP의 POST, GET method와 같은 레벨이라 보면 된다. method대신 command라고 부르는데, SEND: 서버로 보내기, SUBSCRIBE: 구독할곳 등록하기, MESSAGE: 다른 subscribers들에게 braodcast하기 쯤으로 이해하면 된다. 사실 이 내부적인 프레임은 라이브러리를 사용하면 별로 직접 볼 일은 없었다.


WebSocket 연결 과정

그림의 중간에 Bidirectional WebSocket messages는 개발자도구의 네트워크탭에서도 보이지 않는다. 하지만 HTTP→WS로 프로토콜을 바꾸기 위한 HTTP Upgrade 요청은 보인다. 나는 그림의 맨 처음에 보이는 HTTP Upgrade request에 JWT를 보내 서버에서 101을 보낼지 혹은 연결을 거부할 지 정하고 싶었다. 리액트에서 STOMP 라이브러리를 가져와 기본 세팅으로만 사용해도 쉽게 101 메시지를 확인해볼 수 있었다.

여기서부터 머리가 복잡해졌다.

Handshake의 첫 HTTP 요청만을 Spring Cloud Gateway에서 잡아 JWT 파싱을 하고, 이후의 ws 프로토콜은 따로 파싱 없이 자유롭게 설정할 수 있을 것인가??? 검색해도 비슷한 예시조차 찾을 수 없었다

우선 다른 서버에서 공통적으로 사용하던 Gateway에서의 JWT validating Filter는 사용할 수 없는 것으로 결정했다. Spring Cloud Gateway에서 exchange객체를 까서 사용했었는데 결국 String token = exchange.getRequest().getHeaders().get("Authorization").get(0).substring(7);형태로 HTTP 기반의 Request를 잡아내 사용했었다. 지금 하고 싶은 WebSocket의 인증은 애초에 HTTP가 아니므로 사용할 수 없었던 것. 200 OK 의 status는 ws에는 아무런 의미가 없다. 101을 받아야 한다.. 여기서부터 골이 아파졌는데 그렇다면 굳이 Gateway를 거쳐야 할 필요가 있을까? (가능하긴 할까?) 내부적인 핸드쉐이크 자체를 서로 다른 서버에서 처리하고 또 연결하는게 실시간 서비스의 속도에 도움이 될까? 소켓 서버는 직접 연결하면 안될까?

여기서 네 가지 방법을 생각했다. (모두 무언가 부족하다)

  1. 소켓 통신은 Gateway를 거치지 않고 직접 소켓 서버와 클라이언트를 바로 연결한다.
  2. Client에서 Server로 연결을 시도하기 직전에 호출하는 beforeConnect 단계에서 추가적인 HTTP 요청을 헤더의 토큰과 함께 보내 인증을 받고, 못받으면 에러처리 해버린다.
  3. 소켓 통신을 시도하기 전에 Client 단에서 먼저 Gateway를 통해야하는 HTTP 요청을 보내 인증을 받은 뒤 추가적인 인증 없이 소켓을 연결한다.
  4. Gateway에서 client에 소켓을 뚫고, 소켓에서 필요한 인증을 모두 한 뒤 data sending 서버와 또다른 소켓통신이나 HTTP통신을 한다.

4번은 (소켓*2의 시간 < HTTP 한번의 시간) 이라면 의미가 있을 수도 있겠지만 일단 아무리봐도 아닌것같다. 3번은 결국 인증을 받은 뒤 클라이언트 단에서 소켓 서버로 마찬가지로 직접 연결해야 하고, 사실상 소켓 서버의 url은 뚫려 있는 것과 마찬가지이다. 2번은 그래도 클라이언트에서 직접적으로 원하는 요청에 대해서는 서버의 인증을 받아야 한다는 점에서 좀 나은것 같긴 한데, 소켓 연결 자체에 인증 메커니즘이 붙어있는 형태가 아니다. 뭔가 부족하다. 모든 소켓 연결 요청 자체에 있어 서버가 직접 인증을 하는 1번을 선택했다. 속도 면에서도 결국 실시간 통신을 빨리 해내는게 중요한데 다이렉트로 꽂는게 낫지 않을까?

Gateway를 포기한다 쳐도, 소켓 서버에서는 언제 어디서 JWT를 검사해야 할까?

Socket Server

서버쪽에서는 좋은 자료를 찾아 직접 해석해보았다. 발해석이지만..


모든 STOMP 메세징 세션은 websocket으로의 upgrade를 위한 HTTP 요청으로 시작한다. 많은 웹 어플리케이션들이 이미 전통적인 Spring Security로 로그인 페이지나 HTTP 기본 인증 등등으로HTTP 요청에 대한 인증/인가 시스템을 가지고 있다. 인증된 유저에 대한 security context가 HTTP 세션에 저장되어 cookie-based 세션으로 연속된 요청을 처리했었다. 그러므로 WebSocket이나 SockJS의 HTTP 요청은 기존의 spring security에서 딱히 달리 처리할 게 없다.

STOMP 프로토콜도 CONNECT 프레임에 login과 passcode header를 가지고 있다. 이것은 STOMP TCP를 위해 디자인되었고 그렇게 쓰인다. 하지만 STOMP over WebSocket 이라면 기본적으로 스프링은 STOMP 프로토콜 레벨의 authorization 헤더를 무시한다. 이 유저가 HTTP 전송레벨에서 이미 인증되었을 것이라고 가정하는 것이다. Spring Security는 ChannelInterceptor를 이용한 Websocket sub-protocol 인증을 지원하는데 메시지 안의 유저 헤더를 이용한다. 또한 Spring Session은 Websocket integration을 지원하는데 WebSocket이 열려있다면 HTTP 세션을 만료시키지 않는다.

→ 세션이 아니라 JWT를 사용해서 별 해당 없는 얘기..

Spring Security OAuth는 JWT를 포함한 토큰 기반의 인증을 지원한다. STOMP over WebSocket을 포함해 인증 메커니즘으로 사용할 수 있다. 하지만 서버사이드 세션이나 header의 토큰으로 인증을 하는 경우에 cookie-based 세션이 최선이 아닐 수 있다.

WebSocket 프로토콜은 WebSocket 핸드쉐이크에서 서버가 클라이언트를 인증할 수 있는 방법을 전혀 지원하지 않는다. 하지만 실제로 브라우저 클라이언트는 스탠다드한(HTTP) 인증 헤더나 쿠키만을 사용할 수 있고 커스텀 헤더를 사용할 수가 없다. 비슷하게, SockJS를 사용한 자바스크립트 클라이언트 또한 SockJS 요청을 이용해 HTTP 헤더를 사용할 수 없다. 대신, 쿼리 파라미터를 이용해 토큰을 전송할 수 있다. 하지만 토큰이 서버 로그의 URL에 남는 등의 단점이 있다.

위에 언급한 제한점들은 browser-based 클라이언트들에 대한 것이고 WebSocket과 SockJS 둘 다 헤더를 보낼 수 있게 지원해주는 Spring Java-based STOMP 클라이언트들엔 해당되지 않는다.

→ React Stomp에서도 connect 시 Header를 보낼 수 있는 것을 찾았다. 아마 여기서 말하는 browser-based 클라이언트는 자바스크립트로 직접 만든 클라이언트와는 다른 건가 보다.

그러므로, 쿠키를 쓰고싶지 않은 어플리케이션들은 HTTP 프로토콜 레벨에서 인증하는 것 외의 대안은 없다. 쿠키를 사용하는 대신에, STOMP 메시징 프로토콜 레벨에서 헤더에 토큰을 보내 인증하고 싶을 것이다.

이를 위해 두가지 간단한 스텝을 수행할 수 있다.

  1. connect time에 헤더를 보내기 위해 STOMP 클라이언트를 사용한다.
  2. ChannelInterceptor 를 이용해 authentication header에 접근한다.

다음의 예제는 서버사이드 설정에서 커스텀 인증 인터셉터를 등록한다. 인터셉터는 CONNECT Message에서 유저 헤더를 인증한다. 스프링은 인증된 서버를 저장해두고 연속적인 STOMP 메시지를 같은 세션에서 연결짓는다.

→ 우리 프로젝트에서는 JWT를 사용하므로 세션에서 연결짓지는 않고 CONNECT Message에서 받아온 헤더의 토큰을 직접 까서 확인해본다.

Configuration

@Configuration
@EnableWebSocketMessageBroker
public class MyConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor =
                        MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    Authentication user = ... ; // access authentication header(s)
                    accessor.setUser(user);
                }
                return message;
            }
        });
    }
}

또한, Spring Security의 인증을 사용해 메시지를 인증한다면, ChannelInterceptor 설정이 Spring Security의 인증보다 앞에 오도록 해야 한다. WebSocketMessageBrokerConfigurer (인터셉터를 부르는 클래스)에

@Order(Ordered.HIGHEST_PRECEDENCE + 99) 어노테이션을 사용해 설정하는 것이 좋다.


Client

그렇다면 클라이언트는 socket의 어디에 언제 JWT를 보내야 할까? @stomp/stompjs 라이브러리에서 아주 편하게 구현할 수 있었다. 아무리 찾아도 모르겠어서 문서에서 가능성 있어보이는 후보들을 찾아 테스트해봤다. beforeConnectconnectHeaders 가 유력해보였다.

beforeConnect

beforeConnect 는 그야말로 서버에 연결을 요청하기도 전에 실행된다. 서버를 끈 상태에서 실행해도 실행된다. 처음에는beforeConnect 에서 따로 추가적인 http 요청을 보내 인증을 받지 못하면 Connect로 넘어가지 못하게 하려고 했다. 하지만 그건 Client 단에서 눈가리고아웅하는 느낌이다.

connectHeaders

connectHeaders 에서 헤더를 보내면 message에서 확인할 수 있다. 이건 뒤의 ChannelInterceptor 코드에서 설명한다. 클라이언트에서의 사용법은 이렇다.

this.client.configure({     
        brokerURL: 'ws://localhost:8080/wscn/websocket',
        onConnect: () => {
          console.log(new Date());
          this.client.subscribe('/topic/message', message => {
            var datas = JSON.parse(message.body);
              this.setState({
                data: datas.concat(this.state.data)
              });
          });
        },
/*
        beforeConnect: () => {
          console.log("beforeConnect");
        },
*/
        connectHeaders : {
                    'Authorization': "Bearer " + cookie.load('access-token')
                },
        // Helps during debugging, remove in production
        debug: (str) => {
          //console.log(new Date(), str);
        }
      });
      this.client.activate();
}

추가적으로, onConnect는 connection이 established되면 호출되는 함수이다.

debug는 그냥 STOMP 프로토콜을 받아와 눈버깅하기 쉬우라고 만들어놓은 듯 하다.

React에서 최신에 받은 데이터가 위에 뜨게 하기

보통 기존state.concat(new data) 로 바꿨었는데 그냥 new data를 array로 만들어 거기에 기존 state를 concat하면 되는 간단한 문제였다. [new data].concat(기존 array형태의 state)

실시간 데이터가 계속 업데이트되면 state에 모든 데이터를 누적해서 가지고 있을 수는 없으니 한번에 state에는 몇개를 가지고 있을 거고, 스크롤을 밑으로 내리면 rest api에 요청을 보내 몇 개를 가져올 지 등 세부사항은 후에 따로 진행할 것이다.


HandshakeInterceptor vs ChannelInterceptor

둘은 비슷하게 interceptor로 작용하지만 HandshakeInterceptor를 보면 ServerHttpRequest,ServerHttpResponse, WebSocketHandler를 잡아온다. 물론 WebSocketHandler 안에 handleMessage 메소드도 있다. 하지만 HandshakeInterceptor에서 CONNECT / DISCONNECT 프레임을 직접 가져올 수가 없다고 한다. 비슷한 짓을 할 수는 있지만 내 생각에도 Message 자체의 전송 과정에서는 ChannelInterceptor를 사용해 message 자체와 channel을 잡아오는게 더 자세한 컨트롤이 가능할 것 같다.

ChannelInterceptor 구현하기

ChannelInterceptor를 사용하기 위해 우선 WebSocketMessageBrokerConfigurerconfigureClientInboundChannel 메소드를 오버라이드해 Registration에 인터셉터를 추가해야한다. registration.setInterceptors 는 deprecated 되었다고 떠 찾아보니 registration.interceptors 로 바뀐 듯 하다.

WebSocketMessageBrokerConfigurer

@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/wscn").setAllowedOrigins("*").withSockJS();
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new FilterChannelInterceptor());
    }
}

FilterChannelInterceptor

@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
        System.out.println("full message:" + message);  
        System.out.println("auth:" + headerAccessor.getNativeHeader("Authorization"));
        System.out.println(headerAccessor.getHeader("nativeHeaders").getClass());
        if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
            System.out.println("msg: " + "conne");
        }
        //throw new MessagingException("no permission! ");
        return message;
    }
}

StompHeaderAccessor 로 message를 감싸주면 STOMP의 헤더에 직접 접근할 수 있다. 조건에 따라 소켓 연결을 못하게 하려면 exception을 던지거나 Message 객체 대신 null을 반환해주면 된다. JWT validator를 data sending 서버에서도 따로 써야된다는게 너무 마음에 안들지만 우선 이게 최선이다. JWT validator는 gateway에서 썼던 것을 그대로 가져올 생각이다.

가장 큰 문제는 exception이나 null을 줘도 소켓의 네트워크 탭에서는 일단 101 status를 받았다고 뜬다는 것이다. JWT가 유효하지 않을 때 로그인 페이지로 가거나 refresh token으로 다시 access token을 요청하는 등의 액션을 취해야하는데 이 방법은 그저 중간에 에러를 일으켜 연결만 해제하거나 클라이언트가 CONNECT시에 보낸 message 자체를 망가뜨려 보이지 않게 하는 것 밖에 안된다. 물론 이 방법을 써도 구현을 할 수는 있지만 나는 납득할만한 방법을 찾고 싶었다.

결국 다른 방법으로 넘어감 다음포스팅에서

참고자료

Web on Servlet Stack

stompjs-docs documentation

How to intercept connection and subscription with Spring Stomp

spring websocket (polling, Handshake 과정, sockjs, webSocketHandler, 예제)

1개의 댓글

comment-user-thumbnail
2020년 11월 10일

궁금한게 있습니다. 글을 최대한 많이 해석하려고 했는데 제대로 이해했는지 모르겠습니다.
일반적인 stomp+sockjs의 경우 스프링 시큐리티를 사용하는 경우 딱히 다른 보안방법이 없다는 건가요? 현재 저도 stomp+sockjs 보안을 알아보고 있는데 ssl인증의 경우 nodejs 서버를 이용하는 경우에는 있었는데, 스프링의 경우 자료를 찾아보지 못했고 그러하여 토큰을 알아보고 있는데 토큰도 거의 사용되지 않는다는 글 같은데 그렇다면 sockjs와 stomp를 이용하면 스프링 시큐리티 외의 보안은 거의 없다는건가요?

답글 달기