websocket 환경에서의 securitycontextHolder

byeol·2023년 10월 1일
0

이번에 당근 클론 코딩 프로젝트를
하면서 생긴 오류가 있었는데 원인을 모른 채 프로젝트 구현을 마쳤고
이후 개인적으로 왜 오류가 발생했는가를 추적하면서
그 원인을 정리해보려고 한다.

환경 : Stomp + Spring

구체적인 질문
ChannelInterceptor의 preHandle 메서드를 통해 SecurityContextHolder에 유저 정보를 넣었는데 Controller에 가면 그 정보가 사라지는 이유가 무엇일까?

🔦 상황

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
분명 컨트롤러를 오기 전에 인터셉터를 들리는데 같은 스레드인데 왜 SecurityContextHolder에 값을 찾지 못하는가?

@Component
@Slf4j
public class ChatPreHandler implements ChannelInterceptor {

    private static final String HEADER_PREFIX = "Bearer ";
    private static final String HEADER_NAME = "Authorization";
    private final AuthTokenProvider tokenProvider;

    public ChatPreHandler(AuthTokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        String authorizationHeader = headerAccessor.getFirstNativeHeader(HEADER_NAME);


        Thread preHandThread = Thread.currentThread();// 현재 쓰레드 얻기
        String num = preHandThread.getName().replaceAll("[^0-9]", "");;
        preHandThread.setName(num+" preSend");

        log.info("현재 스레드의 이름:"+preHandThread.getName());


        StompCommand stompCommand = headerAccessor.getCommand();
        if (StompCommand.CONNECT == stompCommand || StompCommand.SEND == stompCommand) {

            //헤더 토큰
            if (authorizationHeader != null && authorizationHeader.startsWith(HEADER_PREFIX)) {
                String tokenStr = authorizationHeader.substring(HEADER_PREFIX.length());
                AuthToken token = tokenProvider.convertAuthToken(tokenStr);

                if (token.isValidTokenClaims()) {
                    Authentication authentication = tokenProvider.getAuthentication(token);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                } else {
                    throw new UnauthorizedException("유효한 Jwt토큰이 아닙니다.");
                }
            }
        }
        return message;
    }

}

구체적인 코드는 SecurityContextHolder.getContext().setAuthentication(authentication);이다.

분명 넣었지만 컨트롤러에서 null로 들어온다.

그래서 나는 인터셉터와 컨트롤러의 스레드가 서로 다른지 먼저 확인해보았고 원인을 도출했다.

✅ 웹소켓은 Thread per Request가 아니다.

다음과 같이 현재 스레드 이름을 확인하는 메서드를 사용하여 Interceptor에 있는 스레드의 이름과 Controller에 있는 스레드의 이름을 비교해보니 스레드가 달랐다.

 Thread preHandThread = Thread.currentThread();// 현재 쓰레드 얻기
 String num = preHandThread.getName().replaceAll("[^0-9]", "");;
 preHandThread.setName(num+" preSend");

 log.info("현재 스레드의 이름:"+preHandThread.getName());
 /**
 * 채팅 발송
 */
 @MessageMapping("/chats/{roomId}/messages")
public void saveMessage(@DestinationVariable("roomId") Long roomId,
                        Authentication authentication,
                        @Payload @Valid MessageRequest messageRequest) {

     Thread thread = Thread.currentThread();// 현재 쓰레드 얻기
     log.info("메세지 controller 스레드 이름 : "+thread.getName());

     CustomUser customUser = (CustomUser) authentication.getPrincipal();
     ChatMessageResponse response = chatService.saveMessage(customUser.memberId(), roomId, messageRequest);
     sendingOperations.convertAndSend(DESTINATION_URL + roomId, response);
 }

그래서 WebSocket은 Thread per Request가 아니라는 것을 알게 되었다.
그렇다면 왜 아닌것일까?

일단 이 물음을 해결하기 위해서 Http와 Websocket의 방식의 차이를 알아야 한다.

Http는 stateless하고 WebSocket은 stateful하다.

만약에 내가 구현하고 있는 채팅의 경우 사용자가 1시간동안 상대방과 채팅을 주고받을 수도 있다.

