[COGO] CORS에러 해결(preflight와 options, 그리고 JWT)

hwee·2024년 5월 14일
0

COGO개발과정

목록 보기
1/12
post-thumbnail
post-custom-banner

세줄 요약

  1. CORS 에러는 다른 도메인에서 내 서버의 리소스를 요구할 때 허가되어 있지 않으면 발생한다.
  2. 해당 허가를 확인하기 위하여 클라이언트는 본 요청을 보내기 전 Preflight 작업을 한다.
  3. 해당 Preflight 작업은 HTTP options 요청을 보내는데, 이 요청은 JWT Filter에서 뚫어놔야 한다.(토큰을 들고 오지 않기 때문)

문제 상황

Postman으로 테스트를 완료한 API들이, https 프로토콜을 사용한 배포를 한 후부터 CORS에러가 발생하였다.
이 에러를 해결하고자 다음의 과정들을 거쳤다.
1. CORS에러가 발생하는 이유 분석
2. Preflight와 Options 요청이 필요한 이유 분석
3. 요청이 들어왔을 때, 시큐리티의 필터들을 거치는 순서 분석
4. 문제가 생기는 필터 발견 및 원인 분석


이 에러를 해결하고자 CORS에러의 발생 원인을 분석하였고, 해결하는 과정을 기재하였다.

CORS 에러란?

Cross Origin Resource Sharing 에러의 줄임말로, 웹 애플리케이션의 리소스가 다른 도메인, 프로토콜, 또는 포트에서 실행 중인 웹 페이지에서 요청될 때 발생하는 보안 관련 오류이다.

동일 출처 정책

웹 보안의 기본 원칙 중 하나로, 웹 브라우저는 스크립트가 자신이 로드된 그 출처(도메인, 프로토콜, 포트)의 리소스만 접근할 수 있도록 제한한다. 즉, 클라이언트(프론트엔드)와 WAS(백엔드)가 다른 서버를 사용한다면, 도메인이 다르기에 WAS의 리소스에 접근할 수 없다.

Postman에선 문제가 없던 이유

동일 출처 정책은 브라우저 통신에서 이루어지므로, 브라우저가 아닌 Postman에서는 CORS 제약에 걸리지 않는다.

Options 요청과 Preflight

Preflight란?

브라우저가 비단순 요청(사진 업로드를 제외한 json으로 데이터를 주고받는 대부분의 요청들)을 할 때, 요청을 보내기 전 해당 경로에 Options HTTP 메소드로 예비 요청을 보내 유효한 응답을 받을 수 있는지 미리 체크하는 것

사진출저 : wjdwl002 벨로그

Options란?

서버가 어떤 method, header, content-type을 지원하는지 미리 알아내는 HTTP method이다.
200ok와 함께 위의 내용들을 포함하여 응답이 돌아와야 한다.

1차 문제 상황 분석

아래의 코드는 필자의 SecurityConfig 설정이다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(corsCustomizer -> corsCustomizer.configurationSource(request -> {
                CorsConfiguration configuration = new CorsConfiguration();
                configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); // 프론트 서버의 주소
                configuration.setAllowedMethods(Collections.singletonList("*"));  // 모든 요청 메서드 허용
                configuration.setAllowCredentials(true);
                configuration.setAllowedHeaders(Collections.singletonList("*"));  // 모든 헤더 허용
                configuration.setMaxAge(3600L);
                configuration.setExposedHeaders(Collections.singletonList("Set-Cookie"));  // Set-Cookie 헤더 노출
                configuration.setExposedHeaders(Collections.singletonList("Authorization"));
                return configuration;
            }))
            .csrf(csrf -> csrf.disable())  // CSRF 비활성화
            .formLogin(formLogin -> formLogin.disable())  // 폼 로그인 비활성화
            .httpBasic(httpBasic -> httpBasic.disable())  // HTTP Basic 인증 비활성화
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOAuth2UserService))
                .successHandler(customSuccessHandler))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // 모든 OPTIONS 요청에 대해 인증을 요구하지 않음
                .requestMatchers("/health-check", "/", "/reissue", "/security-check").permitAll()
                .requestMatchers("/api/v1/user/**", "/auth/**").hasRole("USER")
                .requestMatchers("/api/v1/possibleDate/**").hasRole("MENTOR")
                .requestMatchers("/api/v1/mentor/**").hasRole("MENTEE")
                .anyRequest().authenticated())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 세션 정책을 STATELESS로 설정
            .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class)
            .addFilterAfter(new JWTFilter(jwtUtil), OAuth2LoginAuthenticationFilter.class);
        return http.build();
    }

Cors필터는 모든 부분을 문제없이 뚫어놓았기에 문제가 아닐거라고 판단하였다.
Options 요청과 특정 경로들에 대하여 permitAll()설정을 통하여 시큐리티의 인증 체계를 거치지 않도록 설정하였지만, preflight요청에 응답이 제대로 되지 않았다.

curl -X OPTIONS {요청경로} -i


어째서 제대로 된 응답이 오지 않는지 알아보기 위하여 디버깅 모드로 요청을 보낸 후, 시큐리티의 필터들이 걸치는 과정들을 살펴보았다.

필터를 거치는 순서


디버깅 모드를 통하여 스택 트레이스를 분석하니, Preflight는 토큰을 포함하지 않고 Options 요청이 들어오기에 JWT Filter를 거치지 않아야 하지만, 거치게 되어 문제가 되었다.
요청이 들어왔을 때 필터를 거친 대략적 순서는 다음과 같다.

