Spring Websocket + Websocket Security 맛깔나게 사용해보기

eora21·2024년 6월 18일
3

웹소켓과 웹소켓 시큐리티를 적용하며 마주친 사항들을 정리해 보았습니다.
사실 맛깔나지는 않고, 슴슴한 정도입니다.. ㅎ

이번에 실시간 송수신 관련 코드를 작성하며 웹소켓(SockJS)을 사용하였습니다.

과거에는 그저 채팅을 구성하기 위해 작성해 보았다면, 이번에는 심도 있게 구성하고 싶었습니다.

따라서 WebSocketMessageBrokerConfigurer와 더불어 @EnableWebSocketSecurity까지 구성해 보았습니다.

WebSocketMessageBrokerConfigurer

전체 코드

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-zelkova")
                .addInterceptors(httpSessionHandshakeInterceptor())
                .setAllowedOriginPatterns("{오리진 허용할 주소}")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/queue");
        registry.setApplicationDestinationPrefixes("/zelkova");
        registry.setUserDestinationPrefix("/zelkova/user");
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(accountDetailWebsocketArgumentResolver());
    }

    @Bean
    HttpSessionHandshakeInterceptor httpSessionHandshakeInterceptor() {
        return new HttpSessionHandshakeInterceptor();
    }

    @Bean
    HandlerMethodArgumentResolver accountDetailWebsocketArgumentResolver() {
        return new AccountDetailWebsocketArgumentResolver();
    }

    static class AccountDetailWebsocketArgumentResolver implements HandlerMethodArgumentResolver {
    
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
        	return AccountDetail.class.isAssignableFrom(parameter.getParameterType());
        }

        @Override
        @Nullable
        public Object resolveArgument(MethodParameter parameter, Message<?> message) {
            Principal principal = SimpMessageHeaderAccessor.getUser(message.getHeaders());

            if (principal instanceof AbstractAuthenticationToken abstractAuthenticationToken) {
                return abstractAuthenticationToken.getPrincipal();
            }

            throw new CustomException(ExceptionStatus.FAIL_CONVERT);
        }
    }
}

registerStompEndpointsconfigureMessageBroker에 대한 내용은 이미 많이 소개되어 있기에 넘어가도록 하겠습니다.

로그인된 유저 정보 가져오기

@Bean
HttpSessionHandshakeInterceptor httpSessionHandshakeInterceptor() {
    return new HttpSessionHandshakeInterceptor();
}

현재 개발중인 서비스는 Session 기반 인증 방식입니다.
사용자가 로그인하면, 해당 브라우저의 쿠키에 JSESSIONID가 기록됩니다.

웹소켓 연결이 같은 브라우저에서 수행될 경우, 먼저 http 기반 핸드쉐이크가 수행됩니다.
이 과정에서 이미 생성된 세션을 확인하고, 해당 세션 내용을 웹소켓의 세션에도 paste합니다.
이 때 기존 세션 내의 인증 정보 또한 넘어가기 때문에, 웹소켓 이전에 로그인된 사용자의 정보(Principal)도 가져올 수 있습니다.

Principal 대신 원하는 UserDetails 가져오기

@SubscribeMapping("/user/queue/message")
public List<LastChatResponse> noticeChatroomInfo(Principal Principal) {
    ...
}

만약 로그인한 사용자의 정보를 @MesssageMapping이나 @SubscribeMapping 등에서 사용하고 싶을 경우, 위의 예시처럼 Principal로 가져와야 합니다.

다만 Principal로 사용자 정보를 가져오기엔 조금 귀찮은 과정이 필요합니다. 형변환을 통해 토큰을 얻고, 해당 토큰에서 UserDetails를 꺼내야 할 수 있기 때문입니다.

따라서 argumentResolver를 사용하여 이를 손쉽게 구성하도록 했습니다.

AccountDetailWebsocketArgumentResolver

static class AccountDetailWebsocketArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return AccountDetail.class.isAssignableFrom(parameter.getParameterType());
    }
    
    @Override
    @Nullable
    public Object resolveArgument(MethodParameter parameter, Message<?> message) {
        Principal principal = SimpMessageHeaderAccessor.getUser(message.getHeaders());
        
        if (principal instanceof AbstractAuthenticationToken abstractAuthenticationToken) {
            return abstractAuthenticationToken.getPrincipal();
        }
        
        throw new CustomException(ExceptionStatus.FAIL_CONVERT);
    }
}

