CORS 에러와 api-key 인증 헤더 이슈

effiRin·2023년 7월 27일
1
post-thumbnail

문제 상황

Filter를 이용하여 헤더에 특정 Api-key를 가지고 있어야 Request가 통과되도록 만들었다.

override fun doFilter(servletRequest: ServletRequest?, servletResponse: ServletResponse?, chain: FilterChain) {
       val request = (servletRequest as HttpServletRequest?)?.let { ServletServerHttpRequest(it) }
       val response = (servletResponse as HttpServletResponse?)?.let { ServletServerHttpResponse(it) }

       val requestApiKey = request?.headers?.get("x-api-key")?.first()

       if (apiKey == requestApiKey) {
          chain.doFilter(servletRequest, servletResponse)
          } else {
                val errorResponse = ErrorData(MessageCode.UnAuthRequest.code, "Unauthorized User")

                response?.servletResponse?.let {
                        it.contentType = MediaType.APPLICATION_JSON_VALUE
                        it.status = HttpStatus.UNAUTHORIZED.value()
                        it.writer.write(ObjectMapper().writeValueAsString(errorResponse))
                    }
                }
            }
        }
    }

그랬더니 프론트에서 CORS 에러 발생 !



원인

1. Access-Control 헤더 설정

앞서 작성한 포스팅에 따르면 (CORS란 무엇인가?)
CORS 에러의 근본적인 해결 방법은 서버 측에서 적절한 Access-Control 관련 header를 넣어주는 것인데, 그 중에서도 응답 헤더에 유효한 Access-Control-Allow-Origin 값이 있어야 한다.

따라서 가장 첫 번째 원인은 'Access-Control 헤더 설정'이 없었기 때문이라고 말할 수 있겠다.

Access-Control 헤더를 설정하기에 앞서
본인이 닥친 cors 에러가 아래 3가지 시나리오에 해당되는지 파악해야 한다.

1. Simple Request
2. Preflight Request
3. Credentialed Repuest

해당 API는 POST 요청 + application/json Content-type 요청이었으므로 2번 preflight 시나리오에 해당되었다.
사전 요청 preflight의 응답으로 적절한 Access-Control 헤더를 설정해서 돌려줘야 했다.

그래서 검색해서 Access-Control header를 세팅하는 코드를 넣어줬다.
response!!.servletResponse.setHeader("Access-Control-Allow-Origin", "*")
response.servletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT")
response.servletResponse.setHeader("Access-Control-Allow-Headers", "Origin, X-Api-Key, X-Requested-With, Content-Type, Accept, Authorization",

override fun doFilter(servletRequest: ServletRequest?, servletResponse: ServletResponse?, chain: FilterChain) {
        val request = (servletRequest as HttpServletRequest?)?.let { ServletServerHttpRequest(it) }
        val response = (servletResponse as HttpServletResponse?)?.let { ServletServerHttpResponse(it) }

            response!!.servletResponse.setHeader("Access-Control-Allow-Origin", "*")
            response.servletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT")
            response.servletResponse.setHeader(
                "Access-Control-Allow-Headers",
                "Origin, X-Api-Key, X-Requested-With, Content-Type, Accept, Authorization"
                )

       val requestApiKey = request?.headers?.get("x-api-key")?.first()

       if (apiKey == requestApiKey) {
          chain.doFilter(servletRequest, servletResponse)
          } else {
                val errorResponse = ErrorData(MessageCode.UnAuthRequest.code, "Unauthorized User")

                response?.servletResponse?.let {
                        it.contentType = MediaType.APPLICATION_JSON_VALUE
                        it.status = HttpStatus.UNAUTHORIZED.value()
                        it.writer.write(ObjectMapper().writeValueAsString(errorResponse))
                    }
                }
            }
        }
    }

그런데 여전히 CORS 에러가 뜬다 !
무엇이 문제인고?! 하면서 콘솔창에서 에러 메시지를 살펴보았다.