즉 오랜 시간 연결되어 있는데 Thread per Request하다면?
Thread Pool에 저장된 Thread의 개수는 제한적인데?
계속 물고 있을 수는 없기 때문에 웹소켓은 비동기 통신이며 이벤트 기반 아키텍처 모델이다.

그래서 Controller와 인터셉터의 스레드가 달랐던 것이다.

여기서 비동기 통신과 동기 통신, 그리고 블로킹과 논블로킹의 개념이 헷갈리신다면
테코톡 동기 vs 비동기, 블로킹 vs. 논블로킹 혹은 동기와 비동기, 그리고 블럭과 넌블럭을 보시기를 추천한다. (블로그 글이 더 좋은 거 같다.)

그렇다면 어떻게 jwt 토큰을 저장해야 하는걸가?🤔
먼저 내가 접근했던 SecurityContextHolder는 왜 불가능했는지 정리해보려고 한다.

✅ SecurityContextHolder의 Authenticaiton은 ThreadLocal로 저장된다.

💡ThreadLocal이란 무엇인가?

This gives us the ability to store data individually for the current thread and simply wrap it within a special type of object.

간단하게 말하면 하나의 스레드에서 공유하는 전역변수라고 생각하면 될 것 같다.

각 스레드에서 userId에 대한 유저정보를 Map과 ThreadLocal에 저장한다고 가정했을 때
Map에서는 다른 스레드임에도 같은 Context를 사용하고
ThreadLocal은 다른 스레드이면 다른 Context를 사용한다.

하지만 Thread Pool과 ThreadLocal을 같이 사용할 때 주의할 점이 존재한다.

하나의 시나리오를 고려해보자
1. 하나의 어플리케이션이 있다고 가정하고 그 어플리케이션이 Thread Pool로부터 하나의 스레드를 빌렸다.
2. 그 다음 현재 스레드의 ThreadLocal에 값들을 저장한다.
3. 현재 실행이 끝났고 어플리케이션을 그 빌린 스레드를 Thread Pool에 반환한다.
4. 시간이 조금 지난 후에, 그 어플리케이션은 다른 요청을 처리하기 위해서 아까와 같은 Thread를 빌렸다.
5. 그러나 새로 들어온 요청은 Thread를 청소하는 필수적인 작업을 하지 않았기 때문에 아까 Thread에 저장한 데이터들을 그대로 재사용한다.

위와 같이 Thread를 사용하고 다시 Thread Pool에 반환하는 과정에서
데이터를 비우지 않으면 높은 동시성 어플리케이션들에게 예상하지 못한 결과
들을 불러올 수 있다고 한다.

따라서 Thread을 사용했다면 ThreadLocal을 제거하는 작업이 반드시 필요하다.

그래서 ThreadPoolExecutor을 extends하는 방법도 해결책이 될 수 있다.
beforeExecute() and afterExecute() 메서드를 제공하여 커스텀 훅의 실행을 제공한다.

Thread Pool은 빌려온 스레드를 사용하여 작업을 실행하기 전에 beforeExecute() 메서드를 호출한다.
그리고 afterExecute()은 로직을 수행하고 나서 호출한다.

따라서 Thread Local을 제거하는 과정은 다음과 같이 활용한다.

public class ThreadLocalAwareThreadPool extends ThreadPoolExecutor {

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        // Call remove on each ThreadLocal
    }
}

출처: baeldung ThreadLocal 글

ThreadLocal에 대해서 살펴보았으니 이제 SecurityContextHolder가 ThreadLocal을 사용하는지 알아보자

💡 SecurityContextHolder 코드

public class SecurityContextHolder {

	public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";

	public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";

	public static final String MODE_GLOBAL = "MODE_GLOBAL";

	private static final String MODE_PRE_INITIALIZED = "MODE_PRE_INITIALIZED";

	public static final String SYSTEM_PROPERTY = "spring.security.strategy";

	private static String strategyName = System.getProperty(SYSTEM_PROPERTY);

	private static SecurityContextHolderStrategy strategy;

	private static int initializeCount = 0;

	static {
		initialize();
	}

