본문 바로가기
Projects,Activity/DizzyCode(React)

[DizzyCode] WebSocket+JWT 시나리오

by 그냥하는거지뭐~ 2024. 5. 29.

 
채팅방에 들어와서 채팅을 한다고 가정해 보자. jwt 토큰을 검사해야 하는 시점은 ①웹소켓으로 업그레이드할 때, ②STOMP 연결할 때, ③메시지를 전송할 때 등등이다. 2, 3은 별로 문제가 되지 않는데, 1번에서 문제가 생긴다. 어떤 문제인지 알아보고, 최적의 시나리오를 짜보자! 
 

1. 웹소켓 업그레이드 시 

무엇이 문제인지 보려면 우선 웹소켓 업그레이드 과정을 이해해야 한다. 
 
① 초기 HTTP 요청: WebSocket 연결은 초기 HTTP GET 요청을 통해 시작되며, 이 요청은 WebSocket 프로토콜로 업그레이드할 것을 요청하는 Upgrade 헤더와 함께 전송된다. 

GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13

 
② 서버의 응답: 서버는 101 Switching Protocols 응답을 통해 WebSocket 업그레이드를 승인한다. 

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=

 
③ 프로토콜 업그레이드: 이 시점부터 HTTP 프로토콜이 웹소켓 프로토콜로 업그레이드되며, 이후의 통신은 HTTP 헤더가 아닌 웹소켓 프레임을 통해 이루어진다. 
 
즉, 초기 GET 요청에서만 커스텀 헤더가 유효하다. 이 시점에서 커스텀 헤더를 통해 토큰을 전달하려는 시도는 의미가 있을 수 있다. 하지만 웹소켓 연결이 성립되면, 이후의 통신은 웹소켓 프레임을 통해 이루어지므로, 더 이상 HTTP 헤더는 사용되지 않는 것이다. 또한 브라우저는 보안상의 이유로 특정 커스텀 헤더를 웹소켓 업그레이드 요청에 포함하는 것을 허용하지 않는다. (CSRF 공격을 방지하기 위함)
 
그럼 어떻게 보내야 할까? 가능한 시나리오는 다음과 같다. 하나씩 살펴보자. 

CASE 1. URL에 쿼리스트링으로 jwt 포함
CASE 2. WebSocket 초기 연결 후 첫 번째 메시지로 인증 토큰 전송
CASE 3. Sec-WebSocket-Protocol을 통한 토큰 전달
CASE 4. Secondary Token 도입

 
 

CASE 1. URL에 쿼리스트링으로 jwt 포함

https://github.com/whatwg/websockets/issues/16
위 이슈는 handshake에서도 커스텀 헤더를 허용하게 해달라는 이슈인데, 크롬 웹소켓 contributor가 URL에 쿼리스트링으로 포함시켜도 된다고 주장한다. 

https://github.com/whatwg/websockets/issues/16#issuecomment-347180825

 
이분 논리를 정리해보면 다음과 같다. 
- 웹소켓 URL은 HTTP URL과 다르게 직접 노출되지 않기 때문에 쉽게 접근하기 어렵다. 
- 자바스크립트 API에서도 최소한으로 노출된다. => 쿼리 스트링에 포함된 정보가 다른 웹 API를 통해 유출될 가능성이 매우 낮음
- Authorization 헤더를 사용하는 경우, 401 응답을 처리해야 하지만, WebSocket API는 보안상의 이유로 페이지에 에러 응답을 노출하지 않는다.
- 짧은 유효기간을 가진 인증 토큰을 쿼리 스트링에 포함시키는 것이 현재로서는 가장 실용적이고 안전한 방법일 수 있다. 
 
 
설득력 있지만 왠지 찝찝하다. 싫어요 수도 많다ㅋㅋㅋㅋ

const socket = new SockJS(`http://localhost:8787/ws?token=${window.localStorage.getItem('authorization')}`);

 

 
CASE 2. WebSocket 초기 연결 후 첫 번째 메시지로 인증 토큰 전송

WebSocket 연결이 설정된 후, 첫 번째 메시지로 인증 토큰을 보내는 방법이다. 이 방법은 초기 연결 시 헤더에 토큰을 포함하지 않고, 연결이 설정된 후 클라이언트가 별도의 인증 메시지를 서버로 보내어 인증을 처리한다. 

client.current = new Client({
    webSocketFactory: () => socket,
    onConnect: () => {
        client.publish({
            destination: '/app/auth',
            body: JSON.stringify({ token: window.localStorage.getItem('authorization') }),
        });
        subscribe();
    },
    // ...
});

 
하지만 이 방법은 리소스 사용 측면에서 비효율적일 수 있다. 인증되지 않은 연결을 허용하는 것은 서버 자원을 낭비하는 것이기 때문이다. 
 

CASE 3. Sec-WebSocket-Protocol을 통한 토큰 전달

