본문 바로가기
Spring/WebSocket

[Spring WebSocket] Spring Security + STOMP

by 진꿈청 2024. 11. 11.

https://asfirstalways.tistory.com/359

 

 

채팅 토이 프로젝트를 진행하면서 사용자 인증/인가를 위해 `Spring Security`도 사용을 하였다.

 

하지만, 채팅 서비스는 `WebSocket` 위에서 동작하기에

커넥션이 맺어진 이후에는 `Security Filter`를 타지 않는다.

 

따라서, 이와 관련해서 `JWT` 검증을 다르게 동작시켜야만 한다. 

 

 

우선, `Spring Security`를 이용해 `WebSocket` 연결을 보호할 때, 인증 및 인가가 이루어지는 순서를 살펴보자.

 

이 과정에서는 `HandShake`와 `WebSocket` 통신 두 가지 주요 단계가 있다.

 

각 단계에서 `Spring Security` 필터와 `ChanneInterceptor`가 어떻게 작동할까?

 

 

1. Handshake 단계

`Handshake` 단계는 `TCP 3 way handshake` 이후 `HTTP` 프로토콜로 `WebScoekt` 연결을 초기화하는 과정이다.

 

여기서 실제 연결 요청이 수락되기 전에, `Spring Security` 필터가 작동하여 클라이언트의 요청을 검사한다.

 

  1. 클라이언트가 `WebSocket` 연결을 요청: 클라이언트가 `WebSocket` 엔드포인트(`/ws-stomp`와 같은 URL)에 연결을 시도하면, `HTTP` 프로토콜을 통해 `Upgrade` 요청을 보낸다. 이 단계는 아직 `WebSocket` 연결이 아니다.
  2. `Spring Security`의 `HTTP` 필터가 `Handshake` 요청 처리:
    • `congifure(HttpSecurity http)` 메서드를 통해 설정된 `Spring Security HTTP` 필터가 요청을 가로채고, 요청이 보안 규칙을 만족하는지 검사한다.
    • 여기서 인증이 필요하면, `Security` 설정에 따라 `JWT`나 세션 정보로 인증을 진행한다.
    • 이 단계에서 `WebSocket` 경로에 관한 허용 설정이 필요하다.
      • 예를 들어, `/ws-stomp` 경로를 허용하지 않으면 보안 설정에 의해 `Handshake` 요청이 차단되어 연결이 실패한다.
  3. `Handshake`가 성공하면 `WebSocket` 연결이 열림: `Spring Security`의 필터가 요청을 허용하면 `Handshake`가 완료되고 `WebSocket` 연결이 열리며, 클라이언트는 `WebSocket`을 통해 메시지를 주고받을 수 있게 된다.

 

2. WebSocket 통신 단계

`Handshake`가 완료된 후, `STOMP` 메시지(`CONNECT`, `SUBSCRIBE`, `SEND`)가 `WebSocket` 연결을 통해 전송된다.

 

이때, `ChannelInterceptor`가 메시지를 가로채어 인증과 인가를 수행한다.

 

  1. 클라이언트가 `STOMP` 메시지 전송(`CONNECT` 메시지): 클라이언트는 연결을 설정한 후, `CONNECT` 메시지를 전송하여 `STOMP` 프로토콜을 초기화한다. 이때, 메시지의 헤더에 `JWT` 토큰을 포함하여 전송할 수 있다.
  2. `AuthentiionChannelInterceptor`가 STOMP 메시지 가로채기:
    • `configureClientInboundChannel` 메서드를 통해 `ChannelInterceptor`를 등록한 경우, 모든 `STOMP` 메시지가 `AuthenticationInterceptor`에서 가로채진다.
    • `preSend` 메서드에서 `CONNECT` 메시지의 헤더를 확인하고, `JwtProvider`를 사용해 토큰의 유효성을 검사한다.
    • 만약 토큰이 유효하지 않다면, 예외를 던져 연결을 거부한다.
  3. 메시지 처리 및 전달: `CONNECT` 메시지가 성공적으로 검증되면 클라이언트는 `WebSocket` 세션 내에서 다른 STOMP 메시지를 (예: `SUBSCRIBE`, `SEND`) 주고받을 수 있다. 이 과정에서 모든 메시지는 `ChannelInterceptor`를 통해 가로채어 추가 검증이나 로깅을 수행할 수 있다.

 

