Backend/인증, 인가

[삽질 기록과 해결] Cross-Origin WebSocket에서 쿠키 전송 문제

동구름이 2024. 11. 20. 20:37

문제 상황

로컬 환경에서 배포된 서버 Backend의 WebSocket으로 접근하는 상황이었다.
인증 정보를 쿠키로 전달하려 했지만, WebSocket 연결이 완료되지 못했다.

bet.gateway.ts

 handleConnection(client: Socket) {
    try {
      const cookies = this.jwtUtils.parseCookies(
        client.handshake.headers.cookie,
      );
      const accessToken = cookies["access_token"];
      if (!accessToken) {
        client.emit("error", {
          event: "handleConnection",
          message: "엑세스 토큰이 존재하지 않습니다.",
        });
        client.disconnect(true);
        return;
      }

     ...
  }

bet.gateway.ts에서는 위의 로직으로 accessToken을 검사한다. 그리고 accessToken이 없다면 client.disconnect(true); 를 통해 소켓을 끊어버린다.

 

브라우저의 개발자 도구에서 응답의 헤더를 확인해보니 Cookie가 비어있었다.

 

그런 이유로 서버에서 client.handshake.headers.cookie를 조회할 때에도 undefined가 출력되었다.

 

 

삽질의 시작

문제 원인을 찾기 위해 Nginx 설정, CORS 설정, Socket.IO 설정 등을 모두 점검했지만 문제는 해결되지 않았다.

시도한 방법들

Nginx CORS 설정 추가

add_header 'Access-Control-Allow-Origin' 'http://localhost:3000';
add_header 'Access-Control-Allow-Credentials' 'true';

Socket.IO Gateway 설정

@WebSocketGateway({
  namespace: "api/betting",
  cors: {
    origin: ["http://localhost:3000", "http://175.45.205.245"],
    credentials: true,
  },
})

CORS, Nginx, Socket.IO 모두 올바르게 설정되었지만 쿠키는 여전히 포함되지 않았다.

 

 

문제 원인 파악

쿠키의 SameSite 속성

BE (user.controller.ts)

    res.cookie("access_token", result.accessToken, {
      httpOnly: true,
      maxAge: 1000 * 60 * 60,
      secure: false, // HTTPS를 통해서만 전송되도록 설정
      // sameSite: "strict", // 기본 값 LAX
    });

BE에서 쿠키는 위와 같이 생성된다.

 

 sameSite 설정을 따로 하지 않으면 기본값은 SameSite=Lax 로 설정된다.

 

 그런데 Lax 설정은 예외 상황을 제외한 Cross-Origin 요청에서 쿠키를 포함하지 않는다.

 예외 상황: GET 요청 + 탑 레벨 네비게이션 (주소창 입력, 링크 클릭 등)

 

 

 그런 이유로 POST, PUT, WebSocket 연결 등 요청에서는 Lax 쿠키가 포함되지 않게 된다.

 

결론은 SameSite 설정 문제였다!

 

 

해결 방법

(1) 쿠키의 SameSite 설정 변경

SameSite=None + Secure=true를 사용하여 쿠키가 Cross-Origin 요청에도 포함되도록 설정하는 방법이 있다.

BE (user.controller.ts)

res.cookie("access_token", token, {
  httpOnly: true,
  secure: true, // HTTPS 필수
  sameSite: "None", // Cross-Origin 허용
});

 

하지만 secure: true를 설정하면 HTTPS 환경이 필수적으로 요구되는데, 현재의 서버는 HTTPS로 구성되어 있지 않았다.

 

 

(2) JWT 토큰 기반 인증으로 대체

쿠키 대신 토큰을 사용하여 인증을 진행한다.

FE

import { io } from "socket.io-client";

const socket = io(SOCKET_URL + options.url, {
  auth: {
    token: `${access_token}`,
  },
});

 

BE

handleConnection(client: Socket) {
  const token = socket.handshake.auth.token;
  const payload = this.jwtUtils.verifyToken(token);
  client.data.userId = payload.id;
}

 

토큰을 이용한 방식으로 에러를 해결했다.

 

 

 

추가로 참고하면 좋을 사항

토큰을 이용한 방식으로 처리할 때 가장 처음 custom header를 사용했다.

FE

const socket = io(SOCKET_URL + options.url, {
  transports: ["websocket"],
  extraHeaders: {
    Authorization: `Bearer ${accessToken}`, // 커스텀 헤더 만들기
  },
  withCredentials: true,

 

BE

  handleConnection(client: Socket) {
    try {
      const authorizationHeader = client.handshake.headers.authorization;
    ...

 

하지만 http→websocket upgrade를 위한 handshake http 요청에는 custom header를 달 수 없다!

 

 

이와 관련된 참고자료

https://socket.io/docs/v4/server-socket-instance/#sockethandshake
https://github.com/whatwg/websockets/issues/16
https://stackoverflow.com/questions/23406163/socket-io-client-how-to-set-request-header-when-making-connection