WebSocket 연결을 설정할 때 Sec-WebSocket-Protocol 헤더에 JWT 토큰을 포함한다. 서버는 HandshakeInterceptor를 사용하여 Sec-WebSocket-Protocol 헤더에서 JWT 토큰을 추출하고 검증한다. 

// client (react)
const socket = new SockJS('http://localhost:8787/ws', null, {
    protocols_whitelist: ['v10.stomp', 'v11.stomp', 'v12.stomp', window.localStorage.getItem('authorization')],
});

 

// server(spring boot)
import org.springframework.web.socket.server.HandshakeInterceptor;
import org.springframework.web.socket.WebSocketHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(HttpServletRequest request, HttpServletResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        String jwt = request.getHeader("Sec-WebSocket-Protocol");
        if (isValidJwt(jwt)) {
            return true;
        } else {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
    }

    @Override
    public void afterHandshake(HttpServletRequest request, HttpServletResponse response, WebSocketHandler wsHandler, Exception exception) {
        // Do nothing
    }

    private boolean isValidJwt(String jwt) {
        // JWT 검증 로직 구현
        return true;
    }
}

음.. 크롬에서 동작하긴 하지만 목적이 너무 왜곡된 느낌이다. 모든 브라우저가 Sec-WebSocket-Protocol 헤더를 통해 인증 토큰을 전달하는 것을 지원하지 않을 수 있으며, 이 방법은 다른 방법보다 더 안전하지 않을 수 있다. 특히, 이 헤더는 프로토콜 협상을 위해 여러 개의 값으로 구성될 수 있고, 토큰 관리가 복잡해질 수 있다. 
 
 

CASE 4. Secondary Token 도입

편의상 ST라고 부르겠다. 이 방법으로 보안 문제가 해결되는데, 우선 로직을 보자. 

[client]
- JWT를 사용하여 ST 발급을 요청한다. 

[server]
- 서버는 클라이언트가 JWT를 보내면, 이를 검증한 후 ST를 반환한다.
- 이때 유효기간은 30초 정도로 매우 짧게 둔다.

[client]
- ST를 쿼리 파라미터에 넣어서 보내 웹소켓 업그레이드를 요청한다. 

[server]
- HandshakeInterceptor를 사용하여 ST를 추출하고 검증한다. 

 
웹소켓 업그레이드가 완료된 이후의 STOMP 연결 및 메시지 전송 시에는 JWT를 사용한다.

async function connectWebSocket() {
    const jwt = window.localStorage.getItem('authorization');
    const secondaryToken = await getSecondaryToken(jwt);
    const socket = new SockJS(`http://localhost:8787/ws?token=${secondaryToken}`);
    // ... STOMP 연결 설정
}

 
 
이 방법의 장점은, 우선 ST는 유효기간이 매우 짧으므로 노출되더라도 위험하지 않다는 점이다. 또한 ST는 웹소켓 업그레이드라는 특정 목적에만 사용되므로, 일반적인 access token보다 권한이 제한적이다. 또다른 장점으로는 ST를 사용하면 웹소켓 연결 상태와 jwt 토큰 상태를 독립적으로 관리할 수 있다는 것이다. ST를 발급받기 위해 jwt 토큰 유효성 검사를 할 때, access token이 만료됐더라도 웹소켓 연결 전에 재발급받으므로 로직이 분리된다. 
 
 
이 방법을 통해 WebSocket 업그레이드 요청 시의 보안 문제를 해결하면서, STOMP 메시지 전송 시에는 기존의 JWT 인증 방식을 유지할 수 있다. 


2. STOMP 연결 시 검사 

WebSocket 업그레이드 요청이 완료되고 나서 STOMP 연결을 설정할 때, connectHeaders를 사용하여 JWT를 전달하고 검증한다. 

client.current = new Client({
  webSocketFactory: () => socket,
  connectHeaders: {
    Authorization: `Bearer ${window.localStorage.getItem('authorization')}`,
  },
  onConnect: () => {
    console.log('Connected successfully');
    // 연결 후 구독 등 처리
    subscribe();
  },
  
});

3. 메시지 전송

header에 포함한다. 
 

const sendMessage = () => {
    if (client && connected) {
      client.publish({
        destination: '/app/send',
        headers: {
          Authorization: `Bearer ${window.localStorage.getItem('authorization')}`,
        },
        body: JSON.stringify({ message: 'Hello, World!' }),
      });
    }
  };

 


4. 정리

웹소켓 연결 시 유효기간이 짧은 secondary token을 도입하고, 그 이후의 STOMP 연결, 메시지 전송 등에서는 JWT를 사용하자!


 

Reference

- https://velog.io/@tlatldms/Socket-%EC%9D%B8%EC%A6%9D-with-API-Gateway-Refresh-JWT
- https://github.com/whatwg/websockets/issues/16