채팅 토이 프로젝트를 진행하면서 사용자 인증/인가를 위해 `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` 필터가 작동하여 클라이언트의 요청을 검사한다.
- 클라이언트가 `WebSocket` 연결을 요청: 클라이언트가 `WebSocket` 엔드포인트(`/ws-stomp`와 같은 URL)에 연결을 시도하면, `HTTP` 프로토콜을 통해 `Upgrade` 요청을 보낸다. 이 단계는 아직 `WebSocket` 연결이 아니다.
- `Spring Security`의 `HTTP` 필터가 `Handshake` 요청 처리:
- `congifure(HttpSecurity http)` 메서드를 통해 설정된 `Spring Security HTTP` 필터가 요청을 가로채고, 요청이 보안 규칙을 만족하는지 검사한다.
- 여기서 인증이 필요하면, `Security` 설정에 따라 `JWT`나 세션 정보로 인증을 진행한다.
- 이 단계에서 `WebSocket` 경로에 관한 허용 설정이 필요하다.
- 예를 들어, `/ws-stomp` 경로를 허용하지 않으면 보안 설정에 의해 `Handshake` 요청이 차단되어 연결이 실패한다.
- `Handshake`가 성공하면 `WebSocket` 연결이 열림: `Spring Security`의 필터가 요청을 허용하면 `Handshake`가 완료되고 `WebSocket` 연결이 열리며, 클라이언트는 `WebSocket`을 통해 메시지를 주고받을 수 있게 된다.
2. WebSocket 통신 단계
`Handshake`가 완료된 후, `STOMP` 메시지(`CONNECT`, `SUBSCRIBE`, `SEND`)가 `WebSocket` 연결을 통해 전송된다.
이때, `ChannelInterceptor`가 메시지를 가로채어 인증과 인가를 수행한다.
- 클라이언트가 `STOMP` 메시지 전송(`CONNECT` 메시지): 클라이언트는 연결을 설정한 후, `CONNECT` 메시지를 전송하여 `STOMP` 프로토콜을 초기화한다. 이때, 메시지의 헤더에 `JWT` 토큰을 포함하여 전송할 수 있다.
- `AuthentiionChannelInterceptor`가 STOMP 메시지 가로채기:
- `configureClientInboundChannel` 메서드를 통해 `ChannelInterceptor`를 등록한 경우, 모든 `STOMP` 메시지가 `AuthenticationInterceptor`에서 가로채진다.
- `preSend` 메서드에서 `CONNECT` 메시지의 헤더를 확인하고, `JwtProvider`를 사용해 토큰의 유효성을 검사한다.
- 만약 토큰이 유효하지 않다면, 예외를 던져 연결을 거부한다.
- 메시지 처리 및 전달: `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
'Spring > WebSocket' 카테고리의 다른 글
[Spring WebSocket] STOMP에서 Kafka 활용 Flow (0) | 2024.11.11 |
---|---|
[Spring WebSocket] STOMP에서의 예외처리 (0) | 2024.11.11 |
[Spring WebSocket] MongoDB Collection 설계 With Auto-Incremented Sequence (9) | 2024.11.10 |
[Spring WebSocket] 채팅 서비스 프로젝트에 Kafka 적용 (2) | 2024.11.10 |
[Spring WebSocket] STOMP를 활용한 채팅 서비스 토이 프로젝트 (4) | 2024.11.10 |