fragile and resilient

CS

[Network, Spring] CORS Error 왜 발생했을까?

Green Lawn 2022. 6. 19. 21:52

문제 상황

프론트엔드와 협업하는 프로젝트를 진행하면서 문제를 겪었습니다.
인수 테스트와 Postman으로 테스트했을 때는 문제가 없었는데, 브라우저와 연결하니 에러가 떴습니다.

에러 문구 중 해당 부분이 눈에 들어왔습니다.
has been blocked by CORS policy

그럼 CORS에 대해 알아보기 전에, origin에 대해 먼저 알아보겠습니다.

출처(origin)

origin은 protocol, domain, port에 의해 정의됩니다.
따라서 protocol, domain, port가 모두 일치하는 경우를 같은 출처라고 하고, 이 중 하나라도 다를 경우 교차 출처(cross-origin)라 합니다.

http://example.com:80

여기서 http://를 protocol, example을 domain, 80을 port라고 합니다.
밑의 예제를 통해 더 쉽게 이해해 봅시다.

1) 같은 origin

http://lawn.com/app1
http://lawn.com/app2

2) 다른 origin

2.1) 다른 protocol

http://lawn.com/app1
https://lawn.com/app1

2.2) 다른 domain

http://lawn.com
http://greenlawn.com

2.3) 다른 port

http://lawn.com:8080
http://lawn.com:8081

다시 돌아와서, 웹 애플리케이션은 리소스가 자신의 origin과 다를 때 교차 출처 HTTP 요청을 실행하는 데요.
보안상의 이유로 브라우저는 스크립트에서 온 교차 출처 HTTP 요청을 제한합니다.

해당 정책을 SOP(Same Origin Policy)라 부릅니다.

즉, 다른 origin 응답에 올바른 CORS 헤더가 포함되어 있지 않는 한, 해당 API를 사용하는 웹 애플리케이션은 동일한 origin의 리소스만 요청할 수 있는 정책입니다.

그럼 CORS란 무엇일까요?

CORS(Cross-Origin Resource Sharing)는 교차 출처 리소스 공유의 약자입니다.
추가적인 HTTP 헤더를 사용하여, 한 origin에서 실행 중인 웹 애플리케이션이 다른 origin의 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다.
따라서 CORS 정책은 브라우저와 서버 간의 안전한 교차 출처 요청 및 데이터 전송을 지원합니다.
이러한 CORS에는 3가지의 시나리오가 있는데요. 하나씩 살펴봅시다.

CORS 접근 제어 시나리오

1) Simple Requests

단순 요청은 바로 본(Main) 요청을 보내는 경우입니다.

단순 요청은 밑의 조건들을 모두 충족해야 합니다.
1) 요청 메서드가 GET, HEAD, POST 중 하나여야 합니다.
2) User-Agent가 자동으로 설정한 헤더 이외의, 수동으로 설정할 수 있는 헤더는 CORS-safelisted request-header로 정의한 헤더만 가능합니다.

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type

3) Content-Type 헤더는 다음 값들만 허용됩니다.

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

2) Preflight Requests

위에서 살펴 본 simple requests와 달리, 예비(Preflight) 요청을 보낸 후에 본(Main) 요청을 보내 안전한지 확인합니다.
OPTIONS 메서드로 다른 도메인의 리소스로 HTTP 요청을 보내, 실제 요청을 전송하는 것이 안전한지 확인합니다.
교차 출처 요청은 데이터에 영향을 미칠 수 있기 때문입니다.

https://developer.mozilla.org/ko/docs/Web/HTTP/CORS


위의 예제를 보면, 예비(Preflight) 요청을 보낸 뒤에, 본(Main) 요청을 보내는 것을 알 수 있습니다.
(본 요청에서는 Access-Control-Request-*헤더가 포함되지 않고, 예비 요청에만 필요하다는 것 또한 확인해 볼 수 있습니다.)

Request

  • Origin : 요청을 보내는 출처
  • Access-Control-Request-Method : 실제 요청하려는 메서드
  • Access-Control-Request-Headers : 실제 요청의 추가 헤더

