컴퓨터/JAVA

[Spring] 트러블 슈팅 -스프링 부트 axios 프리플라이트 에러/cors에러

도도새 도 2024. 1. 9. 18:51

스프링부트 axios 통신시 에러

 

문제 상황

axios.post로 통신할 때 헤더가 셋되어있지 않아 통신 에러가 지속적으로 발생하였다. 재밌는 것은 allowAll로 설정한 컨트롤러에서는 정상 통신이 된다는 것이었다. 문제가 되는 지점은 axios.post로 요청을 보낼 때, 토큰이 유효할 경우에만 도달 가능한 컨트롤러와 통신을 할 때였다. 또한 신기한 점은 axios.get으로 요청을 할 때는 전혀 에러가 발생하지 않는다는 것이다.

 

분석

오랜 시간 끝에 에러 흐름이 아래와 같이 발생한다는 것을 깨달았다.

1. axios에서 get은 프리플라이트를 날리지 않지만 post요청을 할 시 프리플라이트를 날리게 된다. 이 프리플라이트가 정상 통신을 했을 경우에 본 요청이 날아가게 된다. 이 프리플라이트는 OPTIONS라는 메소드 형식으로 날아가게 된다.

2. allowAll로 지정한 곳에서는 프리플라이트가 날아갔을 때, 토큰이 없더라도 정상 값을 뱉어준다. 그러므로 다음 정상 값을 내보낼 수 있다. 그러나 토큰이 필요한 지점에서는 프리플라이트가 통신 오류를 뱉어 다음 값은 cors오류를 뱉는다.

 

나의 경우 프리플라이트가 발생하는 이유는 쿠키를 보내야 403이 아닌 에러가 발생하기 때문에 with Credential을 세팅하기 때문이었다. 즉 쿠키로 세팅한 토큰을 서버로 보내야하기 때문에 필연적으로 prefilight가 발생한다.

 

아래 로그를 보자

(요청 1)
헤더 정보host: localhost:8080
헤더 정보connection: keep-alive
(...)
요청0:0:0:0:0:0:0:1
클라이언트로부터 쿠키가 전송되지 않았습니다.

(요청 2)
헤더 정보host: localhost:8080
헤더 정보connection: keep-alive
헤더 정보content-length: 2
(...)
쿠키 이름: JSESSIONID
쿠키 값: B9FABBDBF4E95691F8AC586A2FB27A59
쿠키 이름: Refresh-token
쿠키 값: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJma3RoZnZrMTEyIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MDQ3OTA4NDgsImlhdCI6MTcwNDc4MDA0OH0.BIQME5h0VjLsOI5jKg57CKndduhA8es2PEX3DCB9C9s
쿠키 이름: Authorization
쿠키 값: Bearer_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJma3RoZnZrMTEyIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MDQ3ODAyMTUsImlhdCI6MTcwNDc4MDIxMH0.vj7tBEjY1bAkScyprQKlc4WFjAMLtyHu_RQBx2-UJO8
이름:JSESSIONID
이름:Refresh-token
이름:Authorization
디코드 된 값 Bearer_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJma3RoZnZrMTEyIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MDQ3ODAyMTUsImlhdCI6MTcwNDc4MDIxMH0.vj7tBEjY1bAkScyprQKlc4WFjAMLtyHu_RQBx2-UJO8
이름:JSESSIONID
값:B9FABBDBF4E95691F8AC586A2FB27A59
이름:Refresh-token
값:eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJma3RoZnZrMTEyIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MDQ3OTA4NDgsImlhdCI6MTcwNDc4MDA0OH0.BIQME5h0VjLsOI5jKg5

즉, post를 한 번 날릴 때 요청은 두 번 날아가고 있는 모습다. 첫 요청의 메소드는 OPTIOSN, 두 번째 요청은 POST이다.(프리플라이트와 본요청)

 

 

해결

그렇담 어떻게 해야할까?

 

두 가지 경우의 수를 떠올렸다. 우선 프리 플라이트에 헤더를 세팅해서 같이 보내는 방법이다. 두 번쨰는 프리플라이트의 경우 무조건 통신에 성공하게 하는 것이다. 

 

알아보니 직접 쿠키를 세팅하는 건 어렵다고 한다. (스프링 공식 문서에는 프리플라이트에는 쿠키를 담지 않는다고 되어있었다.) 그렇다면 두번째 무조건 통신이 성공하는 경우를 구현한다. 나의 경우 첫 번째 필터를 통과하도록 하면 된다.

