Spring Boot 中全面解决跨域请求

什么是跨域请求(CORS)

跨域的概念

跨域是指浏览器出于安全考虑,限制了从不同源(协议,域名,端口任一不同)的服务器请求资源,这是浏览器的同源策略(Same-Origin Policy)所导致的。

同源策略要求一下三个必须相同

  • 协议相同(http/https)
  • 域名相同
  • 端口相同

为什么需要CORS

在实际开发中,前后端分离架构非常普遍,前端应用运行在一个域名下,后端 API 服务运行在另一个域名下,这时就会遇到跨域问题

CORS(Cross-Origin Resource Sharing) 是一种 W3C 标准,允许服务器声明哪些源可以访问其资源,从而解决跨域问题。

CORS 的工作原理

简单请求与预检请求

浏览器将 CORS 请求分为两类:简单请求预检请求

简单请求条件

  • 方法为 GET、HEAD、POST 之一
  • Content-Type 为 text/plain、multipart/form-data、application/x-www-form-urlencoded 之一
  • 没有自定义头部

预检请求
不满足简单请求条件的请求会先发送 OPTIONS 预检请求,获得服务器许可后再发送实际请求。

Spring Boot 中解决CORS的四种方式

方法一 使用@CrossOrigin 注解

在控制器方法上使用

@RestController
@RequestMapping("/api")
public class UserController {
    
    @GetMapping("/users")
    @CrossOrigin(origins = "http://localhost:3000")
    public List<User> getUsers() {
        return Arrays.asList(
            new User(1, "张三", "zhangsan@example.com"),
            new User(2, "李四", "lisi@example.com")
        );
    }
    
    @PostMapping("/users")
    @CrossOrigin(origins = "http://localhost:3000")
    public User createUser(@RequestBody User user) {
        // 创建用户逻辑
        return user;
    }
}

多路径模式配置

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // API 路径配置
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
        
        // 管理后台路径配置
        registry.addMapping("/admin/**")
                .allowedOrigins("https://admin.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("Authorization", "Content-Type", "X-Requested-With")
                .allowCredentials(true)
                .maxAge(1800);
        
        // 公开接口配置
        registry.addMapping("/public/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "HEAD")
                .allowedHeaders("Content-Type")
                .maxAge(1800);
    }
}

环境特定的配置

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Value("${cors.allowed.origins:http://localhost:3000}")
    private String[] allowedOrigins;
    
    @Value("${cors.allowed.methods:GET,POST,PUT,DELETE,OPTIONS}")
    private String[] allowedMethods;
    
    @Value("${cors.max.age:3600}")
    private long maxAge;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins(allowedOrigins)
                .allowedMethods(allowedMethods)
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(maxAge);
    }
}

对应的 application.yml 配置:

cors:
  allowed:
    origins: 
      - "http://localhost:3000"
      - "https://staging.example.com"
      - "https://app.example.com"
    methods: "GET,POST,PUT,DELETE,OPTIONS"
  max:
    age: 3600

方法三 使用CorsFilter 过滤器

基础CorsFilter配置

@Configuration
public class GlobalCorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        
        // 允许的域名
        config.addAllowedOrigin("http://localhost:3000");
        config.addAllowedOrigin("https://example.com");
        
        // 允许的请求方法
        config.addAllowedMethod("GET");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("OPTIONS");
        
        // 允许的请求头
        config.addAllowedHeader("*");
        
        // 是否允许发送 Cookie
        config.setAllowCredentials(true);
        
        // 预检请求的有效期
        config.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        
        return new CorsFilter(source);
    }
}

高级CorsFilter配置

@Configuration
public class AdvancedCorsConfig {

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        
        // 从配置文件中读取允许的源
        config.setAllowedOrigins(Arrays.asList(
            "http://localhost:3000",
            "https://admin.example.com",
            "https://app.example.com"
        ));
        
        // 设置允许的方法
        config.setAllowedMethods(Arrays.asList(
            "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"
        ));
        
