WebSocket 연결(핸드셰이크) 단계 - JWT 인증 : JwtHandshakeInterceptor

ssongyi·2025년 6월 4일
0

Java/Spring TIL

목록 보기
16/22

WebSocket 에서도 인증이 필요한 이유

1️⃣ 웹소켓 연결 시: 별도의 연결 흐름

일반적인 HTTP 요청:

  • 매번 HTTP 헤더에 Authorization(JWT) 넣음
  • SecurityFilter가 처리함

WebSocket:

  • 최초 핸드셰이크(Handshake) 요청은 HTTP로 가지만
  • 연결 후에는 별도 TCP 소켓 통신
  • SecurityFilter는 작동 안 함!
  • 즉, 연결 이후의 메시지는 인증된 사용자인지 판단할 방법이 없어짐 → 보안 문제 발생

2️⃣ 위험한 시나리오

상황발생 가능 문제
WebSocket 연결 시 인증 없음아무나 WebSocket 연결 가능
악성 사용자 WebSocket 연결 후 메시지 전송채팅방에서 권한 없는 사용자도 메시지 송신/수신 가능
서버가 사용자 정보를 모름누가 어떤 메시지를 보내는지 서버가 파악 불가 → 전체 서비스 위험

구현 흐름

  1. 클라이언트가 /ws 엔드포인트로 JWT 포함해서 연결 시도
  • 예: SockJS + Stomp client 사용 시 → 헤더에 JWT 포함
  1. 서버 쪽 JwtHandshakeInterceptor 에서 토큰 검증 후 사용자 인증 정보 저장

  2. 인증된 사용자만 채팅방 입장 허용

  3. 이후 메시지 처리 시에도 인증된 사용자 정보 기반으로 송신/수신 처리

  • STOMP 사용하면 ChannelInterceptor 추가 구현 추천
    → 메시지 송신 시점에서도 인증 체크 가능

JwtHandshakeInterceptor

// WebSocket 연결(핸드셰이크) 요청이 들어올 때 JWT 토큰을 검증해서 인증된 사용자만 WebSocket 연결 허용
// 인증된 사용자의 정보를 WebSocket 세션에 저장 → 이후 채팅 메시지 처리 시 활용 가능
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    private final JwtUtil jwtUtil;

    public JwtHandshakeInterceptor(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {

        // WebSocket 연결 요청이 Servlet 기반 HTTP 요청인지 확인
        // 이유: HttpServletRequest 에서 헤더를 읽으려고 하기 때문 + Spring WebSocket 지원 구조 때문
        if (request instanceof ServletServerHttpRequest servletRequest) {
            // 원본 HttpServletRequest 꺼내오기
            HttpServletRequest httpServletRequest = servletRequest.getServletRequest();

            // JWT 토큰 추출 (Authorization 헤더에서 Bearer 토큰 가져오기)
            String token = jwtUtil.extractToken(httpServletRequest);

            // 토큰이 존재하고, 유효한지, 그리고 ACCESS 토큰인지 확인
            if (token != null && jwtUtil.validateToken(token) && jwtUtil.isAccessToken(token)) {
                // 인증 성공 → 사용자 정보(UserAuth)를 WebSocket 세션에 저장
                attributes.put("userAuth", jwtUtil.extractUserAuth(token));
                return true; // → WebSocket 연결 허용
            } else {
                // 인증 실패 → WebSocket 연결 거부
                return false;
            }
        }
        // Servlet 기반 요청이 아니면 거부
        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
        // 아무것도 안 해도 됨
        // afterHandshake 는 지금은 별도 로직 필요 없음 (빈 메서드)
    }

}
  • 이 구조로 만들면
    SecurityFilter 없이도 WebSocket 보안 적용 가능
    (기본 SecurityFilter는 WebSocket에 적용 안 되니까!)
  • Handshake 단계에서만 하지 말고
    → 추가로 ChannelInterceptor로 메시지 송수신 단계에서도 인증 체크 가능!

Q. WebSocket 연결 요청이 왜 Servlet 기반 HTTP 요청이어야 하는가 ?

이것은 WebSocket 인증 흐름을 알면 깊게 이해할 수 있다.

🌐 WebSocket 연결은 어떻게 동작할까?