코드는 일반적인 argumentResolver를 만들 때와 동일합니다.
저는 UserDetails를 확장한 AccountDetail을 사용 중입니다.
따라서 supportsParameter에서 AccountDetail인지를 확인합니다.

그 후 PrincipalabstractAuthenticationToken의 형태인지 확인 후 getPrincipal을 통해 AccountDetail을 반환하도록 작성하였습니다.

@EnableWebSocketSecurity

웹소켓을 설정하고 나니 새롭게 떠오른 생각이 있었습니다.
'만약, 임의의 사용자가 강제로 특정 topic에 대해 악의가 담긴 메시지를 보내면 어쩌지? 이를 막을 방법이 있나?'

이번 프로젝트에서는 queue 방식 + UserDestination으로 최대한 막았지만 이후에 진행될 프로젝트에서는 topic 방식 또한 사용할 예정입니다. 따라서 방어하는 방법을 미리 학습해 보았고, 그 과정에서 웹소켓 시큐리티에 대해 사용해 보았습니다.

전체 코드

@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {

    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager(
            MessageMatcherDelegatingAuthorizationManager.Builder messages) {

        messages.simpTypeMatchers(SimpMessageType.CONNECT).authenticated();
        messages.simpSubscribeDestMatchers("/zelkova/user/queue/message").authenticated();
        messages.simpMessageDestMatchers("/zelkova/message").authenticated();
        messages.anyMessage().denyAll();

        return messages.build();
    }
}

코드는 굉장히 심플합니다. 그러나, 내부적인 사항들을 미리 알아 둘 필요가 있습니다.

제일 크게 맞닥뜨린 문제는 csrf 토큰이었습니다.

csrf 토큰 검증하기

스프링 웹소켓 시큐리티 공식문서를 보시면, 현재 csrf 토큰은 필수라고 선언되어 있습니다(물론 사용하지 않게끔 설정하는 방법도 있습니다. 이는 뒤에 소개해 드리겠습니다).

저는 세션 인증 방식 + 더블 서밋 쿠키 패턴으로 이미 csrf 토큰을 사용 중이었습니다.

그러나 SockJS 사용 시 내부적으로 전송하는 POST 통신에 대해 csrf 토큰을 내장하여 전송하는 건 불가능하다고 나와있습니다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.csrf(httpSecurityCsrfConfigurer -> {
        httpSecurityCsrfConfigurer.ignoringRequestMatchers("/ws-zelkova/**");
        ...
    });
    ...
}

위와 같이 연결에 사용될 경로에 대해 csrf 토큰 검증을 하지 않도록 설정하였습니다.

이후 세션 커넥션을 확인하였고, 웹소켓 시큐리티 적용 후 csrf 토큰을 그냥 보내면 되겠다 싶었습니다.

웹소켓 시큐리티는 내부적으로 DefaultCsrfTokenheaderName과 일치하는 nativeHeaders를 살펴봅니다. 설정에 따라 달라질 수 있겠지만 저는 'X-XSRF-TOKEN'이었습니다.

그러나, 해당 이름으로 csrf 토큰을 전송해도 인증에 실패하였습니다. 이는 XorCsrfChannelInterceptor에서 사용하는 XorCsrfTokenUtils의 검증 방식 때문이었습니다.

XorCsrfTokenUtils

좀 더 확실한 이해를 위해, 2주 전 버그가 fix된 따끈따끈한 코드를 대상으로 확인해 보겠습니다.

static String getTokenValue(String actualToken, String token) {
	byte[] actualBytes;
	try {
		actualBytes = Base64.getUrlDecoder().decode(actualToken);
	}
	catch (Exception ex) {
		return null;
	}

	byte[] tokenBytes = Utf8.encode(token);
	int tokenSize = tokenBytes.length;
	if (actualBytes.length != tokenSize * 2) {
		return null;
	}

	// extract token and random bytes
	byte[] xoredCsrf = new byte[tokenSize];
	byte[] randomBytes = new byte[tokenSize];

	System.arraycopy(actualBytes, 0, randomBytes, 0, tokenSize);
	System.arraycopy(actualBytes, tokenSize, xoredCsrf, 0, tokenSize);

	byte[] csrfBytes = xorCsrf(randomBytes, xoredCsrf);
	return Utf8.decode(csrfBytes);
}

private static byte[] xorCsrf(byte[] randomBytes, byte[] csrfBytes) {
	Assert.isTrue(randomBytes.length == csrfBytes.length, "arrays must be equal length");
	int len = csrfBytes.length;
	byte[] xoredCsrf = new byte[len];
	System.arraycopy(csrfBytes, 0, xoredCsrf, 0, len);
	for (int i = 0; i < len; i++) {
		xoredCsrf[i] ^= randomBytes[i];
	}
	return xoredCsrf;
}

