문제&해결

Spring WebSocket 애플리케이션에 Spring Security 적용하기(simpUser, Interceptor, handler)

mint* 2024. 5. 15. 17:31
728x90

서론

프로젝트에서 실시간 채팅 기능을 구현하면서 WebSocket을 사용시 JWT를 인증을 처리하는 방법을 작성해보았습니다.

혹시 글을 읽고 해결이 안되시는 분들은 댓글로 남겨주시면 도와드릴게요 !!

 

라이브러리 버전

  • Spring boot 3.2.5
  • Spring security 3.2.5
  • jwt 0.12.3
  • websocket 10.1.20
  • spring security messaging 6.2.4

 

build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-websocket'

    implementation 'org.springframework.boot:spring-boot-starter-security'    
    implementation 'org.springframework.security:spring-security-messaging'

 

WebSocket 설정

WebSocket 메시지 브로커를 활성화하고 엔드포인트, prefix, CORS 설정, 인증 인터셉터를 등록하는 설정 클래스입니다.

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

    private static final String ENDPOINT = "/ws";  
    private static final String SIMPLE_BROKER = "/topic";  
    private static final String PUBLISH = "/app";  

    private final JwtChannelInterceptor jwtChannelInterceptor;  

    @Override  
    public void configureMessageBroker(MessageBrokerRegistry registry) {  
        registry.enableSimpleBroker(SIMPLE_BROKER);  
        registry.setApplicationDestinationPrefixes(PUBLISH);  
    }  

    @Override  
    public void registerStompEndpoints(StompEndpointRegistry registry) {  
        registry.addEndpoint(ENDPOINT)  
                .setAllowedOriginPatterns("*");  
    }  

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

 

Spring Security 기본 설정

  • 웹소켓 엔드포인트("ws/")는 인증을 진행하지 않고 허용(permitAll()) 하도록 설정합니다. 
    • 웹소켓 설정은 따로 설정 클래스를 만들어 진행합니다.
  • 개발 환경에 따라 cors 허용 origin을 분리할 수 있도록 @Value로 외부에서 값을 받아옵니다.
    • 테스트 도구인 Apic을 사용할 경우 cors 설정은 전체 허용(*)으로 두어야 테스트가 가능합니다.
    • 웹소켓 요청도 브라우저 입장에서는 다른 도메인으로의 요청으로 인식되기 때문입니다.(프로토콜이 다릅니다.)
@Configuration  
@EnableWebSecurity  
@RequiredArgsConstructor  
public class SecurityConfig {  
  
    public static final String[] PUBLIC_URLS = {  
			...
            "/ws/**",  
			...
    };
    ...
    @Value("${cors.allowed-origins}")  
	private String[] allowedOrigins;

	@Bean  
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
	  
	    http  
	            .cors((cors) -> cors  
	                    .configurationSource(request -> {  
	                        CorsConfiguration configuration = new CorsConfiguration();  
	                        configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins));  
	                        configuration.setAllowedMethods(Collections.singletonList("*"));  
	                        configuration.setAllowCredentials(true);  
	                        configuration.setAllowedHeaders(Collections.singletonList("*"));  
	                        configuration.setMaxAge(3600L);  
	                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));  
	                        return configuration;  
	                    }));
		..

		http  
        .authorizeHttpRequests((auth) -> auth  
                .requestMatchers(PUBLIC_URLS).permitAll()  
                .anyRequest().authenticated()  
        );
}

 

application-local.yml

cors:  
  allowed-origins: "*"

 

웹소켓 경로별 인가 설정

@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages
                .nullDestMatcher().permitAll()
                .simpDestMatchers("/app/**").authenticated()
                .simpSubscribeDestMatchers("/topic/**").authenticated()
                .anyMessage().denyAll();
    }

    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
}
  • nullDestMatcher().permitAll() : 목적지가 null이면 허용 (초기 메세지)
  • simpDestMatchers("/app/** ").authenticated() :  인증된 사용자만 /app 접근 가능
  • simpSubscribeDestMatchers("/topic/** ").authenticated() : 인증된 사용자만 구독 url 접근 가능
  • anyMessage().denyAll() : 그 외 메세지는 deny(거부)

 