1️⃣ WebSocket은 처음부터 WebSocket 프로토콜이 아니다

  • 클라이언트가 처음 연결 시도할 때는
    → HTTP 프로토콜을 이용해서 Handshake 요청을 보냄
  • 즉, 첫 연결은:
    GET /ws
    HTTP 요청으로 서버에 도착함

2️⃣ 서버는 이 요청을 받고 → "업그레이드" 결정

  • 서버는 HTTP 101 Switching Protocols 응답을 보냄
    → 이후 WebSocket 프로토콜로 전환
  • 그 후에는 WebSocket 프레임 기반으로 통신
    (이때부터는 HTTP 아님!)

🚩 왜 ServletServerHttpRequest 인지 확인해야 하는가?

1️⃣ HttpServletRequest에서 헤더를 읽으려고 하기 때문

  • JWT 토큰은 보통 HTTP 헤더 Authorization에 담아서 전송
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  • 이걸 읽으려면 → HttpServletRequest 객체가 필요

2️⃣ Spring WebSocket 지원 구조 때문

  • Spring에서 HandshakeInterceptor의 beforeHandshake 메서드는 파라미터로 ServerHttpRequest를 줌

  • 그런데 ServerHttpRequest는 여러 구현체가 있음:

    • ServletServerHttpRequest
      서블릿 기반 요청 (Tomcat, Jetty 등 서블릿 컨테이너 사용)
      → 우리가 원하는 경우!

    • 다른 구현체 → WebFlux 기반 등
      (Reactive Netty 기반 등)

→ 그래서 우리가 Servlet 기반일 때만 HttpServletRequest로 다운캐스팅 해서 헤더를 읽는 것

if (request instanceof ServletServerHttpRequest servletRequest) {
    HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
    String token = jwtUtil.extractToken(httpServletRequest);
}

✅ 흐름 정리

단계설명
클라이언트가 /ws 연결 시도HTTP 요청으로 Handshake 요청 보냄
HandshakeInterceptor의 beforeHandshake 실행ServerHttpRequest 객체 전달
우리가 헤더에서 JWT 읽으려면 → HttpServletRequest 필요→ Servlet 기반인지 확인 (ServletServerHttpRequest 인지 확인)
맞으면 HttpServletRequest로 변환 → 헤더에서 JWT 추출 후 검증인증 성공 여부에 따라 연결 허용/거부 결정

🔔 왜 이런 체크가 꼭 필요한가?

서블릿 기반이 아닐 때는 HttpServletRequest가 없으므로 헤더를 못 읽음
→ 방어 코드 필요

✅ 예를 들어 WebFlux 기반으로 서버가 구성되어 있을 경우 ServerHttpRequest 구현체가 다름
→ NullPointerException 발생 가능
그래서 안전하게 instanceof 체크 후 변환

질문답변
왜 Servlet 기반 요청이어야 하나요?JWT 토큰을 HTTP 헤더에서 읽어야 하고, 헤더는 HttpServletRequest를 통해서만 읽을 수 있기 때문
왜 instanceof 체크를 하나요?HandshakeInterceptor는 다양한 ServerHttpRequest 구현체를 받을 수 있고, 우리가 원하는 HttpServletRequest는 Servlet 기반에서만 사용 가능하므로 안전하게 확인 후 사용

팁 💡

1️⃣
나중에 WebFlux (Reactive)로 서버 구성하게 되면 HandshakeInterceptor 구현 방식이 달라짐
→ 주의!

2️⃣
클라이언트가 JWT 토큰을 쿼리 파라미터로 보내게 하면 헤더 없이도 쓸 수 있지만
→ 보안상 권장되지 않음 (URL 노출 가능)

3️⃣
→ 지금 구조는 Spring Boot + Spring MVC (서블릿 기반) 이므로 현재 로직이 적절!

Q. 왜 WebSocket에서는 "Access Token만 허용" 하는가 ?

토큰 종류용도특징
Access Token실제 API 호출, 리소스 접근 시 사용만료 시간 짧음 (예: 30분)
Refresh TokenAccess Token 재발급 받을 때 사용만료 시간 김 (예: 7일), 보안상 더 엄격하게 관리

🚩 왜 WebSocket에서는 "Access Token만 허용"하는가?

1️⃣ WebSocket 연결 목적은? → 실시간 서비스 사용 (ex. 채팅방)

  • WebSocket 연결 = 실시간 기능에 직접 접근하는 것
    → 실제 서비스 이용 중

  • 즉, WebSocket 연결은 "인가된 사용자(로그인된 사용자)"가 이용해야 함

    • 로그인되어 있음을 증명하는 건 → Access Token

