如何在Spring Boot认证服务器上正确配置CORS?
问题场景
我有一个对接Spring Boot认证服务器的Vue.js应用,实现了完整的登录流程(重定向至/login、请求token端点等)。在禁用CORS检查的浏览器中,登录及其他请求均正常,但普通浏览器请求token端点时出现CORS错误:
Access to fetch at 'http://localhost:8081/oauth2/token?grant_type=authorization_code&client_id=core-server&code=Ps19gIUUePThDLr15xX0U-UWEMd0HgHfyAOCcZjGmfXUqED80GOMLBykuldNrL7k23dxEydcP49hX_kGigKsZjcFCTS93xU7kwdwAIm6-eIcIk_ayN5i0mZPLeg_bDYx&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: 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.
前端登录组件代码
<template> <div class="h-screen w-screen flex flex-col items-center justify-center"> <p class="text-center mb-5">You need to be logged in<br>to access the admin dashboard.</p> <button @click.prevent="startAuthFlow" class="bg-blue-500 text-white h-8 w-36 rounded shadow">Login</button> </div> </template> <script setup> import { config } from "./../common/config.js"; import { parseQueryString, generateRandomString } from "./../utils/authHelpers.js"; import router from './../router'; import { useAuthStore } from './../stores/auth.js'; const auth = useAuthStore(); if (auth.access_token) { router.push({ name: 'home', replace: true }); } const startAuthFlow = () => { console.log("Starting auth flow..."); // Create and store a random "state" value var state = generateRandomString(); localStorage.setItem("pkce_state", state); console.log(state); // Build the authorization URL var url = config.authorization_endpoint + "?response_type=code" + "&client_id=" + encodeURIComponent(config.client_id) + "&state=" + encodeURIComponent(state) + "&redirect_uri=" + encodeURIComponent(config.redirect_uri); // Redirect to the authorization server window.location = url; }; // Handle the redirect back from the authorization server and // get an access token from the token endpoint var q = parseQueryString(window.location.search.substring(1)); // Check if the server returned an error string if (q.error) { alert("Error returned from authorization server: " + q.error); } // If the server returned an authorization code, attempt to exchange it for an access token if (q.code) { // Verify state matches what we set at the beginning if (localStorage.getItem("pkce_state") != q.state) { alert("Invalid state"); } else { // Base64 encode client credentials const base64Credentials = btoa(config.client_id + ':' + config.client_secret); // Build the token URL var url = config.token_endpoint + "?grant_type=authorization_code" + "&client_id=" + encodeURIComponent(config.client_id) + "&code=" + q.code + "&redirect_uri=" + encodeURIComponent(config.redirect_uri); // Send POST request to token endpoint to retrieve access token fetch(url, { method: "POST", headers: { "Authorization": "Basic " + base64Credentials, 'Content-Type': 'application/x-www-form-urlencoded', } }).then(response => response.json()) .then(result => { console.log(result); // Extracting tokens from result const { access_token, refresh_token } = result; // Save login to pinia and tokens to cookies auth.login({ access_token, refresh_token, user: null }); router.push({ name: 'home', replace: true }); }) .catch(error => console.log('error', error)); } // Clean up local storage localStorage.removeItem("pkce_state"); } </script>
后端CORS配置(CorsConfig.java)
@Configuration public class CorsConfig { private static final Logger logger = LoggerFactory.getLogger(CorsConfig.class); /** * Cors configuration */ @Bean(name="corsConfigurationSource") CorsConfigurationSource corsConfigurationSource() { logger.info("Creating corsConfigurationSource bean"); CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of( "http://localhost:5173", "http://192.168.2.144:5173" )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); configuration.setAllowCredentials(true); configuration.setAllowedHeaders(List.of( "Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With" )); configuration.setExposedHeaders(List.of( "Cache-Control", "Content-Language", "Content-Type", "Expires", "Last-Modified", "Pragma" )); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }
后端安全配置(SecurityConfig.java)
@Configuration @EnableWebSecurity public class SecurityConfig { // claim names used in the bearer token private static final String ROLES_CLAIM = "user-authorities"; private static final String SCOPES_CLAIM = "scope"; private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class); @Bean @Order(1) public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) { logger.info("Creating corsFilter bean"); return new CorsFilter(corsConfigurationSource); } /** * Configures the authorization server endpoints. */ @Bean @Order(2) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, RegisteredClientRepository clientRepository) throws Exception { logger.info("Creating authorizationServerSecurityFilterChain bean"); OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .registeredClientRepository(clientRepository) // autowired from ClientConfig.java .oidc(Customizer.withDefaults()); http.exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ); http.oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); http.csrf(AbstractHttpConfigurer::disable); return http.build(); } /** * Secures pages used to log in, log out, register etc. * Sets custom login menu. */ @Bean @Order(3) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(new NegatedRequestMatcher(new AntPathRequestMatcher("/admin/**"))); logger.info("Creating defaultSecurityFilterChain bean"); http.authorizeHttpRequests((authorize) -> authorize .requestMatchers(new AntPathRequestMatcher("/register")).permitAll() .requestMatchers(new AntPathRequestMatcher("/recover/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/error/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/css/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/js/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/favicon.ico")).permitAll() .anyRequest().authenticated()); http.oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); // set custom login form http.formLogin(form -> { form.loginPage("/login"); form.permitAll(); }); http.logout(conf -> { // default logout url conf.logoutSuccessHandler(logoutSuccessHandler()); }); // Temp disable CSRF http.csrf(AbstractHttpConfigurer::disable); http.cors(AbstractHttpConfigurer::disable); return http.build(); } /** * Secures admin endpoints with a bearer token. Does not use session authentication. */ @Bean @Order(4) public SecurityFilterChain adminResourceFilterChain(HttpSecurity http) throws Exception { logger.info("Creating adminResourceFilterChain bean"); // handle out custom endpoints in this filter chain http.authorizeHttpRequests((authorize) -> authorize .requestMatchers(new AntPathRequestMatcher("/admin/**")).hasRole("ADMIN") .anyRequest().authenticated()); http.sessionManagement(conf -> conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); // Temp disable CSRF http.csrf(AbstractHttpConfigurer::disable); http.cors(AbstractHttpConfigurer::disable); return http.build(); } // ...
已尝试的方法
- 修改过滤器链,将CORS配置加入每个Security Bean顶部
- 按照Spring Security官方CORS教程逐一实现
- 手动设置请求头(非长期方案)
- 临时允许所有请求(无效)
解决方案
1. 为Authorization Server过滤器链启用CORS配置
OAuth2的/oauth2/token端点由authorizationServerSecurityFilterChain处理,需要在该链中明确启用CORS并关联配置源:
@Bean @Order(2) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, RegisteredClientRepository clientRepository, CorsConfigurationSource corsConfigurationSource) throws Exception { logger.info("Creating authorizationServerSecurityFilterChain bean"); OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .registeredClientRepository(clientRepository) .oidc(Customizer.withDefaults()); http.exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ); http.oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); http.csrf(AbstractHttpConfigurer::disable); // 新增:启用CORS并绑定配置源 http.cors(c -> c.configurationSource(corsConfigurationSource)); return http.build(); }
2. 移除其他过滤器链中禁用CORS的配置
在defaultSecurityFilterChain和adminResourceFilterChain中,删除http.cors(AbstractHttpConfigurer::disable);这一行,改为启用默认CORS配置:
// 替换原有的禁用配置 http.cors(Customizer.withDefaults());
3. 验证配置生效逻辑
- 确保
CorsFilter的优先级(@Order(1))高于所有Security过滤器链,保证CORS预检请求被优先处理。 - 确认
CorsConfiguration中的allowedOrigins包含前端域名,allowedMethods包含POST(token端点使用POST请求),allowedHeaders包含Authorization和Content-Type。
内容的提问来源于stack exchange,提问作者J. M. Arnold