	private static void initialize() {
		initializeStrategy();
		initializeCount++;
	}

	private static void initializeStrategy() {
		if (MODE_PRE_INITIALIZED.equals(strategyName)) {
			Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
					+ ", setContextHolderStrategy must be called with the fully constructed strategy");
			return;
		}
		if (!StringUtils.hasText(strategyName)) {
			// Set default
			strategyName = MODE_THREADLOCAL;
		}
		if (strategyName.equals(MODE_THREADLOCAL)) {
			strategy = new ThreadLocalSecurityContextHolderStrategy();
			return;
		}
		if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
			strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
			return;
		}
		if (strategyName.equals(MODE_GLOBAL)) {
			strategy = new GlobalSecurityContextHolderStrategy();
			return;
		}
		// Try to load a custom strategy
		try {
			Class<?> clazz = Class.forName(strategyName);
			Constructor<?> customStrategy = clazz.getConstructor();
			strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
		}
		catch (Exception ex) {
			ReflectionUtils.handleReflectionException(ex);
		}
	}

구체적인 코드를 이해하기 힘들지만 생성자를 통해 객체를 만들 때 initializeStrategy();가 호출되는데 그 메서드 안에서 다시 전략 객체를 반환한다.

전략 객체를 들어가보면 ThreadLocal를 사용하는 것을 볼 수 있다.

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

	private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();

✅ Stomp에서의 Token Authentication

나는 비동기 통신에서 SecurityContextHolder를 통해서 Jwt 토큰을 저장하는 것이 어렵다는 것을 알았다.

그렇다면 어떻게 저장해야할까?

스프링 공식 문세에 따르면 다음과 같은 두 가지 단계를 거쳐서 구현해보라고 한다.

  1. Use the STOMP client to pass authentication headers at connect time.
    (STOMP 클라이언트를 사용하여 연결 시 인증 헤더를 전달한다.)
  2. Process the authentication headers with a ChannelInterceptor.
    (ChannelInterceptor로 인증 헤더를 처리한다.)
    @Configuration
    @EnableWebSocketMessageBroker
    public class MyConfig implements WebSocketMessageBrokerConfigurer {
    		@Override
    		public void configureClientInboundChannel(ChannelRegistration registration) {
    			registration.interceptors(new ChannelInterceptor() {
    				@Override
    				public Message<?> preSend(Message<?> message, MessageChannel channel) {
    					StompHeaderAccessor accessor =
    							MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
    					if (StompCommand.CONNECT.equals(accessor.getCommand())) {
    						Authentication user = ... ; // access authentication header(s)
    						accessor.setUser(user);
    					}
    					return message;
    				}
    			});
    		 }
      }

또한 현재 Spring Security의 권한을 메시지에 사용할 때는 인증 ChannelInterceptor 구성이 Spring Security의 구성보다 먼저 순서가 지정되도록 해야 한다.
이는 @Order(Order.HEST_PRECRECENCE + 99)로 표시된 WebSocketMessageBrokerConfigurer 자체 구현에서 사용자 지정 인터셉터를 선언하는 것이 가장 좋다.

그래서 @Order(Order.HEST_PRECRECENCE + 99)를 사용한 사용자 지정 인터셉터를 만들어보기로 했다.

그러나 먼저 ChannelInterceptor와 @Order(Order.HEST_PRECRECENCE + 99)의 의미를 알아보자

💡 ChannelInterceptor란

버전 4.1을 시작으로 웹소켓은 Spring Integration이 지원한다.
ChannelInterceptor는 Sptring Integration의 Interceptor이다.

Spring Integration에 대해서 궁금하다면
토리맘의 한글라이즈 프로젝트(Spring Integration 부분)를 읽으면서 대략적인 그림을 그려보았으니 읽어보시길 추천한다.

그렇다면 ChannelInterceptor는 무엇인가?

package org.springframework.messaging.support에 있는 메시지 채널에서 보내고 받는 메시지를 보거나 수정할 수 있는 인터셉터용 인터페이스이다.