2️⃣ Refresh Token의 목적은?

  • Access Token이 만료되었을 때 "재발급" 요청할 때만 사용
  • Refresh Token 자체로는 서비스 리소스 접근 권한이 없음!
    • Refresh Token = "새 Access Token 달라고 요청할 때만 쓰는 토큰"
    • 만약 Refresh Token으로 WebSocket 연결이 허용되면
      → 보안상 큰 문제 발생 (권한 없는 연결 허용됨)

Q. afterHandshake 는 왜 별도 로직이 필요 없는가 ?

🚩 각 메서드 역할

메서드언제 호출?주로 하는 일
beforeHandshakeWebSocket 연결 시도 직전인증 검사, 속성 설정, 연결 허용 여부 결정
afterHandshakeWebSocket 연결 성공 직후주로 로깅, 감사 기록, 부가 처리

✅ 지금 구조에서 왜 afterHandshake는 필요 없나?

지금 내 코드의 흐름은:

1️⃣ beforeHandshake에서 JWT 검증
→ 연결 허용 여부 결정 (return true/false)
2️⃣ JWT 검증 후 UserAuth를 attributes에 저장
3️⃣ 연결 성공 시 → WebSocket 연결이 정상 성립됨

그럼 afterHandshake에서는 뭘 더 할 필요가 있을까?
→ 지금 단계에서는 딱히 추가할 로직이 없음

  • JWT 인증은 이미 beforeHandshake에서 끝났음
  • 사용자 정보는 attributes에 저장됨
    → 이후 WebSocket 세션에서 사용 가능
  • 특별한 후처리(로깅, 알림, 모니터링)를 지금 요구하지 않음

📌 afterHandshake를 "언제 쓰면 좋은가?"

쓰는 경우예시
감사 로깅(Audit Logging)"누가 언제 WebSocket 연결했는지" 로그 기록
메트릭 수집WebSocket 연결 수 모니터링 (Prometheus 연동 등)
연결 성공 후 알림 발송관리자 알림, 슬랙 메시지 등
연결 성공 후 트랜잭션 처리예: DB에 연결 로그 저장 등

WebSocketConfig

public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final JwtUtil jwtUtil;

    /* 클라이언트가 최초로 WebSocket 연결을 시도할 때 접속할 엔드포인트 설정 */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 	서버 내부 엔드포인트 경로 설정 + CORS 설정 + SockJS 프로토콜 지원 추가 (fallback)
        registry.addEndpoint("/ws")
                .addInterceptors(new JwtHandshakeInterceptor(jwtUtil)) // 핸드셰이크 단계에서 JWT 토큰을 검증해서 인증된 사용자만 WebSocket 연결을 허용
                .setAllowedOrigins("*")
                .withSockJS();
    }
}    

JwtHandshakeInterceptor에서 Authorization 헤더 또는 쿼리파라미터로 토큰 받아서 검증


🌐 WebSocket 연결 흐름 단계별 도식화

[ 클라이언트 (브라우저, 앱) ]
        |
        | 1️⃣ WebSocket 연결 시도
        |    HTTP GET /ws + Upgrade: websocket
        v
[ 서버 (Spring Boot) ]

    ┌────────────────────────────┐
    │ registerStompEndpoints()   │  → /ws 엔드포인트 등록
    └────────────────────────────┘
              |
              | 2️⃣ Handshake 단계 시작
              v
    ┌──────────────────────────────────────────┐
    │ JwtHandshakeInterceptor.beforeHandshake() │  → JWT 인증 검증
    └──────────────────────────────────────────┘
              |
              | 3️⃣ Handshake 성공 시 (HTTP 101 Switching Protocols)
              v
    [ WebSocket 연결 성립 (TCP 업그레이드 완료) ]
              |
              | 4️⃣ 이후 실시간 통신 (WebSocket 프레임)
              v
    ┌────────────────────────────┐
    │ @MessageMapping             │  → 서버가 메시지 핸들링
    └────────────────────────────┘
              |
              | (선택) 추가 보안:
              v
    ┌────────────────────────────────────────┐
    │ ChannelInterceptor.preSend(), postSend()│ → 메시지 레벨 인증/인가 가능
    └────────────────────────────────────────┘

0개의 댓글