[Spring] 트러블 슈팅 -스프링 부트 axios 프리플라이트 에러/cors에러
스프링부트 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에 대해서는 인증을 우회할 수 있다고 한다.
즉, 프리플라이트는 쿠키를 설정할 수 없기 때문에 auth는 항상 실패했다고 뜰 것이라고 한다.
가장 쉬운 해결책은 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오류로 처리된다.
네트워크 탭에서 아래 힌트를 얻었다.
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();
}
정말 개발은 하면 할 수록 모르는 게 쌓이는 느낌이지만, 헤맬수록 깊이가 쌓이는 것 같다.