AbstractSecurityWebSocketMessageBrokerConfigurer를 상속받는 대신, @EnableWebSocketSecurity을 사용해도 됩니다. 
하지만 그럴경우 SOP 비활성화를 따로 해줄 수 없습니다.

 

JWT 인증 인터셉터 구현

@Component  
public class JwtChannelInterceptor implements ChannelInterceptor {  
  
    public static final String AUTHORIZATION = "Authorization";  
    public static final String BEARER_ = "Bearer ";  
  
    @Override  
    public Message<?> preSend(Message<?> message, MessageChannel channel) {  
  
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);  
  
        if (!StompCommand.CONNECT.equals(accessor.getCommand())) {  
            return message;  
        }  
  
        Optional<String> jwtTokenOptional = Optional.ofNullable(accessor.getFirstNativeHeader(AUTHORIZATION));  
        String jwtToken = jwtTokenOptional  
                .filter(token -> token.startsWith(BEARER_))  
                .map(token -> token.substring(BEARER_.length()))  
                .filter(token -> !isExpired(token))  
                .orElseThrow(() -> new RuntimeException("Invalid token"));  
  
        String userId = getId(jwtToken);  
        String userRole = getRole(jwtToken);  
  
        Authentication authentication = createAuthentication(userId, userRole);  
        accessor.setUser(authentication);  
  
        return message;  
    }  
}

웹소켓 연결 및 웹소켓 메세지에 대한 인증 처리를 할 수 있는 ChannelInterceptor를 구현합니다.

 

  • 인증 객체를 simpUser 헤더에 추가합니다 (setUser())
  • Spring Security는 simpUser 헤더에서 추출한 사용자 정보를 SecurityContextHolder에 저장합니다.
  • @AuthenticationPrincipal 애노테이션을 사용하여 현재 인증된 사용자의 정보를 가져올 수 있습니다.
공식 문서 : SecurityContextHolder는 인바운드 요청에 대한 simpUser 헤더 속성 내의 사용자로 채워집니다. 

https://docs.spring.io/spring-security/site/docs/4.2.x/reference/html/websocket.html#websocket-authorization

 

23. WebSocket Security

SockJS provides fallback transports to support older browsers. When using the fallback options we need to relax a few security constraints to allow SockJS to work with Spring Security. 23.5.1 SockJS & frame-options SockJS may use an transport that leverag

docs.spring.io

 

인증 정보 접근하기

@AuthenticationPrincipal로 Authentication에 접근할 수 있습니다.

@RestController  
@RequiredArgsConstructor  
public class ChatController {  
  
    private final ChatCommandService chatCommandService;  
  
    @MessageMapping("/agoras/{agora-id}/chats")  
    @SendTo(value = "/topic/agoras/{agora-id}/chats")  
    public SendChatResponse sendChat(@DestinationVariable("agora-id") Long agoraId,  
                                     @Payload SendChatRequest sendChatRequest,  
                                     @AuthenticationPrincipal UserDetails userDetails) {  
  
        return chatCommandService.sendChat(userDetails, agoraId, sendChatRequest);  
    }  
}

 

 

웹소켓과 별개로 동작하는 빈

웹소켓과 관련이 없는 빈이어도 SecurityContextHolderAuthentication이 저장되어 있으므로 따로 웹소켓을 위한 설정을 하지 않아도 됩니다.

@EnableJpaAuditing  
@Configuration  
public class JpaConfig {  
  
    @Bean  
    public AuditorAware<String> auditorAware() {  
        return this::getPrinciple;  
    }  
  
    private Optional<String> getPrinciple() {  
  
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();  
        if (authentication == null || !authentication.isAuthenticated()) {  
            throw new IllegalStateException("AuditorAware : principal is missing");  
        }  
  
        Object principal = authentication.getPrincipal();  
        if (principal instanceof CustomUserDetails) {  
            return Optional.of(((CustomUserDetails) principal).getUsername());  
        }  
  
        return Optional.of(principal.toString());  
    }  
}

 

