CORS(Cross-Origin Resource Sharing)는 서로 다른 출처를 가진 리소스의 공유를 허락하는 정책입니다. 출처가 다르더라도 요청과 응답을 주고받을 수 있도록 서버에 리소스 호출이 허용된 출처(Origin)을 명시해 주는 방식으로 허용하고 있습니다.
CORS에러는 이러한 출처를 명시하지 않고 SOP(Same Origin Policy)를 미준수 할 때 발생합니다. 일반적인 해결하는 방법은 다음과 같습니다.
그래서 첫번째 방식으로 아래와 같이 백엔드에서 allowedOrigins("*")
를 작성하면서 모든 출처에 대해서 허용하는 방식으로 했습니다만...?
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowedOrigins("*") // 모든 출처 허용
.allowedMethods(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.PUT.name(),
HttpMethod.DELETE.name(), HttpMethod.HEAD.name(), HttpMethod.OPTIONS.name(),
HttpMethod.PATCH.name())
.maxAge(3600); // Pre-flight Caching
}
???????
가 되는 원인을 설명하고 이를 해결해보겠습니다.
axios()
요청을 할 시에 브라우저 - 서버 사이에서는 다음과 같은 과정을 거칩니다.
브라우저가 서버로 OPTIONS 메소드로 다음과 같은 예비 요청(Preflight)을 보냅니다.
해당 예비 요청에 대한 다음과 같은 응답을 받습니다.
브라우저에서의 출처(origin)와 서버에서 허용한 출처(access-control-allow-origin)를 비교합니다.
그런데... 1번 과정에서 이상한 점이 있습니다. 해당 요청을 한 axios를 보겠습니다.
async function info(request, success, fail) {
// console.log("member.js 회원 정보 요청 ", request);
await server
.get("/member/info", {
headers: {
Authorization: `Bearer ${request.trim()}`
}
})
.then(success)
.catch(fail);
}
request를 컨솔에 찍어서 확인해보면 브라우저에서 저장하고 있는 JWT의 accessToken 이 나오게 됩니다. 그런데 Preflight에서는 해당 정보를 아무리 찾아봐도 안보입니다.
그리고 그러고 보니 info 요청이 2개가 와있는 것이 보입니다. Preflight와 본요청인데, 본요청이 윗줄에 해당합니다.
본 요청에서는 JWT가 보내지는 것을 확인할 수 있는데, 여기서 문제가 발생합니다.
바로, Preflight 요청에는 JWT 정보가 존재하지 않습니다. 즉, Preflight 역시 인증(authorization)이 필요한 요청으로 서버에서 받아 들여서 CORS 허용이 되지 않는 것입니다.
그러면 이를 어떻게 해결할까요? 다시 CORS 동작의 첫번째 과정을 봅니다.
즉 Preflight 요청은 무조건 OPTIONS 메소드로 이루어집니다. 따라서 OPTIONS 메소드의 경우에는 별도의 인증 없이 모두 받아들이면 해결이 가능합니다.
우선, OPTIONS 메소드를 허용합니다. 사실 이는 전에 잘 적어두어서 추가적인 수정은 없었습니다.
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowedOrigins("*") // 모든 출처 허용
.allowedMethods(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.PUT.name(),
HttpMethod.DELETE.name(), HttpMethod.HEAD.name(), HttpMethod.OPTIONS.name(),
HttpMethod.PATCH.name())
.maxAge(3600);
}
다음으로, 인증 확인을 위해 Interceptor를 확인합니다. 여기서, 메소드가 OPTIONS일 경우에는 별도의 인증을 받지 않도록 합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthorizationInterceptor implements HandlerInterceptor {
private final JWTUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authorizationHeader = request.getHeader("Authorization");
if (request.getMethod().equals("OPTIONS")) {
return true; // OPTIONS 메소드, 즉 Preflight 요청의 경우 추가 확인을 진행하지 않음
}
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
log.info("Missing or invalid Authorization header {}", authorizationHeader);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing or invalid Authorization header");
}
String token = authorizationHeader.substring(7);
if (!jwtUtil.checkToken(token)) {
log.info("Token is expired or invalid {}", token);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Token is expired or invalid");
}
String userId = jwtUtil.getUserId(token);
request.setAttribute("userId", userId);
return true;
}
}
이전에는 프론트엔드 프레임워크 대신 JSP로 모두 다 짜다보니 Frontend 서버와 Backend 서버가 따로 돌아가는 상상을 잘하지 못한 것 같고, 실제 이 두 서버가 요청과 응답을 주고 받기 위해서는 어떤 과정을 거치는지 모르고 있었습니다.
하지만, 실제 프론트엔드 프레임워크 Vuejs 를 사용하면서, 이 둘의 연결에 대해 고민해본 좋은 계기였습니다.