본문 바로가기

웹 어플리케이션 공부/Spring 공부

Spring으로 개발하며 맞닥뜨린 CORS!

반응형

앞서, CORS에러 문제 해결방법에 대하여 설명하기 전에 CORS에 대하여 간략하게 설명하고자 합니다. 더 자세한 내용을 알고 싶다면 해당 페이지에서 확인하는 것을 추천드립니다.

CORS란?

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다. 웹 애플리케이션은 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 교차 출처 HTTP 요청을 실행합니다. 

위에 나온 시나리오를 요약해보았습니다.

  1. 클라이언트(=브라우저)의 웹 컨텐츠가 'https://foo.example'이고, https://bar.other 도메인의 컨텐츠를 호출했습니다.
  2. 이 때, 클라이언트의 요청 헤더의 Origin 부분에는 https://foo.example 로부터 요청이 옵니다.
  3. 서버는 이에 대한 응답으로 Access-Control-Allow-Origin 헤더를 다시 전송합니다.

위 예시처럼 가장 간단한 접근 제어 프로토콜은 'Origin' 헤더와 'Access-Control-Allow-Origin'을 사용하는 것입니다. 이 경우 서버는 'Access-Control-Allow-Origin: *', 으로 응답해야 하며 이는 모든 도메인에서 접근할 수 있음을 의미합니다. 

프리플라이트 요청

"preflighted" request는 먼저 OPTIONS 메서드를 통해 다른 도메인의 리소스로 HTTP 요청을 보내 실제 요청이 전송하기에 안전한지 확인합니다. Cross-site 요청은 유저 데이터에 영향을 줄 수 있기 때문에 이와 같이 미리 전송(preflighted) 합니다.

preflighted request는 OPTIONS 요청과 함께 두 개의 다른 요청 헤더가 전송됩니다. 

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

Access-Control-Request-Method 헤더는 preflight request의 일부로 실제 요청을 전송할 때 POST 메소드로 전송된다는 것을 알려줍니다. Access-Control-Request-Headers 헤더는 실제 요청을 전송할 때 X-PINGOTHER와 Content-Type 사용자 정의 헤더와 함께 전송된다는 것을 서버에 알려줍니다. 

이 preflight 에 대한 응답으로 서버는 아래와 같이 응답을 합니다.

Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

서버는 Access-Control-Allow-Methods로 응답하고 POST와 GET이 리소스를 쿼리하는데 유용한 메소드라고 응답합니다. Access-Control-Max-Age는 다른 preflight request를 보내지 않고, preflight request에 대한 응답을 캐시할 수 있는 시간을 제공합니다. 

 

첫 번째 CORS 에러 - Preflight

현재 프로젝트에서는 스프링 부트를 사용하여 백엔드 서버를 구성하고 있고 개발 서버에 배포하기 전에 항상 포스트맨으로 테스트를 하고 배포 후에도 테스트로 정상 확인을 했습니다.

프론트쪽에서 로컬 개발을 진행하시면서 백엔드 서버에 연결하여 테스트를 진행중에 아래와 같은 에러가 난다고 연락을 받았습니다.

현재 프로젝트에서 진행중인 백엔드 구조를 잠깐 설명하자면 아래와 같습니다.

저희 현 프로젝트에서 두 가지의 어드민이 동일 서버( 및 소스 코드)에 존재합니다. 다만 endpoint의 prefix로 어드민을 구분할 수 있도록 하였고 두 서버의 인증방식이 다르기 때문에 두 가지의 커스텀 필터가 각각 분류하여 적용될 수 있도록 했습니다.