public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
	log.info("[doFilter] - 요청 메소드" + request.getMethod());

 

위 코드를 확인해보면 preflight요청의 경우 OPTOIONS으로 찍히게 된다. 즉, 요청이 OPTIONS일 경우 바로 200을 리턴하면 되겠다. 이때 세팅해야 할 헤더들이 있다.

Access-Control-Allow-Credentials : 자격 증명(Credentials)을 사용할 수 있는지 여부를 나타낸다. true가 되어야지 헤더에 쿠키를 담아서 서버로 넘길 수 있다.

Access-Control-Allow-Origin : 허용된 오리진을 나타낸다. credential을 쓸 경우 와일드 카드(*)를 쓸 수 없다. 이 경우 에러가 발생한다.

 

즉, 위 헤더를 세팅해서 200을 빠르게 리턴해주면 프리플라이트가 통과 될 것이다. 그러나 스프링 시큐리티를 쓸 경우 아래 방법이 공식문서에서 지원하는 내용이다.

 

스프링 시큐리티 cors()를 필터에 적용할 경우 OPTIONS에 대해서는 인증을 우회할 수 있다고 한다.

Spring Framework provides first class support for CORS. CORS must be processed before Spring Security because the pre-flight request will not contain any cookies (i.e. the JSESSIONID). If the request does not contain any cookies and Spring Security is first, the request will determine the user is not authenticated (since there are no cookies in the request) and reject it.

즉, 프리플라이트는 쿠키를 설정할 수 없기 때문에 auth는 항상 실패했다고 뜰 것이라고 한다.

 

The easiest way to ensure that CORS is handled first is to use the CorsWebFilter. Users can integrate the CorsWebFilter with Spring Security by providing a CorsConfigurationSource. For example, the following will integrate CORS support within Spring Security:

가장 쉬운 해결책은 CorsWebFilter를 적용하는 것이란다.

 

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://localhost:3001"));
        corsConfiguration.setAllowedHeaders(List.of("*"));
        corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "PATCH"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }

문제는 이렇게 설정했더니 preflight는 통과하지만 본 요청이 cors오류로 처리된다. 

 

네트워크 탭에서 아래 힌트를 얻었다.

A cross - origin resource sharing (CORS) request was blocked because it was configured to include credentials but the Access-Control-Allow-Credentials response header of the request or the associated preflight request was not set to true.

To fix this issue, ensure that resources that expect credentialed CORS requests set the Access-Control-Allow-Credentials header to true. Note that this requires the Access-Control-Allow-Origin header to not be a wildcard *.

즉, 앞서 말한대로 Access-Control-Allow-Credentials 를 세팅하라는 것인데, 이를 스프링 시큐리티 CorsConfiguration에서는 아래와 같이 한다.

corsConfiguration.setAllowCredentials(true);

 

이 설정을 했음에도 에러가 떴다. 필터에 아래 한 줄을 넣어 csrf를 비활성화 해주어야한다.

.csrf(AbstractHttpConfigurer::disable)

 

이렇게 해서 비로소 거의 6시간에 걸린 에러 핸들링이 끝이 났다.

 

필터는 결국 아래와 같은 모습으로 완성되었다.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        return http

                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .cors((cors) -> cors
                        .configurationSource(corsConfigurationSource())
                )
                .csrf(AbstractHttpConfigurer::disable)
                // 특정 URL에 대한 권한 설정.
                .authorizeHttpRequests((authorizeRequests) -> {
                    authorizeRequests.requestMatchers("/sign-api/**").permitAll(); // 권한 없어도 열람 가능
                    authorizeRequests.requestMatchers("/recipe/create").authenticated(); //제한
                    authorizeRequests.requestMatchers("/auth-test/**").authenticated(); //제한
                    authorizeRequests.requestMatchers("/manager/**")
                            .hasAnyRole("ADMIN", "MANAGER");

                    authorizeRequests.requestMatchers("/admin/**")
                            .hasRole("ADMIN");

                    authorizeRequests.anyRequest().permitAll();
                })
                .build();
    }

 

정말 개발은 하면 할 수록 모르는 게 쌓이는 느낌이지만, 헤맬수록 깊이가 쌓이는 것 같다.