순서(주요 필터 볼드처리)

  1. DelegatingFilterProxy: FilterChainProxy를 호출
  2. SecurityContextPersistenceFilter: 보안 컨텍스트를 로드하고, 요청이 끝난 후에는 컨텍스트를 저장
  3. HeaderWriterFilter: 보안 관련 헤더를 응답에 추가
  4. CorsFilter: CORS 요청을 처리
  5. CustomLogoutFilter: 사용자 정의 로그아웃 처리 로직을 수행합니다.
  6. LogoutFilter: 세션을 무효화하고 로그아웃을 처리합니다.
  7. OAuth2AuthorizationRequestRedirectFilter: OAuth2 로그인 요청을 처리합니다.
  8. AbstractAuthenticationProcessingFilter: 인증 요청을 처리합니다.
  9. JWTFilter: JWT 토큰을 검증하고 보안 컨텍스트에 인증 정보를 설정합니다.
  10. RequestCacheAwareFilter: 이전 요청을 캐시에서 복원합니다.
  11. SecurityContextHolderAwareRequestFilter: HttpServletRequest를 Spring Security에 맞게 래핑합니다.
  12. AnonymousAuthenticationFilter: 익명 사용자를 위한 인증 토큰을 생성합니다.
  13. SessionManagementFilter: 세션 정책을 처리합니다.
  14. ExceptionTranslationFilter: 보안 예외를 Spring Security 흐름에 맞게 변환합니다.
  15. FilterSecurityInterceptor: 접근 제어 결정을 내리고, 인증된 사용자의 요청이 허용된 자원에 접근하는지 검사합니다.
    즉, 앞서 authorizeHttpRequests에서 설정한 내용들은 JWTFilter를 걸쳐 토큰에 담긴 인증정보들을 가져온 후 반영된다.
.authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // 모든 OPTIONS 요청에 대해 인증을 요구하지 않음
                .requestMatchers("/health-check", "/", "/reissue", "/security-check").permitAll()
                .requestMatchers("/api/v1/user/**", "/auth/**").hasRole("USER")
                .requestMatchers("/api/v1/possibleDate/**").hasRole("MENTOR")
                .requestMatchers("/api/v1/mentor/**").hasRole("MENTEE")
                .anyRequest().authenticated())

따라서 계속하여 Options요청에 제대로 된 응답이 되지 않았던 이유는 JWTFilter에서 Options요청에 대한 처리를 해주지 않았기 떄문이다!

JWTFilter 수정

JWT Filter가 먼저 적용되는 것을 알았으니, JWT없이 요청이 들어오는 경로는 뚫어주는 코드를 추가하였다.

// 특정 경로들에 대해 필터 로직을 건너뛰도록 설정
		if (request.getMethod().equals(HttpMethod.OPTIONS.name())) {
			// OPTIONS 요청일 경우 필터 처리를 건너뛰고 다음 필터로 진행
			filterChain.doFilter(request, response);
			return;
		}
		String path = request.getRequestURI();
		if (path.startsWith("/health-check") || path.startsWith("/security-check") || path.startsWith("/reissue")) {
			filterChain.doFilter(request, response);
			return;
		}

결과


이제 preflight에 정상적인 응답을 주는 것을 확인하였고, 스웨거를 확인해보니 정상적으로 작동하였다.

추가적인 내용

1. WebMvcConfigurer와 CorsFilter

Spring Security의 필터체인에 위치한 CORS 필터는 요청이 들어올 때 출처, HTTP 메소드, 헤더 등을 검사하여 CORS 정책에 맞는지 확인한다. 이 과정에서 요청이 거부되면, 더 이상 진행되지 않고 에러 응답을 반환한다.
요청이 Spring Security 필터를 통과하고 나면, Spring MVC의 처리 단계로 넘어가는데, 이 때 WebMvcConfigurer에 설정된 CORS 설정이 적용된다. 이 설정은 주로 응답에 필요한 CORS 헤더를 추가하는 역할을 하며, 클라이언트(브라우저)가 서버의 응답을 받아들일 수 있도록 한다.

@Configuration
public class CorsMvcConfig implements WebMvcConfigurer { //컨트롤러에서 보내는 데이터를 받을수 있게끔
	@Override
	public void addCorsMappings(CorsRegistry corsRegistry) {
		corsRegistry.addMapping("/**")  //모든 경로에서 매핑 진행
			.exposedHeaders("Set-Cookie")      //노출할 헤더값은 쿠키헤더
			.allowedOrigins("http://localhost:3000")
			.allowedMethods("OPTIONS", "GET", "POST", "PUT", "DELETE")
			.allowedHeaders("*")
			.allowCredentials(true)
			.maxAge(3600);
	}
}

2. 스웨거에 기본 도메인 설정

스웨거를 통한 요청을 보낼 때, 기본 도메인을 설정하여야 요청 url이 잘못되는 것을 방지할 수 있다.(예를 들어, https프로토콜을 사용하는 도메인에 http 프로토콜로 요청이 가는 것을 방지할 수 있다.)

@OpenAPIDefinition(
	servers = {
		@Server(url = "https://{도메인}", description = "Production server"),
		@Server(url = "http://localhost:8080", description = "Local development server")
	}
)
profile
https://fuzzy-hose-356.notion.site/1ee34212ee2d42bdbb3c4a258a672612
post-custom-banner

0개의 댓글