문제는 JwtTokenFilter를 사용하는 /supply/** 요청에서는 위 에러가 나지 않는데, FirebaseTokenFilter를 사용하는 /hq/** API 호출시 CORS 에러가 난다는 것이었습니다. 에러를 보시다시피 preflight에서 실패가 났다는 에러여서 각 필터에서 Preflight 요청에 대해서는 인증을 요구하지 않도록 구현하였습니다.

위 에러에 대한 해결방법으로 우선 Filter의 Config 부분과 Filter부분의 수정이 필요했습니다.

@EnableWebSecurity
@Configuration
@AllArgsConstructor
@Order(100)
public class HqSecurityConfig extends WebSecurityConfigurerAdapter {

    final FirebaseTokenFilter firebaseTokenFilter;
    final ExceptionHandlerFilter exceptionHandlerFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable().cors()
                .and()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .anyRequest().authenticated()
                .and()
                .antMatcher("/hq/**")
                .addFilterBefore(firebaseTokenFilter, BasicAuthenticationFilter.class)
                .addFilterBefore(exceptionHandlerFilter, FirebaseTokenFilter.class);
    }
}
  • .requestMatchers(CorsUtils::isPreFlightRequest).permitAll(): Preflight 요청은 허용한다는 의미입니다.
  • FirebaseTokenFilter가 Bean으로 등록되어 있기 때문에 FirebaseTokenFilter 사용 여부는 필터 내부의 shouldNotFilter 오버라이드 함수를 통하였습니다.
@EnableWebSecurity
@Configuration
@AllArgsConstructor
@Order(300)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    final JwtTokenFilter jwtTokenFilter;
    final ExceptionHandlerFilter exceptionHandlerFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable().cors()
                .and()
                .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/supply/login/**")
                .permitAll()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .anyRequest().authenticated()
                .and()
                .antMatcher("/supply/**")
                .addFilterBefore(jwtTokenFilter, BasicAuthenticationFilter.class)
                .addFilterBefore(exceptionHandlerFilter, JwtTokenFilter.class);
    }
}
  • .requestMatchers(CorsUtils::isPreFlightRequest).permitAll(): Preflight 요청은 허용한다는 의미입니다.
  • JWTTokenFilter가 Bean으로 등록되어 있기 때문에 JWTTokenFilter 사용 여부는 필터 내부의 shouldNotFilter 오버라이드 함수를 통하였습니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    ... (생략) ...

    @Override
    protected boolean shouldNotFilter (HttpServletRequest request) throws ServletException {

        if (request.getMethod().equals("OPTIONS")) {
            return true;
        }
        ... (생략) ...
	}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class FirebaseTokenFilter extends OncePerRequestFilter {
	...(생략)...

    @Override
    protected boolean shouldNotFilter (HttpServletRequest request) throws ServletException {

        if (request.getMethod().equals("OPTIONS")) {
            return true;
        }

   		...(생략)...
    }

}

위처럼 커스텀 필터에서 저희는 @Component 어노테이션으로 필터를 빈으로 생성하였기 때문에, shoudNotFilter 함수를 오버라이드 하여 OPTIONS 메소드에 대하여 필터하지 않도록 추가적인 설정을 했습니다.

이렇게 필터를 적용하니 에러가 해결이 되었습니다.

그리고 혹시 궁금해 하시는 분들을 위하여 초반에 어드민2에서 호출 시 CORS 에러가 나지 않았던 이유는 프론트에서 작업시 /supply/login/** 으로 JWT토큰값을 받은 후 모든 요청 전에 프론트에서 interceptor로 응답받은 토큰을 헤더에 넣어주고 있었기 때문에 필터를 정상적으로 수행할 수 있었던 것입니다.

두 번째 CORS 에러 

위 에러를 잘 해결한 후 마음 좋게 개발하던 중 프론트 개발자분으로부터 또 다시 연락을 받았습니다.

프론트 개발자: "저희가 토큰이 만료되면 서버에서 보내주시는 403 status code 확인하여서 토큰 연장 혹은 재로그인할 수 있도록 구현해놨는데, /hq/** 쪽은 response 값이 'undefined'로 와서 제대로 로직이 동작하지 않아요 ㅠㅠ"

response가 undefined로 뜬다고 하셔서 비동기 처리 문제인가 싶기도 하였지만, axios 소스 확인 결과 Network error가 되면 response를 따로 넘겨주지 않으면서 undefined로 보여준 것 같습니다. 

    // Handle low level network errors
    request.onerror = function handleError() {
      // Real errors are hidden from us by the browser
      // onerror should only fire if it's a network error
      reject(createError('Network Error', config, null, request));

      // Clean up request
      request = null;
    };

 

'어쩔 수 없다' '방법은 디버그 뿐이군,,' 하며 /hq/** 요청과 /supply/** 요청에 대하여 각각 디버깅을 해보았습니다.

그런데 정말 신기한 현상을 발견했습니다.

/supply/** 으로 요청을 보냈을 때는 필터가 아래와 같이 디버깅 되었습니다.

반면, /hq/** 요청을 보냈을 때는 필터가 아래와 같이 디버깅 되었습니다.

여기서 이상함을 느낀 저는 포스트맨 요청을 확인해보았습니다.

아니나 다를까, /hq/** 의 헤더에는 그 다음 수행되는 커스텀 필터 ExceptionHandlerFilter에서 지정해준 헤더 말고는 response의 header가 없었습니다.

이 결과, 저는 응답 헤더에 시큐리티 관련 헤더를 추가해주는 역할을 담당하는 HeaderWriterFilter가 수행되지 않고 있다고 판단했습니다. 그리고 그 이유를 확인한 결과 제 코드에 문제가 있었습니다.

바로 Config 클래스에서 선언한 configure(Websecurity) 메소드 문제였습니다. 

   @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/hq/**");

JwtTokenFilter를 Config하는 클래스에서 위처럼 선언이 되어있었습니다. (당연히 /hq/**를 ignore 하라고 했으니 필터가 제대로 사용되지 못했던 것이죠.. ㅠ)

다음에는 FilterChainProxy와 configure(WebSecurity)에 대하여 좀 더 공부하여 공유해보겠습니다!

 

 

 

 

 

반응형

'웹 어플리케이션 공부 > Spring 공부' 카테고리의 다른 글

스프링 필터  (0) 2022.01.23
MongoDB 와 Spring 연동설정  (0) 2020.05.05
Spring 공부 - 초기 설정  (0) 2019.08.16