📡 백엔드/🌱 Spring Boot

[Spring] 스프링 시큐리티로 CORS와 preflight 설정하기

gengminy 2022. 8. 11. 15:29

스프링 시큐리티로 CORS 설정 삽질해서 해결한 과정을 올려본다

 

사실 결론부터 말하자면 설정은 몇 줄 빼고 아주 잘 되어있었고

도커 이미지 태그가 달라져서 갱신이 안됐던거였다 ^^^^^;;;

 

 

로컬 환경에서는 너무나도 잘 되다가 EC2에 띄웠을 때만

자꾸 계속 preflight 과정에서 CORS 이슈가 생겨서 무한 삽질을 하고 있었다

 

그러다가 도커 컴포즈 파일을 봤는데

도커 이미지 태그가 바뀐 걸 보고 아,,, 한숨밖에 안나왔다

그 과정에서 CORS 관련 공부는 된 거 같아서 긍정적으로 생각할란다

 

 

 

🔥 문제 상황

주니어 개발자들을 벌벌 떨게 만드는 무시무시한 CORS 에러

 

Access to XMLHttpRequest at '{SERVER}' from origin '{ANOTHER ORIGIN}' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

 

특히 Postman 으로 API 요청을 보냈을 때는 매우 잘되는데

막상 실제 리액트 앱을 만들어 요청했을 때 이 에러가 발생하는 경우가 있다

나도 그랬다

이것도 브라우저에서 보내는 Preflight 요청 때문이었다 (후술)

 

 

📌 CORS ?

Cross Origin Resource Sharing 의 약자

하나의 도메인에서 다른 도메인의 리소스에 접근할 수 있게 해주는 보안 메커니즘

동일 출처 정책(SOP), 즉 프로토콜과 호스트명, 포트가 같은 출처의 리소스에만 접근할 수 있도록 제한하는 정책 때문에 등장했다.

 

서버 사이드 렌더링으로 자원을 뿌려줄 때는 신경쓸 일이 거의 없지만

요즘에는 API 서버와 클라이언트(리액트나 뷰)를 분리하기 때문에 자주 발생하는 에러이다,,,,

 

CORS 에러를 해결하기 위해서는

API 서버에서 클라이언트 URL에 대한 리소스 참고를 허용하도록

헤더에 "Access-Control-Allow-Origin" 값을 넣어 응답을 해줘야 한다.

 

하지만 이 헤더를 보내 해결할 수 없는 경우가 몇 가지 있는데,

  • GET, POST, HEAD 를 제외한 메소드를 사용
  • Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink,
    Save-Data, Viewport-Width, Width 를 제외한 헤더를 사용
  • Content-Type 에서 application/x-www-form-urlencoded, mulitpart/form-data, text/plain 이외의 것을 사용

특히 2번과 3번에서 문제가 되는데

JWT 방식으로 로그인을 구현할 때 X-AUTH-TOKEN 또는 Authorization 헤더를 사용하고

Content-Type 으로 application/json 을 보내는 경우가 많기 때문이다

 

 

📌 Preflight

 

"Access-Control-Allow-Origin" 헤더로 해결할 수 없는 경우

사이트가 안전한지 확인하기 위해 간을 보는데

예비 요청으로 OPTIONS 메서드를 먼저 보내서 판단한 후 본 요청을 보내게 된다

 

이것이 바로 preflight 요청이다

 

아까 만났던 에러는 바로 이 preflight 요청을 서버에서 처리하지 못해서

내 요청이 팅겨져 나간 것이다

 

그러니까 결론적으로는 서버에서 preflight 요청을 처리할 수 있도록

OPTIONS 메서드를 허용해주어야 한다.

 

 

 

📌 스프링 시큐리티 설정

📝 SecurityConfiguration.java

@EnableWebSecurity
@RequiredArgsConstructor
@Configuration
public class SecurityConfiguration {
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .csrf().disable()
                //예외처리 핸들러
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .httpBasic().disable()
                //권한이 필요한 요청에 대한 설정
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .antMatchers("/user/**").authenticated()
                .anyRequest().permitAll()
                .and()
                .headers().frameOptions().disable();

                http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
                return http.build();
    }

    /** cors 설정 bean */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        configuration.addAllowedOriginPattern("*");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.addExposedHeader("x-auth-token");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

스프링 MVC 말고 스프링 시큐리티를 사용중이라면

CORS 관련 설정을 전역적으로 할 수 있다

 

간혹 Controller 단에서 @CrossOrigin 에노테이션 박아서 사용하는 코드를 많이 보는데

별로 좋은 방법은 아니라고 생각한다

 

여기서 핵심은 시큐리티 필터의 허용 Request 에 PreFlight 요청을 추가하는 것이다

...
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
...

나는 CorsUtils 클래스에서 가져왔는데

HttpMethod.OPTIONS 로 빼서 가져와도 된다

 

이후 CorsConfigurationSource 빈에서 CORS 관련 설정을 해주면 끝이다

...
        configuration.addAllowedOriginPattern("*");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.addExposedHeader("x-auth-token");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
...

스프링 최신 버전에서는

addAllowedOrigin 과 setAllowCredentials(true)를 같이 사용할 수 없다고 오류가 뜬다

 

에러 메세지 그대로 addAllowedOriginPattern 으로 수정해주면 된다

 

배포 이후 프론트단 리액트앱에서 axios 요청 날리니까 정상적으로 되는 모습

서버 도커 이미지 제대로 풀 해오니까 잘 되더라

 

이상 스프링 시큐리티 CORS 와 Preflight 관련한 삽질의 흔적이었습니다