Access to fetch at 'http://localhost:8080/v1/board' from origin 'http://localhost:3000' has been blcked by CORS policy : Response to preflight request doesn't pass access control check : It does not have HTTP ok status

preflight request에 대한 응답이 HTTP ok status가 아니라서 access control check를 pass하지 못했다는 것이다.

네트워크 탭에서 상태 코드를 확인해보았다.
401 에러가 뜨고 있었다.



2. Preflight Request의 응답은 무조건 200이어야 한다.

  • 브라우저는 서버가 보낸 Response 정보를 이용하여 허용되지 않은 요청인 경우 405 Method Not Allowed 에러를 발생시키고, 실제 페이지의 요청은 서버로 전송하지 않음
  • OPTIONS 요청에서는 다른 처리를 하지 않고 현재 서버에서 제공 가능한 옵션 정보만 내려주면서 무조건 2XX 상태로 전달해야 한다.
    CORS, Preflight, 인증 처리 관련 삽질 | Popit

CORS 문제면 405 상태가 나타나야 하는데 401이 나타난 것은 Preflight 요청이 서버의 api-key 필터에 걸려 HttpStatus.UNAUTHORIZED.value() 이 반환된 것으로 보였다.

또한 preflight Request(OPTIONS) 응답은 무조건 200이어야 한다고 한다.

하지만 분명 API-key를 헤더에 담아서 날린 것 같은데, 왜 Preflight request는 Api-key 필터를 통과하지 못했을까???



3. preflight Request는 인증 관련 header를 담지 못한다.

    override fun doFilter(servletRequest: ServletRequest?, servletResponse: ServletResponse?, chain: FilterChain) {
        val request = (servletRequest as HttpServletRequest?)?.let { ServletServerHttpRequest(it) }
        val response = (servletResponse as HttpServletResponse?)?.let { ServletServerHttpResponse(it) }

            println("RequestMethod ::: ${request!!.method}")
            
            response!!.servletResponse.setHeader("Access-Control-Allow-Origin", "*")
            response.servletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT, OPTIONS")
            response.servletResponse.setHeader(
                "Access-Control-Allow-Headers",
                "Origin, X-Api-Key, X-Requested-With, Content-Type, Accept, Authorization"
                )


                val requestApiKey = request?.headers?.get("x-api-key")?.first()

                println("ApiKey ::: $requestApiKey")

                if (apiKey == requestApiKey) {
                    chain.doFilter(servletRequest, servletResponse)
                } else {
                    val errorResponse = ErrorData(MessageCode.UnAuthRequest.code, "Unauthorized User")

                    response?.servletResponse?.let {
                        it.contentType = MediaType.APPLICATION_JSON_VALUE
                        it.status = HttpStatus.UNAUTHORIZED.value()
                        it.writer.write(ObjectMapper().writeValueAsString(errorResponse))
                }
            }
        }
    }

그래서 위와 같이
println("RequestMethod ::: ${request!!.method}")
println("ApiKey ::: $requestApiKey")

요청 메소드와 APIKey를 println 해보았다.
내 예상대로라면 아래와 같이 출력되어야 할 터였다.

RequestMethod ::: OPTIONS
Apikey ::: ApiKey12345

RequestMethod ::: POST
Apikey ::: ApiKey12345

preflight Request의 OPTIONS 메소드가 api-key와 함께 찍히고,
무사히 응답 헤더에 access-control header가 담겨져서 보내진다면,
본 요청인 POST가 api-key와 함께 찍힐 것이었다.

그러나 실제 결과는 달랐다.

RequestMethod ::: OPTIONS
Apikey ::: null

preflight는 도착했으나 api-key가 정상적으로 담아서 보내지지 않아 Null로 찍혔다.
즉 Preflight Request가 api-key 헤더를 실어 보내고 있지 않아, api-key 필터에 걸려 401로 반환되었던 것!


