WebSocket에 Spring Security, JWT 적용해보기
목차
적용 계기
entity에 생성일과 생성한 사용자 등을 관리하려는 중 WebSocket 통신으로 이루어진 채팅 메세지는 생성한 사용자 auditing이 안되는 현상 을 발견하게 되는데...

http 통신으로 이루어진 Chat Room 등록은 user auditing이 잘 되는데

websocket으로 이루어진 Chat Message는 user auditing이 되지 않는다..
mongoDB를 사용중이기에 @EnablieMongoAuditing이 걸려있었고 사용자 정보는 아래와 같이 SecurityContext에서 가지고 오고 있었다.
Java
public class SpringSecurityAuditorAware implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getPrincipal)
.map(CustomUserDetails.class::cast)
.map(CustomUserDetails::getUserId)
;
}
}
확인해보니 SecurityContextHolder.getContext()가 null 이어서 못 가지고 온 것이었다.
원인
- http와 WebSocket의 Security chain, config는 완전히 독립적.
- 기존 AuthenticationProvider는 Websocket Authentication에 관여하지 않는다.
- Websocket에 CONNECT 되면 해당 user는 websocket session에 저장되며 이후 메세지에는 인증 과정을 거치지 않는다.
기존 상태
WebSocket CONNECT 시에만 JWT를 통해서 인증하고 이후에는 검증하지 않았다.
- WebSocketInterceptor.java
Java
public class WebSocketInterceptor implements ChannelInterceptor {
private final JwtProvider jwtProvider;
@SneakyThrows
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor.getCommand() == StompCommand.CONNECT) {
String authToken = accessor.getFirstNativeHeader("Authorization");
if (authToken == null || !jwtProvider.validateJwt(authToken)) {
throw new AuthException("Authentication failed!!");
}
}
return message;
}
}
- WebSocketConfig.java
Java
@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketInterceptor webSocketInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/sub");
config.setApplicationDestinationPrefixes("/pub");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketInterceptor);
}
}
적용하기
- 먼저 build.gradle에 해당 dependency를 추가해준다
implementation("org.springframework.security:spring-security-messaging") AbstractSecurityWebSocketMessageBrokerConfigurer를 상속받은SecurityWebSocketConfig를 작성한다.
Java
@Configuration
public class SecurityWebSocketConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry message) {
message
.nullDestMatcher().permitAll()
.simpDestMatchers("/pub/**").authenticated()
.simpSubscribeDestMatchers("/sub/**").authenticated()
.anyMessage().denyAll()
;
}
@Override
protected boolean sameOriginDisabled() {
return true;
}
}
- nullDestMatcher()
- CONNECT, HEARTBEAT 등은 permitAll()로 열여준다.
- simpDestMatchers()
- destination이 /pub/** 인 메세지는 인증 된 사용자(authenticated())만 전송 가능
- simpSubscribeDestMatchers()
- 인증 된 사용자만 /sub/** subscribe 가능
- anyMessage()
- 이외의 다른 모든 message는 허용하지 않는다 (denyAll())
- sameOriginDisabled()
- 개발 중 CSRF 비활성화를 위해 true로 설정
- WebSocketInterceptor 수정
Java
public class WebSocketInterceptor implements ChannelInterceptor {
private final JwtProvider jwtProvider;
@SneakyThrows
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor.getCommand() == StompCommand.CONNECT) {
String authToken = accessor.getFirstNativeHeader("Authorization");
if (authToken == null || !jwtProvider.validateJwt(authToken)) {
throw new AuthException("Authentication failed!!");
}
// UsernamePasswordAuthenticationToken 발급
UsernamePasswordAuthenticationToken authentication = jwtProvider.getAuthentication(authToken);
// accessor에 등록
accessor.setUser(authentication);
}
return message;
}
}
- CONNECT 시에 JWT 검증을 통과한 뒤 해당 토큰을 바탕으로 UsernamePasswordAuthenticationToken을 발급해 accessor의 user로 등록해준다.
- WebSocketConfig 수정
Java
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketInterceptor webSocketInterceptor;
...
}
- @Order(Ordered.HIGHEST_PRECEDENCE + 99) 를 통해 WebSocketInterceptor가 먼저 처리될 수 있도록 설정
결과