웹소켓 EventHandler

초기 연결시에는 SecurityContextHolder에 인증 객체가 없으므로 accessor에 접근하여 인증 정보를 가져옵니다.

@Slf4j  
@Component  
public class WebSocketEventHandler {  
  
    @EventListener  
    public void handleWebSocketSessionConnect(SessionConnectEvent event) {  
        logConnectEvent(event);  
    }  
  
    private void logConnectEvent(SessionConnectEvent event) {  
  
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(event.getMessage(), StompHeaderAccessor.class);  
  
        Authentication authentication = (Authentication) Objects.requireNonNull(accessor.getUser());  
        String username = authentication.getName();  
        String userRole = authentication.getAuthorities()  
                .stream()  
                .findFirst()  
                .map(GrantedAuthority::getAuthority)  
                .orElseThrow(() -> new IllegalArgumentException("User role is not exist."));  
  
        log.info("WebSocket {}: username={}, userRole={}", event.getClass().getSimpleName(), username, userRole);  
    }  
  
    @EventListener(SessionConnectedEvent.class)  
    public void handleWebSocketSessionConnected() {  
        log.info("WebSocket Connected");  
    }  
  
    @EventListener  
    public void handleWebSocketSessionDisconnected(SessionDisconnectEvent event) {  
        log.info("WebSocket Disconnected");  
    }  
  
    @EventListener(SessionSubscribeEvent.class)  
    public void handleWebSocketSessionSubscribe() {  
        log.info("WebSocket Subscribe");  
    }  
  
    @EventListener(SessionUnsubscribeEvent.class)  
    public void handleWebSocketSessionUnsubscribe() {  
        log.info("WebSocket Unsubscribe");  
    }  
}

 

 

 

마무리

ThreadLocal, 세션 이용해서 인증 체계를 구현한 후 

Spring Security도 웹소켓 보안 설정을 지원한다는 것을 공식 문서를 보며 뒤늦게 깨달았습니다.

결국엔 수동으로 인증 체계를 구축하지 않고 편리하게 적용하는 방법이 있었네요.

공식 문서의 중요성을 다시 한번 깨닫습니다..하하

 

그 후 블로그 글을 수정하는 작업을 거치고 Spring Security를 활용해 좀 더 완성도 있는 기능을 만들 수 있었습니다.

궁금하신 점이나 보완할 부분이 있다면 댓글로 알려주세요! 감사합니다~

 

아래 내용은 이 전 구현시 SpringSecurity의 websocket 설정을 사용하지 않고 수동으로 구현한 내용인데
웹소켓 다른 부분에 적용할 부분이 있을 수 있기 때문에 남겨두도록 하겠습니다.

 

 


ThreadLocal을 이용한 인증 정보 공유

웹소켓 처리 과정와 별개로 동작하는 빈에서 웹소켓 세션 정보 얻기

웹소켓을 통해 전송한 메세지를 데이터베이스에 저장한다고 가정해보겠습니다. (실제로 프로젝트 상황이었습니다.)
데이터베이스에 저장되는 엔티티에는 createdBy 속성이 있어서 세션 인증 정보를 가져와 저장할 필요가 있었습니다.

@EnableJpaAuditing  
@Configuration  
public class JpaConfig {  

    @Bean  
    public AuditorAware<String> auditorAware() {  
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())  
                .map(SecurityContext::getAuthentication)  
                .filter(Authentication::isAuthenticated)  
                .map(Authentication::getPrincipal)  
                .map(principle -> principle instanceof CustomUserDetails  
                        ? ((CustomUserDetails) principle).getUsername()  
                        : principle.toString());  
    }  
}

 

문제는 데이터베이스에 저장하는 로직은 웹소켓 처리 과정과 별개로 동작하는 빈에 있어서, 해당 빈에서는 웹소켓 세션에 직접 접근할 수 없었습니다.

즉, SimpleMessageHeaderAccessor에 직접 접근할 수 없고, 세션에서 사용자 id를 가져올 수 없었습니다.

 