According to the CORS specification when a preflight request is performed user credentials are excluded.
(...) using the method OPTIONS, and with the following additional constraints:

  • (...)
  • Exclude the author request headers.
  • Exclude user credentials.
  • (...)

OPTIONS 메소드를 사용하면 추가적인 제약사항이 붙는데,
author request header나 user credentials를 배제해야 한다는 것.

With this in mind, the problem seems to be on the API side of things, which should be accepting OPTIONS requests without requiring authentication.

또한 "API 쪽에서 OPTIONS request를 인증 없이 받아들일 수 있도록 해야한다"고 하고,

So preflight OPTIONS calls from a Get (and I assume Post etc) with Basic Auth will fail as the Authorization Header is not allowed
Http Endpoint - preflight OPTIONS not allowing Authorization Header

프리플라이트 OPTIONS 호출은 인증 헤더가 허용되지 않기 때문에 실패한다는 이야기도 있었다.


    override fun doFilter(servletRequest: ServletRequest?, servletResponse: ServletResponse?, chain: FilterChain) {
        val request = (servletRequest as HttpServletRequest?)?.let { ServletServerHttpRequest(it) }
        val response = (servletResponse as HttpServletResponse?)?.let { ServletServerHttpResponse(it) }

        if(request?.method == HttpMethod.OPTIONS) {
            println("RequestMethod ::: ${request!!.method}")
            
            response!!.servletResponse.setHeader("Access-Control-Allow-Origin", "*")
            response.servletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT, OPTIONS")
            
            response.servletResponse.setHeader(
                "Access-Control-Allow-Headers",
                "Origin, X-Api-Key, X-Requested-With, Content-Type, Accept, Authorization"
                )
                response.servletResponse.status = HttpStatus.OK.value()
                return                
     	     }
             	val origin = response!!.servletResponse.getHeaders("Access-Control-Allow-Origin")
                println("Origin ::: $origin")

                val requestApiKey = request?.headers?.get("x-api-key")?.first()
                println("ApiKey ::: $requestApiKey")

                if (apiKey == requestApiKey) {
                    chain.doFilter(servletRequest, servletResponse)
                } else {
                    val errorResponse = ErrorData(MessageCode.UnAuthRequest.code, "Unauthorized User")

                    response?.servletResponse?.let {
                        it.contentType = MediaType.APPLICATION_JSON_VALUE
                        it.status = HttpStatus.UNAUTHORIZED.value()
                        it.writer.write(ObjectMapper().writeValueAsString(errorResponse))
                }
            }
        }
    }

그래서 위와 같이 Preflight의 OPTIONS 메소드일 경우만 응답 헤더에 Access-Control 관련 설정을 주고,
OK status를 담아서 인증 필터 로직 전에 return 하도록 했다.

그리고 이번엔 response!!.servletResponse.getHeaders("Access-Control-Allow-Origin") 도 print 해보았다.
응답 header에 response가 제대로 담기는지 궁금했기 때문.

그리고 결과는...

RequestMethod ::: OPTIONS
Apikey ::: null
Origin ::: [*]

RequestMethod ::: POST
Apikey ::: ApiKey12345
Origin ::: []

우선 Preflight가 정상적으로 통과했고, POST 본 요청까지 날린 것으로 확인되었다!
상태코드도 잘 뜬다!
그런데... 콘솔창에 또 다른 에러가 떴다...



4. 사전 요청(preflight Request) 뿐만 아니라 본 요청도 Access-Control-Allow-Origin 설정이 되어야 한다

Access to fetch at 'http://localhost:8080/v1/board' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

CORS는 브라우저의 구현 스펙에 포함되는 정책이기 때문에
서버에선 정상적으로 상태 코드를 200으로 던져줘도, 이렇게 에러가 뜨면서 Response를 버린다고 했다.
즉, cors 에러가 아직 해결되지 않은 것...