Spring Security는 ChannelInterceptor를 사용하여 메시지 내의 사용자 헤더를 기반으로 메시지를 인증하는 WebSocket 서브프로토콜 인증을 제공한다. 또한, Spring Session은 WebSocket 세션이 활성화되어 있는 동안 사용자의 HTTP 세션이 만료되지 않도록 보장하는 WebSocket 통합 기능을 제공한다.

💡왜 @Order(Ordered.HIGHER_PRECRECENCE + 99)일까?

Ordered.HIGHEST_PRECEDENCE means that some interceptor will have the maximum priority in the chain executed by spring, but for spring, the lower the number the higher the priority. So if you look at Ordered class, you'll see

스프링에 의해 실행되는 채인 안에서 가장 큰 우선순위를 가지는 인터셉터를 의미한다.
왜냐하면 낮을 수록 높은 우선순위를 가지기 때문이다.

HIGHEST_PRECEDENCE = Integer.MIN_VALUE;

그러나 하필 99인 이유는 Spring Security에서 ChannelInterceptor를 이용하여 메시지 내의 사용자 헤더를 기반으로 메시지를 인증하는 WebSocket 서브프로토콜 인증을 제공하기 때문이다.

즉 그 기능을 제공하는 클래스의 우선순위가 다음과 @Order(Ordered.HIGHEST_PRECEDENCE + 100) 같기 때문이다.

@Order(Ordered.HIGHEST_PRECEDENCE + 100)
@Import(MessageMatcherAuthorizationManagerConfiguration.class)
final class WebSocketMessageBrokerSecurityConfiguration
		implements WebSocketMessageBrokerConfigurer, SmartInitializingSingleton {

📌최종 코드

import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

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

    private static final String HEADER_PREFIX = "Bearer ";
    private static final String HEADER_NAME = "Authorization";
    private final AuthTokenProvider tokenProvider;

    public ChatPreHandler(AuthTokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        String authorizationHeader = headerAccessor.getFirstNativeHeader(HEADER_NAME);


        Thread preHandThread = Thread.currentThread();// 현재 쓰레드 얻기
        String num = preHandThread.getName().replaceAll("[^0-9]", "");;
        preHandThread.setName(num+" preSend");

        log.info("현재 스레드의 이름:"+preHandThread.getName());


        StompCommand stompCommand = headerAccessor.getCommand();
        if (StompCommand.CONNECT == stompCommand || StompCommand.SEND == stompCommand) {

            //헤더 토큰
            if (authorizationHeader != null && authorizationHeader.startsWith(HEADER_PREFIX)) {
                String tokenStr = authorizationHeader.substring(HEADER_PREFIX.length());
                AuthToken token = tokenProvider.convertAuthToken(tokenStr);

                if (token.isValidTokenClaims()) {
                    Authentication authentication = tokenProvider.getAuthentication(token);
                    headerAccessor.setUser(authentication);
                } else {
                    throw new UnauthorizedException("유효한 Jwt토큰이 아닙니다.");
                }
            }
        }
        return message;
    }

결론

WebSocketMessageBrokerSecurityConfiguration를 살펴보니 SecurityContextHolder를 사용하고 있는데 아마도 내부적으로 웹소켓 메세지 헤더에서 Spring Security를 이용하도록 구현했기 때문이라고 예상한다. 그래서 MessageMapping Controller에서 Authentication을 사용할 수 있는거 같다.

즉 개발자 입장에서는 메세지 헤더를 통해서 넣는 방법 말고는 구현하기에 매우 복잡하고 까다롭지 않을까 생각한다.

결론적으로 SecurityContextHolder를 사용하지만

언제 스레드가 바뀌는지 예상하기 어렵기 때문에 SecurityContextHolder에 직접적으로 넣는 방식은 해결하기 어렵다.

그래서 스프링 시큐리티에서 제공하는 방법을 참고해서 적용하자

출처

https://docs.spring.io/spring-integration/docs/current/reference/html/web-sockets.html
https://docs.spring.io/spring-framework/reference/web/websocket/stomp/authentication-token-based.html
https://docs.spring.io/spring-integration/reference/channel/interceptors.html
https://stackoverflow.com/questions/60004924/strange-99-in-order-spring

profile
꾸준하게 Ready, Set, Go!

0개의 댓글