Spring Security CORS 설정하기
Configure CORS Policy with Spring Security
웹 애플리케이션에서 서버로 API를 호출할 때 서버와 클라이언트의 출처가 서로 다른 경우 브라우저의 CORS 정책에 위배되어 CORS 에러가 발생합니다.
이번 포스팅에서는 CORS란 무엇이며 Spring Security를 통해 CORS 정책을 설정하는 방법에 대해 살펴봅니다.
CORS Policy
CORS란 Cross-Origin Resource Sharing의 약자로 번역하면 교차 출처 리소스 공유입니다.
Cross-Origin Resource Sharing
CORS 교차 출처 리소스 공유는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다. 웹 애플리케이션은 리소스가 자신의 출처와 다를 때 교차 출처 HTTP 요청을 실행합니다.
© MDN
브라우저는 보안적인 이유로 다른 출처에 대한 요청을 기본적으로 제한하고 있습니다. 그래서 웹 애플리케이션에서 외부 서버의 특정 자원에 대해 접근을 하기 위해서는 해당 서버의 승인이 필요합니다.
CORS 동작 방식
- 이 정책은 브라우저에서 지정한 스펙입니다. 따라서, 브라우저 위에서 동작하는 웹 애플리케이션은 반드시 이행해야 합니다.
- JavaScript로 발생하는 요청에 대해서만 적용되며, HTML 태그로 인한 요청에는 적용되지 않습니다.
- JavaScript를 통해 서버에 요청을 보낼 때 브라우저에서 자동으로 출처(Origin)를 확인합니다. 서로 출처가 다른 경우 요청 헤더에 Origin 출처를 담아 HTTP 요청을 전송합니다.
- 요청을 받은 서버는 처리를 완료하고 응답할 때
Access-Control-Allow-Origin
과 같은 헤더를 함께 담아 전송합니다. - 브라우저는 서버의 응답 헤더를 비교하여 최종적으로 CORS 정책 통과 여부를 판단합니다.
이 CORS 정책은 브라우저에서 정한 스펙이다 보니, 브라우저 단에는 CORS 에러가 발생하더라도 서버에서는 정상적으로 요청을 처리한 것으로 나타납니다.
Origin 비교
CORS 이야기를 하면서 계속해서 등장하는 출처(Origin)에 대하여 명확한 기준을 확인해둘 필요가 있습니다. 브라우저는 요청하는 곳의 출처와 현재 출처를 비교할 때 URL의 세 가지 구성 요소를 기반으로 판단합니다.
- Scheme: HTTP 또는 HTTPS
- Domain: 도메인 주소
- Port: 애플리케이션 포트
만약 세 가지 구성 요소중 하나라도 일치하지 않는다면, 브라우저는 서로 다른 출처라고 판단합니다.
⛔ https://catsriding.dev ≠ http://catsriding.dev <- Scheme이 다른 경우
⛔ https://app.catsriding.dev ≠ https://www.catsriding.dev <- Domain이 다른 경우
⛔ https://catsriding.dev:8080 ≠ https://catsriding.dev:3000 <- Prot가 다른 경우
CORS 정책 검증
CORS 정책을 검증하는 방식은 HTTP 요청 스펙에 따라 구분하여 진행됩니다.
Simple Request
브라우저가 정한 특정 조건을 만족할 경우, JavaScript로 작성된 요청은 Simple Request(기본 요청) 방식으로 처리됩니다. 이 방식은 아래에 제시된 HTTP 요청 스펙을 따르며, 주로 과거에 설계된 API들에서 주로 사용되었습니다.
- HTTP Methods
- GET
- POST
- HEAD
- HTTP Headers
- Accept
- Accept-Language
- Content-Language
- Content-Type
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
- DPR
- Downlink
- Save-Data
- Viewport-Width
이 Simple Request 방식은 서버로 한 번의 본 요청만을 보내며, 서버에서 내려온 응답 헤더의 값을 통해 CORS 정책을 검증하여 최종적으로 통과 여부를 확인하는 방식입니다.
오래 전에 구현된 API가 이 조건에 해당하는 이유는 Content-Type
으로 JSON 포맷을 지원하지 않기 때문입니다. 현재 널리 사용되는 REST API는 다음 Preflight Request 방식이 적용됩니다.
Preflight Request
Simple Request 방식의 조건에 벗어나는 경우에는 모두 이 Preflight Request(예비 요청)방식으로 진행됩니다. Preflight의 사전적 의미처럼, 브라우저에서 서버로 요청을 보낼 때 본 요청에 앞서 자동으로 예비 요청을 먼저 전송합니다. 결과적으로 API 호출 시 총 두 번의 요청이 서버로 전송됩니다.
이 예비 요청에는 OPTIONS
라는 HTTP 메서드가 사용되며, 단순히 출처에 대한 정보 이외에도 본 요청에 대한 정보들이 함께 포함됩니다.
OPTIONS https:/v1/users HTTP/1.1
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: origin, x-requested-with
Origin: http://localhost:3000
만약 서버에서 이 출처를 허용하고 있는 경우에는 Access-Control-Allow-Methods
응답 헤더가 전달됩니다.
HTTP/1.1 200 OK
Connection: keep-alive
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE
Access-Control-Max-Age: 86400
서버에서 요청자의 출처를 허용하고 있지 않다면, 브라우저는 CORS 에러를 발생시키고 서버로 부터 내려온 응답을 사용하지 않고 폐기합니다.
참고로, Preflight Request는 성공하였지만 실제 요청에서 CORS 에러가 발생할 수도 있습니다. Preflight Request 이후에 본 요청을 전송하면서 두 요청간의 시간차가 발생하여 그동안 추가된 정보가 있거나 서버에 변동 사항이 있을 수 있기 때문입니다.
CORS 에러
웹 서비스를 개발하다 보면 누구나 한 번쯤은 마주하게 되는 그런 에러 메시지가 있습니다.
Access to fetch at 'https://app.catsriding.dev/v1/users' 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 nees, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
에러 메시지를 간단히 요약하자면 app.catsriding.dev 서버에서 localhost:3000 출처에 대한 접근을 허용하고 있지 않다는 의미입니다. 이를 해결하기 위해서는 서버에서 localhost:3000 출처에 대한 접근을 허용해주어야 합니다.
CORS Configuration
CORS 에러를 해결하기 위해서는 서버에서 CORS 정책과 관련된 설정을 추가해야 합니다.
- `Access-Control-Allow-Origin`: 허용되는 출처 설정
- `Access-Control-Allow-Methods`: 허용되는 HTTP 메서드 설정
- `Access-Control-Allow-Headers`: 허용되는 HTTP 헤더 설정
- `Access-Control-Allow-Credentials`: 쿠키 또는 토큰을 통한 사용자 자격 증명 포함 허용 여부 설정
- `Access-Control-Max-Age`: Preflight의 캐시 유효 시간 설정
Spring Security는 CorsConfigurer
를 통해 CORS 정책과 관련된 설정을 처리하고 있습니다.
CorsConfigurer
는 Spring Security 필터 체인에CorsFilter
를 추가합니다.corsFilter
라는 이름의 Bean이 제공이 된다면 이 필터가 우선적으로 사용됩니다.- 만약
corsFilter
이름으로 등록된 빈이 없다면 다음은corsConfigurationSource
빈이 정의되었는 지 확인하고, 등록된 경우 이 값을 CORS 설정값으로 사용합니다. corsConfigurationSource
빈도 정의되어 있지 않은 경우, Spring MVC가 클래스 경로에 있으면HandlerMappingIntrospector
가 사용됩니다.
CorsFilter
CorsConfigurer
가 동작하는 순서대로 CorsFilter
빈을 등록하여 CORS 이슈를 해결하는 방법을 먼저 살펴보겠습니다.
CORS 설정을 담당할 CorsConfig
클래스를 추가하고 CorsFilter
빈을 등록합니다.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setMaxAge(1800L);
source.registerCorsConfiguration("/v1/**", corsConfiguration);
return new CorsFilter(source);
}
}
setAllowCredentials()
: 요청에 사용자의 자격 증명이 담긴 쿠키나 토큰이 포함 가능한지 여부를 설정합니다.addAllowedOrigin()
: 패턴으로 추가된 출처만 브라우저에서 리소스에 접근할 수 있도록 허용합니다.addAllowedOriginPattern()
: CORS 스펙에서 모든 출처 허용과 같은*
와일드카드가 규정되어 있지는 않습니다. 그래서 사용자 인증을 위해setAllowCredentials(true)
로 설정해야 하는 경우에는addAllowedOrigin()
대신에 이 메서드를 사용해야 합니다.addAllowedHeader()
: 허용하는 HTTP 헤더를 설정합니다.addAllowedMethod()
: 허용하는 HTTP 메서드를 설정합니다.setMaxAge()
: Preflight 요청에 대한 캐시 유효시간을 설정합니다.registerCorsConfiguration()
: 이 CORS 설정을 적용할 URL 패턴을 지정합니다.
CORS 필터를 빈에 등록했다면 다음은 Spring Security 필터 체인에 이 필터를 추가합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
CorsConfigurationSource
이번에는 CorsFilter
등록 대신 CorsConfigurationSource
빈을 등록하여 CORS 관련 정책을 설정하는 방법입니다. CorsFilter
를 등록하는 방식과 크게 다르지 않습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(customizer -> customizer.configurationSource(corsConfigureSource()));
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigureSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOriginPattern("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setMaxAge(1800L);
corsConfiguration.addExposedHeader(AUTHORIZATION);
source.registerCorsConfiguration("/v1/**", corsConfiguration);
return source;
}
}
- Spring
- Security