Spring 和 CORS 跨域

如果你从事 web 应用开发,在前端使用异步请求(fetch/XMLHttpRequest)时,那你或多或少都应该在浏览器控制台见识过如下异常信息。

Access to fetch at 'http://localhost:8080/hello' from origin 'http://localhost:1313' has been blocked by CORS policy: 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.

是的,这就是在异步请求时跨域失败的异常信息。接下来本文将会先简单地介绍跨域的基础知识,再详细地介绍如何在 spring 应用中处理跨域。

简单了解跨域

跨域,全称为“跨域资源共享”(Cross-origin resource sharing),是浏览器的一种安全机制。只会在浏览器中,使用 AJAX (fetch/XMLHttpRequest)时发生。当你在一个 web 页面中,使用 ajax 对目标 URL 发起了请求,只要目标 URL 的 协议主机端口 和当前 web 页面的 协议主机端口 任意不一致,就会产生跨域。

例如,我在页面 http://localhost:1313 中,对如下 URL 发起 AJAX 请求,都会导致跨域:

https://localhost:1313 # 协议不一致,跨域
http://127.0.0.1:1313  # 主机不一致,跨域
http://localhost:8088  # 端口不一致,跨域

当浏览器发现当前请求存在跨域的时候,浏览器会先往目标 URL 发送一个预检请求(OPTIONS)进行预检。这个请求会提交一些 Header 信息,服务器会根据此做出响应,同样也会写入一些 Header。浏览器就可以根据响应的 Header 信息来确认是否要阻止当前的跨域请求,也就是跨域成功与否。

理解跨域,本质上就是理解和跨域有关的请求 Header 和 响应 Header。

请求 Header

  • Origin

    跨域请求,是从哪里发出,也就是表示请求来自哪个源(协议 + 主机 + 端口)。

  • Access-Control-Request-Method

    跨域请求将会使用什么请求方法发起请求。

  • Access-Control-Request-Headers

    跨域请求将会提交哪些 Header 给服务器。

响应 Header

  • Access-Control-Allow-Origin

    允许发起跨域请求的源,例如:https://foo.example,可以使用通配符: * 表示允许所有。

  • Access-Control-Allow-Methods

    允许跨域请求使用哪些请求方法,多个用逗号分割。

  • Access-Control-Allow-Headers

    允许跨域请求提交哪些 Header,多个用逗号分割。

  • Access-Control-Expose-Headers

    允许在跨域请求的响应中访问的额外的 Header 名称,多个的话,使用逗号分割。

    默认情况下,在跨域请求的响应中,Javascript 的的只能访问 Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma 这个几个基本的响应头。如果你要访问其他的 Header,需要通过此设置。例如:Access-Control-Expose-Headers: X-Ahth-Token

  • Access-Control-Max-Age

    缓存时间,可选,用来指定本次预检请求的有效期,单位为秒。在这个时间内,再次发起跨域请求不会再进行预检。

  • Access-Control-Allow-Credentials

    是否允许客户端携带凭证,boolean 值。如果设置为 true,那么客户端在发起跨域请求的时候会在请求中携带 Cookie 等凭证信息。

用通俗的语言来描述跨域预检的过程:浏览器发现 Javascript 正在发起一个跨域请求的时候,会先往这个URL发起一个预检(OPTIONS)请求,告诉服务器“我打算给你发起一个请求,我所在的地址是 XXX,我要使用 XXX 请求方法,我还要提交 XXX 等 Header”。服务器收到后请求后,响应服务器“我只接受来自于 XXX 的跨域请求,只能用 XXX 请求方法,请求只能携带 XXX Header,请求可以携带凭证,还允许客户端读取响应的 XXX Header,并且30分钟内不必重复发起预检请求”

完成了这一轮协商后,浏览器就可以判断是否要继续发起真正的跨域请求。

一些要注意的点:

  1. 如果服务器要求客户端可以携带凭证 Access-Control-Allow-Credentials: true,那么 Access-Control-Allow-Origin 不能设置为通配符 *,而是必须要设置完整的跨域请求源。
  2. Access-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Expose-Headers 都可以设置通配符 *,但是可能存在浏览器兼容问题,需要注意。

进阶阅读:

Spring 中对的 CORS 支持

WebMvcConfigurer

简单一点,可以通过覆写 WebMvcConfigurer 中的 addCorsMappings(CorsRegistry registry) 方法来配置应用的全局的跨域设置。

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer{
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")                  // 允许跨域请求的path,支持路径通配符,如:/api/**
            .allowedOrigins("*")                    // 允许发起请求的源
            .allowedHeaders("*")                    // 允许客户端的提交的 Header,通配符 * 可能有浏览器兼容问题
            .allowedMethods("GET")                  // 允许客户端使用的请求方法
            .allowCredentials(false)                // 不允许携带凭证
            .exposedHeaders("X-Auth-Token, X-Foo")  // 允许额外访问的 Response Header
            .maxAge(3600)                           // 预检缓存一个小时
            ;
    }
}

@CrossOrigin