에러 메시지를 살펴보니 origin 설정 해줬는데 No 'Access-Control-Allow-Origin' 이 뜨고 있다. 뭘까...
그러다 서버의 콘솔창에서 POST의 Origin이 [] 으로 찍힌 것이 눈에 들어왔다.
혹시 본 요청에 Access-Control-Allow-Origin이 설정이 안되어 있어서 그런가...?



최종 해결

  • 정리해보면
    1) 응답 헤더에 Access-control 관련 설정이 있어야 하고,
    2) preflight Request의 응답은 200 status로 무조건 반환되어야 하고,
    3) preflight Request가 Api-key 필터에 걸리지 않도록 해야했다.
    4) 또한 사전요청(Preflight-OPTIONS) 뿐만 아니라 본 요청(POST)에도 Access-Control-Allow-Origin header 설정을 동일하게 해줘야 했다.

따라서 사전 요청이든 본 요청이든 응답 헤더에 Access-control 관련 설정이 모두 들어갈 수 있게 하고,
Request의 Method가 OPTIONS일 때는
인증 필터 로직에 닿기 전에 OK Status를 담아서 Return 해버리는 식으로 수정했다.



    override fun doFilter(servletRequest: ServletRequest?, servletResponse: ServletResponse?, chain: FilterChain) {
        val request = (servletRequest as HttpServletRequest?)?.let { ServletServerHttpRequest(it) }
        val response = (servletResponse as HttpServletResponse?)?.let { ServletServerHttpResponse(it) }

            println("RequestMethod ::: ${request!!.method}")

            response!!.servletResponse.setHeader("Access-Control-Allow-Origin", "*")
            response.servletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT, OPTIONS")
            response.servletResponse.setHeader(
                "Access-Control-Allow-Headers",
                "Origin, X-Api-Key, X-Requested-With, Content-Type, Accept, Authorization"
                )

            if (request.method == HttpMethod.OPTIONS) {
                response.servletResponse.status = HttpStatus.OK.value()
                return
            }

                val requestApiKey = request?.headers?.get("x-api-key")?.first()
            	println("RequestApiKey ::: $requestApiKey")                

                if (apiKey == requestApiKey) {
                    chain.doFilter(servletRequest, servletResponse)
                } else {
                    val errorResponse = ErrorData(MessageCode.UnAuthRequest.code, "Unauthorized User")

                    response?.servletResponse?.let {
                        it.contentType = MediaType.APPLICATION_JSON_VALUE
                        it.status = HttpStatus.UNAUTHORIZED.value()
                        it.writer.write(ObjectMapper().writeValueAsString(errorResponse))
                }
            }
        }
    }
RequestMethod ::: OPTIONS
Apikey ::: null
Origin ::: [*]

RequestMethod ::: POST
Apikey ::: ApiKey12345
Origin ::: [*]

그 결과, 내가 원하던 대로 동작했고,
드디어 cors 에러도 뜨지 않게 되었다 !



참고 : Access-Control-Max-Age로 Preflight Request 캐시하기

preflight request는 리소스가 많이 드는 요청이라서
response.servletResponse.setHeader("Access-Control-Max-Age", "3600")
이런 식으로 Access-Control-Max-Age에 응답 캐시 유효 시간을 줘서, 리소스를 절약하는 방안을 추천한다고 한다.
(참고로 3600sec으로, 60분동안 캐시된다임)

Access-Control-Max-Age는 preflight의 결과(응답)를 얼마나 오랫동안 캐시할 건지를 결정하는 header으로, cache된 시간동안은 Preflight 요청을 날리지 않고 캐시된 응답을 사용한다.



[Spring Rest API] @CrossOrigin을 줬는데도 CORS 에러가 발생할 때 (CORS preflight 오류)
CORS, Preflight, 인증 처리 관련 삽질 | Popit

profile
모종삽에서 포크레인까지

0개의 댓글