You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Spring Boot认证服务器上正确配置CORS?

解决Spring Boot OAuth2 Token端点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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 03:03:11