즉, 정리하자면 `WebSocket` 연결 이후에는 `Security Filter`가 적용되지 않는다.

  • `WebSocket` 연결은 HTTP 핸드셰이크를 통해 처음 연결될 때만 `Security Filter`를 거친다.
  • `WebSocket`이 연결된 후의 메시지들은 기존의 `Spring Security Filter Chain`을 통과하지 않는다.
  • 다라서, `WebSocket` 연결 이후의 STOMP 메시지에 대해서는 `Security Filter`가 적용되지 않는다. 

 

 

 

관련 코드

 

AuthenticationChannelInterceptor

 

`WebSocket`에서 인증 로직을 담당하는 `Interceptor`이다.

@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class AuthenticationChannelInterceptor implements ChannelInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
//        System.out.println("111111111");
        validateTokenOnConnect(headerAccessor);
        return message;
    }

    @Override
    public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
        ChannelInterceptor.super.postSend(message, channel, sent);
    }

    private void validateTokenOnConnect(StompHeaderAccessor headerAccessor) {
        if(StompCommand.CONNECT.equals(headerAccessor.getCommand())){
            String accessToken = Objects.requireNonNull(
                    headerAccessor.getFirstNativeHeader(JwtProvider.AUTHORIZATION_HEADER));

            if(!jwtProvider.validateToken(accessToken)){
                throw new JwtException(Code.UNAUTHORIZED, "Jwt Token Error");
            }
        }
    }
}

 

여기서 `@Order(Ordered.HIGHEST_PRECEDENCE + 99)`는 실행 우선순위를 결정하는 어노테이션이다.

 

이는 다른 `ChannelInterceptor` 보다 우선순위를 높게 지정하므로, 인증이 필요한 메시지를 가장 먼저 검사하기 위함이다.

 

 

StompConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {

    private final AuthenticationChannelInterceptor authenticationChannelInterceptor;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/ws/chat");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp")
                .setAllowedOrigins("*");

        registry.addEndpoint("/ws-stomp")
                .setAllowedOriginPatterns("*")
                .withSockJS();

    }

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

 

`STOMP` 클라이언트로 등록하기 위한 경로는 `/ws-stomp`이다.

 

 

SecurityConfig

 

허용할 경로에 `STOMP`와 관련된 경로를 허용해주자.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;
    private final ObjectMapper objectMapper;
    private final RedisService redisService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{

        httpSecurity.httpBasic(HttpBasicConfigurer::disable)
                .headers((headerConfig) -> headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
                .formLogin(FormLoginConfigurer::disable)
                .logout(LogoutConfigurer::disable)
                .sessionManagement(sessionConfigurer -> sessionConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(entryPointConfigurer -> entryPointConfigurer.authenticationEntryPoint(new CustomAuthenticationEntryPoint()))
                .exceptionHandling(accessDeniedHandler -> accessDeniedHandler.accessDeniedHandler(new CustomAccessDeniedHandler()))
                .with(new CustomFilterConfigurer(), Customizer.withDefaults())
                .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) // ⭐️⭐️⭐️
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize ->
                        authorize
                                .requestMatchers(UrlUtil.PERMITTED_URLS.toArray(new String[0])).permitAll()
                                .anyRequest().hasAnyRole("USER", "ADMIN")
                );

        return httpSecurity.build();
    }
    
    ...
}

 

 

UrlUtil

public class UrlUtil {
    
    ...

    public static final List<String> PERMITTED_URLS = List.of(
            "/user/signup", "/auth/reissue", "/ws-stomp", "/ws/**"
    );
    
    ...
}

 

 

정리

`WebSocket`을 사용한 인증/인가 처리는 기존의 `HTTP` 방식과 사뭇 다르게 작동한다.

(연결 작업에서만 `Spring Security Filter Chain`을 탄다.)

 

따라서, 적절하게 `Interceptor` 설정을 해주어야 `JWT` 인증/인가가 가능하다. 

 

해당 작업을 통해 좀 더 스프링의 `Filter` 동작 과정이나 `Security`에 관해 더 잘 알게 되었다.

 

 

코드를 확인하고 싶다면?

https://github.com/HanYoonSoo/STOMP-Study

 

GitHub - HanYoonSoo/STOMP-Study

Contribute to HanYoonSoo/STOMP-Study development by creating an account on GitHub.

github.com