        // 设置允许的头部
        config.setAllowedHeaders(Arrays.asList(
            "Authorization",
            "Content-Type",
            "X-Requested-With",
            "Accept",
            "Origin",
            "Access-Control-Request-Method",
            "Access-Control-Request-Headers",
            "X-CSRF-TOKEN"
        ));
        
        // 设置暴露的头部
        config.setExposedHeaders(Arrays.asList(
            "Access-Control-Allow-Origin",
            "Access-Control-Allow-Credentials",
            "X-Custom-Header"
        ));
        
        // 允许凭证
        config.setAllowCredentials(true);
        
        // 预检请求缓存时间
        config.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        
        // 为不同路径设置不同的 CORS 配置
        source.registerCorsConfiguration("/api/**", config);
        
        CorsConfiguration publicConfig = new CorsConfiguration();
        publicConfig.addAllowedOrigin("*");
        publicConfig.addAllowedMethod("GET");
        publicConfig.addAllowedHeader("Content-Type");
        source.registerCorsConfiguration("/public/**", publicConfig);
        
        return new CorsFilter(source);
    }
}

方式四 手动处理OPTIONS请求

在某些特殊情况下,你可能需要手动处理预检请求:

@Component
public class CustomCorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        
        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletRequest request = (HttpServletRequest) req;
        
        // 设置 CORS 头部
        response.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
        response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Max-Age", "3600");
        
        // 如果是 OPTIONS 请求,直接返回 200
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return;
        }
        
        chain.doFilter(req, res);
    }
    
    @Override
    public void init(FilterConfig filterConfig) {
        // 初始化逻辑
    }
    
    @Override
    public void destroy() {
        // 清理逻辑
    }
}

高级配置和最佳实践

环境特定的cors配置

@Configuration
@Profile("dev")
public class DevCorsConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000", "http://localhost:8080")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

@Configuration
@Profile("prod")
public class ProdCorsConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com", "https://admin.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("Authorization", "Content-Type", "X-Requested-With")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

动态CORS配置

@Component
public class DynamicCorsFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        String origin = request.getHeader("Origin");
        
        if (isAllowedOrigin(origin)) {
            response.setHeader("Access-Control-Allow-Origin", origin);
            response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
            response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With");
            response.setHeader("Access-Control-Allow-Credentials", "true");
            response.setHeader("Access-Control-Max-Age", "3600");
        }
        
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return;
        }
        
        filterChain.doFilter(request, response);
    }
    
    private boolean isAllowedOrigin(String origin) {
        // 从数据库或配置中心动态获取允许的源
        List<String> allowedOrigins = Arrays.asList(
            "http://localhost:3000",
            "https://app.example.com",
            "https://admin.example.com"
        );
        return allowedOrigins.contains(origin);
    }
}

安全最佳实践

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and()  // 启用 CORS 支持
            .csrf().disable()  // 根据需求决定是否禁用 CSRF
            .authorizeRequests()
            .antMatchers("/api/public/**").permitAll()
            .antMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
            .and()
            .httpBasic();
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        
        // 生产环境中应该从配置文件中读取
        configuration.setAllowedOrigins(Arrays.asList(
            "https://trusted-domain.com",
            "https://app.trusted-domain.com"
        ));
        
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With"));
        configuration.setExposedHeaders(Arrays.asList("X-Custom-Header"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        
        return source;
    }
}

测试CORS配置

测试控制器

@RestController
@RequestMapping("/api/test")
public class CorsTestController {
    
    @GetMapping("/cors-test")
    @CrossOrigin(origins = "http://localhost:3000")
    public ResponseEntity<Map<String, String>> corsTest() {
        Map<String, String> response = new HashMap<>();
        response.put("message", "CORS test successful");
        response.put("timestamp", Instant.now().toString());
        return ResponseEntity.ok(response);
    }
    