Response

  • Access-Control-Allow-Origin : 허가 출처
  • Access-Control-Allow-Methods : 허가 메서드
  • Access-Control-Allow-Headers : 실제 요청 시 사용할 수 있는 헤더
  • Access-Control-Max-Age : 브라우저가 일정 시간 동안 preflight request 없이 본 요청 허가

전체 과정

  1. 브라우저가 예비 요청을 보내면
  2. 서버는 허가에 대한 정보를 담아서 브라우저에 응답하고
  3. 브라우저는 자신의 요청과 서버가 허가한 정보를 담은 응답을 비교한 후에 안전하다면
  4. 본 요청을 보내게 되는 것입니다.

이러한 과정이 필요한 이유는 브라우저가 CORS 정책을 확인하는 것은 서버 응답이 도착한 후이기 때문에,
preflight 요청이 없다면 서버에서 코드가 다 돌아간 후에야 CORS error가 뜨게 되므로 데이터에 영향을 미칠 수 있기 때문입니다.

3) Credentialed Request

인증된 요청을 사용하는 방법입니다.
preflight 응답은 Access-Control-Allow-Credentials: true를 지정하여 실제 요청을 실행할 수 있음을 나타내야 합니다.
자격 증명 요청에 응답할 때 서버는 반드시 와일드카드(*)를 지정하는 대신, Access-Control-Allow-Origin 헤더 값에 origin을 특정해야 합니다.

CORS 설정

  • @Configuration 사용
@Configuration
public class WebConfig implements WebMvcConfigurer {
	public static finalStringALLOWED_METHOD_NAMES= "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH";

    	@Override
	public voidaddCorsMappings(finalCorsRegistry registry) {
    	registry.addMapping("/**")
    		.allowedMethods(ALLOWED_METHOD_NAMES.split(","))
        	.exposedHeaders(HttpHeaders.LOCATION);
    }
}
  • addMapping: CORS 적용할 url. 위의 예시처럼 모든 url에 대한 접근을 허용할 경우 ‘/**’로 설정합니다
  • allowedMethods: 허용하고자 하는 method
  • exposedHeaders: 추가로 접근할 헤더

결론

결론적으로 제가 만났던 에러는 CORS 설정이 안 되어 있어서 나는 에러는 아니었습니다.
브라우저에서 preflight 요청을 보내는데, 본 요청이 아니기 때문에 preflight 요청에는 Authorization헤더와 토큰이 없어서 나는 문제였습니다.

@Component
public class LoginInterceptor implements HandlerInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    public LoginInterceptor(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        String token = AuthorizationExtractor.extract(request);
        validateToken(token);

        ServletContext servletContext = request.getServletContext();
        servletContext.setAttribute("payload", jwtTokenProvider.getPayload(token));

        return HandlerInterceptor.super.preHandle(request, response, handler);
    }
    
    private void validateToken(String token) {
        if (token == null || token.isEmpty()) {
            throw new AuthorizationException("토큰이 존재하지 않습니다.");
        }
        if (!jwtTokenProvider.validateToken(token)) {
            throw new AuthorizationException("인증되지 않은 회원입니다.");
        }
    }
}

따라서 token이 null이기 때문에 validateToken 메서드의 예외 처리에서 걸려 에러가 발생되는 것이었습니다.
본 요청이 아니라 preflight 요청인지 확인하는 메서드를 추가하니 해결되었습니다.
CORS 접근 제어의 시나리오와 처리 과정을 아는 것이 중요해 보입니다.


주의) Postman은 SOP를 고려하지 않고 본(Main) 요청을 바로 보낸다고 합니다.
브라우저가 아니니 어쩌면 당연한 이야기이긴 하지만,,🥲
https://stackoverflow.com/questions/36250615/cors-with-postman


References

https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
https://www.youtube.com/watch?v=-2TgkKYmJt4
https://da-nyee.github.io/posts/spring-interceptor-cors-issue/

'CS' 카테고리의 다른 글

[Network] HTTPS 동작원리  (0) 2022.07.30
[Network] HTTP 메시지  (0) 2022.05.15
[Network] 프로토콜 HTTP  (0) 2022.05.09
[OS] CPU Scheduling  (0) 2021.01.31