Spring Security의 Prefligt Options 요청 처리 에러 - TaeDongUm/Step_i GitHub Wiki

✅ Preflight이란?

  • **CORS(Cross-Origin Resource Sharing)**와 관련된 개념
  • 브라우저가 보안상의 이유로 교차 출처 요청을 보낼 때 **실제 요청을 보내기 전에 서버에 허락을 구하는 사전 요청(preflight request)**임

✅ Preflight 요청이 발생하는 조건

다음 중 하나라도 해당되면 브라우저는 OPTIONS 메서드를 사용해 preflight 요청을 먼저 보냄:

  • Content-Type이 application/json, text/plain, multipart/form-data 이외의 경우
  • Authorization 헤더, X-Auth-Token 같은 커스텀 헤더가 포함된 경우
  • PUT, DELETE, PATCH 같은 "simple method"가 아닌 HTTP 메서드 사용
  • CORS 요청이 자격 증명(credential)을 포함하는 경우 (withCredentials: true)

🛫 Preflight 흐름 예시

OPTIONS /api/data HTTP/1.1
Origin: https://my-frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

이 요청에 대해 서버가 허용하면 아래와 같은 응답을 보냄:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://my-frontend.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

이후 브라우저가 본 요청을 보냄.

🚨 문제 핵심

  • Spring Security가 Preflight 요청(OPTIONS)을 인증이 필요한 요청으로 판단하여 401 Unauthorized 응답을 반환하고 있었음.
  • 이는 Spring Security의 기본 인증 설정(Basic Auth)이 활성화되어 있었기 때문.

🛠️ 해결 방법

  • OPTIONS 요청 허용: Spring Security에서 OPTIONS 요청을 명시적으로 허용하여 인증 없이 처리되도록 설정.
  • Basic Auth 비활성화: httpBasic(http -> http.disable())를 사용하여 기본 인증을 완전히 비활성화.
package com.stepi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // OPTIONS 요청 허용
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .httpBasic(http -> http.disable())  // Basic 인증 비활성화
            .formLogin(form -> form.disable())
            .logout(logout -> logout.disable())
            .headers(headers -> headers
                .frameOptions(frame -> frame.disable())
                .xssProtection(xss -> xss.disable())
            );

        return http.build();
    }
}
  • CORS 설정 적용: CorsFilter를 Security FilterChain보다 먼저 실행되도록 설정하여 CORS 헤더가 응답에 포함되도록 수정.
package com.stepi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:5173"));  // 정확한 Origin 설정
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
        config.setAllowedHeaders(List.of(
            "Authorization",
            "Content-Type",
            "X-Requested-With",
            "Accept",
            "Origin",
            "Access-Control-Request-Method",
            "Access-Control-Request-Headers"
        ));
        config.setExposedHeaders(List.of("Authorization"));
        config.setAllowCredentials(true);  // 인증 정보 허용
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public CorsFilter corsFilter() {
        return new CorsFilter(corsConfigurationSource());  // Security FilterChain보다 먼저 실행
    }
}