WebMvcConfigurer 是全局配置,如果你需要更精准的控制 Api 的跨域策略,可以通过在 handler 方法或者是 controller 类上注解 @CrossOrigin 来设置。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CrossOrigin {

    @AliasFor("origins")
    String[] value() default {};

    @AliasFor("value")
    String[] origins() default {};

    String[] originPatterns() default {};

    String[] allowedHeaders() default {};

    String[] exposedHeaders() default {};

    RequestMethod[] methods() default {};

    String allowCredentials() default "";

    long maxAge() default -1;
}

有了前面的理论支撑,你也不难看出这些注解属性所代表的含义,它可以注解在 Controller 类上,对当前类中的所有 hanlder 方法都生效,也可以注解在 handler 方法上,仅对此方法生效。如下:

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/foo")
@RestController
@CrossOrigin(origins = "*",             // 允许的源,也就是 allowedOrigins
            allowCredentials = "false",     // 不允许提交cookie
        allowedHeaders = "*",           // 允许的请求头
        exposedHeaders = "*",           // 允许客户端额外读取的响应头
        maxAge = 3600,                  // 缓存时间
        methods = {RequestMethod.GET, RequestMethod.HEAD} // 允许客户端的请求方法
)
public class FooController {
    // 省略 hanlder 方法定义
}

不管是 WebMvcConfigurer 配置还是 @CrossOrigin 注解,都是用硬编码方式配置跨域,这相当的不够灵活。

CorsFilter

Spring 提供了一个 CorsFilterFilter 实现类,可以让我们更加灵活地,以编程式的方式来配置跨域。这也是我较为推荐的一种方式。

import java.time.Duration;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer{

    // 通过 FilterRegistrationBean 注册 CorsFilter
    @Bean
    public FilterRegistrationBean<CorsFilter> corsFilter() {
        
        // 跨域 Filter
        CorsFilter corsFilter = new CorsFilter(request -> {
            
            // 请求源
            String origin = request.getHeader(HttpHeaders.ORIGIN);
            
            if (!StringUtils.hasText(origin)) {
                return null; // 非跨域请求
            }
            
            // 针对每个请求,编程式设置跨域
            CorsConfiguration config = new CorsConfiguration();
            
            // 允许发起跨域请求的源,直接取 Origin header 值,不论源是哪儿,服务器都接受
            config.addAllowedOrigin(origin);
            
            
            // 允许客户端的请求的所有 Header
            String headers = request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
            if (StringUtils.hasText(headers)) {
                config.setAllowedHeaders(Stream.of(headers.split(",")).map(String::trim).distinct().toList());
            }
            
            // 允许客户端的所有请求方法
            config.addAllowedMethod(request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD));
            // 允许读取所有 Header
            // 注意,"*" 通配符,可能在其他低版本浏览中不兼容。
            config.addExposedHeader("*");
            // 缓存30分钟
            config.setMaxAge(Duration.ofMinutes(30));
            // 允许携带凭证
            config.setAllowCredentials(true);
            return config;
        });
        
        FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(corsFilter);
        bean.addUrlPatterns("/*");                  // Filter 拦截路径
        bean.setOrder(Ordered.LOWEST_PRECEDENCE);   // 保证最先执行
        return bean;
    }
}

上面的这个 CorsFilter 是一个万能 Filter,基本上可以解决 spring 应用中 999.999% 的跨域问题。

注意:不论任何方式,如果将 allowCredentials 设置为了 true,则 allowedOrigins 必须设置明确的源,不能使用通配符: *,否则运行时会抛出异常:

java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

另外,上述三种方式,任选其一就行。不要重复定义!

测试

创建演示端点

创建一个简单的 spring boot(3.x)项目,在 8080 端口的 /hello 端点提供一个 HTTP 服务,固定响应字符串:Hello World!

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/hello")
@RestController
public class HelloController {

    @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)
    public String hello () {
        return "hello World!";
    }
}

跨域请求

打开浏览器,访问 http://localhost:8080/,看到的是一个 404 错误页面,这是因为没有设置默认主页,这没关系。

我们可以在控制台用 Javascript 代码发起跨域请求,把服务器的响应输出到控制台。

fetch('http://127.0.0.1:8080/hello').then(resp => resp.text().then(msg => console.log(msg))); // 注意,访问的URL的主机名是 127.0.0.1 和当前页面URL的主机名 localhost 不同,所以这是一个跨域请求。

Spring 应用中的 CORS 请求

如你所见,成功读取到了服务器的响应,跨域配置生效。

查看 OPTIONS 预检请求

以前可以直接在浏览器控制台看到 OPTIONS 请求,不知道从什么时候起,Chrome 不再在控制台展示 CORS 相关的 OPTIONS 预检请求

那就用 Postman 工具模拟一个 OPTIONS 请求吧,下面就是预检的请求/响应日志。

OPTIONS /hello HTTP/1.1
Origin: http://localhost:8080/
Access-Control-Request-Headers: Foo
Access-Control-Request-Method: GET
User-Agent: PostmanRuntime/7.29.2
Accept: */*
Postman-Token: c5db1682-d313-4b78-9e36-a166cd9aea42
Host: 127.0.0.1:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
 
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Foo
Access-Control-Expose-Headers: *
Date: Tue, 29 Aug 2023 06:31:49 GMT
Connection: keep-alive
Access-Control-Allow-Origin: http://localhost:8080/
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Credentials: true
Content-Length: 0
Access-Control-Allow-Methods: GET
Access-Control-Max-Age: 1800