이번 포스팅에서는 인터셉터 설정을 통한 인증 및 메시지 전처리에 대해서 알아보도록 하겠습니다.
우선 WebSocket
과 STOMP
의 관계, WebSocket
과 스프링 시큐리티 필터와의 관계에 대해서 간단하게 알아보고 인터셉터 설정을 해보도록 하겠습니다.
먼저 WebSocket
과 Stomp
에 대해서 살펴보도록 하겠습니다.
WebSocket
은 데이터를 전송하는 방법(전송계층)을 제공하는 역할을 하지만 STOMP
는 데이터가 어떻게 구성되고, 어떻게 처리되어야 하는지에(응용 계층) 대한 규칙과 형식을 제공합니다.
STOMP
는 WebSocket
위에서 동작할 수 있는 프로토콜입니다. STOMP
를 사용함으로써, WebSocket
연결을 통해서 전달되는 메시지를 보다 구조화된 방식으로 주고받을 수 있게 됩니다.
STOMP
를 이용한 통신의 흐름은 다음과 같습니다.
발신자는 STOMP
프로토콜을 사용하여 메시지의 형식을 맞추고, 이 메시지를 WebSocket
연결을 통해 전달합니다.
수신자는 WebSocket
을 통해 해당 메시지를 수신하게 되는데, 메시지 내용은 STOMP
프로토콜을 따르는 형식으로 구성되어있습니다.
STOMP
는 메시지를 구독하고 발행하며, 헤더를 통해 추가적인 정보를 전달할 수 있는 기능 등을 제공하여 메시지 기반 통신을 보다 풍부하고 유연하게 만들어주는 역할을 합니다.
WebSocket
은 이러한 메시지가 실시간으로 효율적으로 전송될 수 있는 통로를 제공해줍니다.
이번에는 WebSocket
과 스프링 시큐리티 필터의 관계를 살펴보도록 하겠습니다.
WebSocket
통신을 연결할 때 HTTP
요청을 통해 초기 WebSocket Handshaking
이 이루어집니다. 해당 과정에서 스프링 시큐리티 필터를 거치게 됩니다.
초기 Handshaking
단계에서 사용자 인증과 권한 부여를 수행할 수 있고, 이를 통해 WebSocket
연결 전에 사용자가 인증되고 적절한 권한을 가지고 있는지 검증이 가능합니다.
하지만 WebSocket
으로 전달된 헤더 정보를 스프링 시큐리티에서 사용할 수 없습니다.
사용자 인증 정보(토큰 등)을 헤더 정보에 담아서 보낼 때는 HandshakeInterceptor
라는 인터셉터를 통해서 handshake
요청을 가로채서 헤더 정보를 꺼내와서 인증 처리를 해줄 수 있습니다.
스프링 프레임워크에서 WebSocket
과 STOMP
를 사용할 때 이용할 수 있는 인터셉터는 두 가지 종류가 있습니다.
HandshakeInterceptor
는 WebSocket
초기 연결의 handshake
과정에서 동작하는 인터셉터입니다.
클라이언트가 WebSocket
연결을 시도할 때 초기 HTTP
요청을 가로채어 추가적인 검사나 수정을 할 수 있습니다. 예를 들어, 사용자 인증을 검사하거나, HTTP 헤더를 수정하거나 추가하는 등의 작업을 할 수 있습니다.
또한 handshake
단계에서 연결을 거부할 수 있는 기능도 제공합니다. 이를 통해 사용자가 조건을 만족하지 못할 경우 WebSocket
연결 자체를 막을 수 있습니다.
// HandShakeInterceptor 생성
@Slf4j
public class HttpHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// 여기서 request 헤더 또는 파라미터에서 인증 정보를 추출하고 검증할 수 있습니다.
return true; // 인증에 성공하면 true를 반환하여 연결을 계속 진행합니다.
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
// 핸드셰이크 후 처리
}
}
// 인터셉터 등록해주기
@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}
**@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
System.out.println("WebSocket endpoint registered");
registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").withSockJS().setInterceptors(new HttpHandshakeInterceptor());
}**
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
ChannelInterceptor
는 WebSocket
연결이 성공한 후, 실제 메시지가 송수신될 때 동작하는 인터셉터입니다.
메시지를 보내거나 받을 때 이를 가로채어 메시지의 내용을 수정하거나 검증하는 등의 처리를 할 수 있습니다. 예를 들어, 특정 사용자의 메시지만 필터링하거나, 메시지 내용에 따라 다른 동작을 수행하게 할 수 있습니다.
@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
// 내용 생략 .......
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
package com.websocket.chat.config.handler;
// import ... 생략
@Slf4j
@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
// websocket을 통해 들어온 요청이 처리 되기전 실행된다.
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT == accessor.getCommand()) { // websocket 연결요청
String hi = accessor.getFirstNativeHeader("hi");
String bye = accessor.getFirstNativeHeader("bye");
log.info("CONNECT {}", hi);
log.info("CONNECT {}", bye);
} else if (StompCommand.SUBSCRIBE == accessor.getCommand()) { // 채팅룸 구독요청
System.out.println("message: ");
System.out.println(message);
// header정보에서 구독 destination정보를 얻고, roomId를 추출한다.
String roomId = chatService.getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId"));
// 채팅방에 들어온 클라이언트 sessionId를 roomId와 맵핑해 놓는다.(나중에 특정 세션이 어떤 채팅방에 들어가 있는지 알기 위함)
String sessionId = (String) message.getHeaders().get("simpSessionId");
chatRoomRepository.setUserEnterInfo(sessionId, roomId);
// 채팅방의 인원수를 +1한다.
chatRoomRepository.plusUserCount(roomId);
// 클라이언트 입장 메시지를 채팅방에 발송한다.(redis publish)
String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.ENTER).roomId(roomId).sender(name).build());
log.info("SUBSCRIBED {}, {}", name, roomId);
} else if (StompCommand.DISCONNECT == accessor.getCommand()) { // Websocket 연결 종료
// 연결이 종료된 클라이언트 sesssionId로 채팅방 id를 얻는다.
String sessionId = (String) message.getHeaders().get("simpSessionId");
String roomId = chatRoomRepository.getUserEnterRoomId(sessionId);
// 채팅방의 인원수를 -1한다.
chatRoomRepository.minusUserCount(roomId);
// 클라이언트 퇴장 메시지를 채팅방에 발송한다.(redis publish)
String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.QUIT).roomId(roomId).sender(name).build());
// 퇴장한 클라이언트의 roomId 맵핑 정보를 삭제한다.
chatRoomRepository.removeUserEnterInfo(sessionId);
log.info("DISCONNECTED {}, {}", sessionId, roomId);
}
return message;
}
}
위의 코드에서는 StompCommand
상태에 따라 전처리 작업을 하도록 분기 처리를 하는 로직입니다.
WebSocket
연결이 성립된 후의 메시지 교환 과정에서는 HTTP
요청과는 다르게 스프링 시큐리티 필터 체인을 거치지 않습니다.
대신 스프링에서 인터셉터(Interceptors
) 또는 채널 인터셉터(Channel Interceptors
)를 사용하여 WebSocket
메시지에 대한 보안 로직 및 추가 작업을 진행할 수 있습니다.