base64

첫번째 문제는, base64 과정이 필요했다는 것입니다.

actualBytes = Base64.getUrlDecoder().decode(actualToken);

해당 코드 내에서 base64를 통해 decode하는 것을 확인할 수 있습니다.
즉, 헤더에 전송하기 전 base64로 csrf 토큰을 인코딩하여 보냈어야 한다는 뜻입니다.

하지만 이뿐만이 아니었습니다.

XOR

base64 decode 이후의 코드가 약간은 복잡합니다.
쉽게 축약해서 말씀드리자면, 전달된 헤더 값 길이는 csrf 토큰 길이의 2배여야 하며, 헤더의 앞부분 값과 뒷부분 값을 XOR 연산한 결과가 실제 csrf 토큰과 같아야 한다입니다.

즉, 전달 전 과정에서 csrf 토큰을 숨기기 위해 약간의 노력이 필요합니다.
A XOR B = csrfToken 수식을 완성하기 위해, csrfToken을 이용해 A와 B를 생성 후 A와 B를 이어붙이고, 이어붙인 문자열에 대해 base64 과정을 처리해야 했던 것입니다.

이를 해결하기 위해 작성한 코드 예시를 보여드리겠습니다.

js 코드 예시

const generateRandomString = (csrfToken) => {
  const randomUUID = uuidv4();
  let secretString = '';

  for (let i = 0; i < csrfToken.length; i++) {
    const targetChar = csrfToken.charCodeAt(i);
    const randomChar = randomUUID.charCodeAt(i);
    const xorChar = targetChar ^ randomChar;
    secretString += String.fromCharCode(xorChar);
  }

  return randomUUID + secretString;
};

const headers = {
  'X-XSRF-TOKEN': btoa(generateRandomString(getCsrfToken()))
};

const connectHandler = () => {
  const client = new StompJs.Client({
    ...
  });

  client.connectHeaders = headers;
  
  ...
    
}

기존의 csrf 토큰도 uuid 형태라는 것에 착안해 uuid 하나를 생성하고, 이와 csrf 토큰을 차례로 xor했습니다.
이후 생성했던 uuid와 계산한 문자열을 붙여 반환한 후, base64로 인코딩하여 커넥션에 이용했습니다.

해당 과정(Base64 + XOR)이 왜 필요한 것일까?

해당 글을 참고해주시면 감사하겠습니다.

부록: Websocket Security에 csrf 적용하지 않는 법

공식 문서에서 소개한 방법은 웹소켓 시큐리티 이전 방식을 사용하는 것이지만, 더 쉬운 방법이 존재합니다.

@Bean(name = "csrfChannelInterceptor")
ChannelInterceptor csrfChannelInterceptor() {
    return new ChannelInterceptor() {
    
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            return message;
        }
    };
}

이처럼 텅 빈 인터셉터를 선언하되,이름을 csrfChannelInterceptor로 선언해주면 됩니다.

이는 WebSocketMessageBrokerSecurityConfiguration에서 확인할 수 있습니다.

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
	ChannelInterceptor csrfChannelInterceptor = getBeanOrNull(CSRF_CHANNEL_INTERCEPTOR_BEAN_NAME,
			ChannelInterceptor.class);
	if (csrfChannelInterceptor != null) {
		this.csrfChannelInterceptor = csrfChannelInterceptor;
	}

	AuthorizationManager<Message<?>> manager = this.authorizationManager;
	if (!this.observationRegistry.isNoop()) {
		manager = new ObservationAuthorizationManager<>(this.observationRegistry, manager);
	}
	AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(manager);
	interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context));
	interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
	this.securityContextChannelInterceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
	registration.interceptors(this.securityContextChannelInterceptor, this.csrfChannelInterceptor, interceptor);
}

해당 이름으로 선언된 빈을 가져와 설정하기 때문에, 임의의 csrf 토큰 인터셉터 빈을 적용할 수 있게끔 되어 있습니다.

적용 전

적용 후

이처럼 XorCsrfChannelInterceptor 대신 임의로 작성한 인터셉터가 동작함을 확인할 수 있었습니다.

Reference

https://docs.spring.io/spring-security/reference/servlet/integrations/websocket.html
https://stackoverflow.com/questions/75547571/authenticated-web-sockets-spring-boot-3

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글

관련 채용 정보