    @PostMapping("/cors-post")
    public ResponseEntity<Map<String, Object>> corsPostTest(@RequestBody TestRequest request) {
        Map<String, Object> response = new HashMap<>();
        response.put("received", request);
        response.put("processedAt", Instant.now().toString());
        return ResponseEntity.ok(response);
    }
    
    @PutMapping("/cors-put/{id}")
    public ResponseEntity<Map<String, Object>> corsPutTest(
            @PathVariable String id, 
            @RequestBody TestRequest request) {
        Map<String, Object> response = new HashMap<>();
        response.put("id", id);
        response.put("data", request);
        response.put("updatedAt", Instant.now().toString());
        return ResponseEntity.ok(response);
    }
}

class TestRequest {
    private String name;
    private String email;
    
    // getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

前端测试代码

<!DOCTYPE html>
<html>
<head>
    <title>CORS Test</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
    <h1>CORS Test Page</h1>
    <button onclick="testGet()">Test GET</button>
    <button onclick="testPost()">Test POST</button>
    <button onclick="testPut()">Test PUT</button>
    <div id="result"></div>

    <script>
        const API_BASE = 'http://localhost:8080/api/test';
        
        async function testGet() {
            try {
                const response = await axios.get(`${API_BASE}/cors-test`, {
                    withCredentials: true
                });
                document.getElementById('result').innerHTML = 
                    `<pre>${JSON.stringify(response.data, null, 2)}</pre>`;
            } catch (error) {
                document.getElementById('result').innerHTML = 
                    `<pre style="color: red">Error: ${error.message}</pre>`;
            }
        }
        
        async function testPost() {
            try {
                const response = await axios.post(`${API_BASE}/cors-post`, {
                    name: 'Test User',
                    email: 'test@example.com'
                }, {
                    withCredentials: true,
                    headers: {
                        'Content-Type': 'application/json'
                    }
                });
                document.getElementById('result').innerHTML = 
                    `<pre>${JSON.stringify(response.data, null, 2)}</pre>`;
            } catch (error) {
                document.getElementById('result').innerHTML = 
                    `<pre style="color: red">Error: ${error.message}</pre>`;
            }
        }
        
        async function testPut() {
            try {
                const response = await axios.put(`${API_BASE}/cors-put/123`, {
                    name: 'Updated User',
                    email: 'updated@example.com'
                }, {
                    withCredentials: true,
                    headers: {
                        'Content-Type': 'application/json',
                        'X-Custom-Header': 'custom-value'
                    }
                });
                document.getElementById('result').innerHTML = 
                    `<pre>${JSON.stringify(response.data, null, 2)}</pre>`;
            } catch (error) {
                document.getElementById('result').innerHTML = 
                    `<pre style="color: red">Error: ${error.message}</pre>`;
            }
        }
    </script>
</body>
</html>

常见问题与解决方案

CORS配置不生效

问题原因:

  • 配置顺序问题
  • 过滤器链顺序问题
  • 安全配置冲突

解决方案:

@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE) // 确保高优先级
public class CorsConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

携带凭证(Credentials)问题

当使用 allowCredentials(true) 时,不能使用通配符 * 作为允许的源:

// 错误配置
.config.setAllowedOrigins(Arrays.asList("*")); // 与 allowCredentials(true) 冲突

// 正确配置
config.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://example.com"));

总结

Spring Boot 提供了多种灵活的方式来处理 CORS 跨域请求:

  • @CrossOrigin 注解:适合细粒度的控制器或方法级别配置

  • WebMvcConfigurer:适合应用级别的全局配置

  • CorsFilter:提供最灵活的过滤器级别配置

  • 手动处理:适合特殊需求的定制化处理

选择建议:

  • 开发环境:使用宽松的全局配置
  • 生产环境:使用严格的、基于配置的 CORS 策略
  • 微服务架构:在 API 网关层统一处理 CORS
  • 特殊需求:使用 CorsFilter 进行精细控制
posted @ 2025-10-22 22:36  小郑[努力版]  阅读(12)  评论(0)    收藏  举报