이를 위해 웹소켓 메세지 처리 과정에서 세션 속성을 해당 빈으로 전달하는 로직을 구현해야했고, 이 때 ThreadLocal을 이용해 세션 속성을 전달할 수 있었습니다.

 

ThreadLocal

각 스레드에 대해 독립적인 변수를 유지할 수 있고, 스레드간에 안전하게 데이터를 공유할 수 있습니다.

 

ThreadLocal에 인증 정보 저장하기

웹소켓 메시지 처리 과정에서 SimpMessageHeaderAccessor를 통해 세션 속성을 추출하고, 이를 ThreadLocal에 저장합니다.
AuditorAware같은 웹 소켓과 독립적인 빈에서는 ThreadLocal에 저장된 값을 가져와 사용할 수 있습니다.

웹소켓 처리와 AuditorAware가 동일한 스레드에서 실행된다고 가정합니다.

 

ThreadLocal을 사용한 세션 속성 관리 클래스

세션 속성을 ThreadLocal에 저장하고, 가져오고, 제거합니다.

public class WebSocketUtils {
    private static final ThreadLocal<Map<String, Object>> sessionAttributesHolder = new ThreadLocal<>();

    public static void setSessionAttributes(Map<String, Object> sessionAttributes) {
        sessionAttributesHolder.set(sessionAttributes);
    }

    public static Map<String, Object> getSessionAttributes() {
        return sessionAttributesHolder.get();
    }

    public static void removeSessionAttributes() {
        sessionAttributesHolder.remove();
    }
}

해당 스레드가 다른 작업 처리시에 영향이 가지 않도록 세션 속성을 삭제하는 메서드를 구현해야합니다.

 

세션 속성 저장하기

@MessageMapping("/agoras/{agora-id}/chats")
@SendTo("/topic/agoras/{agora-id}/chats")
public SendChatResponse sendChat(@DestinationVariable("agora-id") Long agoraId,
                                 @Payload SendChatRequest sendChatRequest,
                                 SimpMessageHeaderAccessor accessor) {
    String userId = (String) accessor.getSessionAttributes().get("userId");
    String userRole = (String) accessor.getSessionAttributes().get("userRole");

    WebSocketUtils.setSessionAttributes(accessor.getSessionAttributes());

    SendChatResponse response = chatCommandService.sendChat(userId, userRole, agoraId, sendChatRequest);

    WebSocketUtils.removeSessionAttributes();

    return response;
}

ThreadLocal에 세션 속성을 저장하고, 서비스 호출 후 세션 속성을 삭제합니다.

 

ThreadLocal에서 웹소켓 세션값을 불러오기

@EnableJpaAuditing  
@Configuration  
public class JpaConfig {  

    @Bean  
    public AuditorAware<String> auditorAware() {  
        return () -> {  
            String userId = getWebSocketUserId();  
            if (userId != null) {  
                return Optional.of(userId);  
            }  

            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();  
            if (authentication == null || !authentication.isAuthenticated()) {  
                throw new IllegalStateException("AuditorAware : principal is missing");  
            }  

            Object principal = authentication.getPrincipal();  
            if (principal instanceof CustomUserDetails) {  
                return Optional.of(((CustomUserDetails) principal).getUsername());  
            }  

            return Optional.of(principal.toString());  
        };  
    }  

    private String getWebSocketUserId() {  
        return Optional.ofNullable(WebSocketUtils.getSessionAttributes())  
                .map(sessionAttributes -> (String) sessionAttributes.get("userId"))  
                .orElse(null);  
    }  
}

ThreadLocal에서 세션 속성을 가져와 저장합니다.
세션 속성이 없을 경우 웹소켓 요청이 아니므로 기존 방식대로 SecurityContext에서 인증 정보를 저장합니다.

웹소켓 처리와 AuditorAware가 동일한 스레드에서 실행된다는 가정을 합니다.
당연하게도, 다른 스레드에서 동작할 경우 세션 속성이 제대로 공유되지 않습니다.. ㅠㅠ

 

다른 스레드일 경우?

세션 속성을 db에 저장하던지, 캐싱을 해서 세션을 캐시에서 가져오는 식으로 수행하면 될 것 같습니다.

 

 

728x90