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-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
这个几个基本的响应头。如果你要访问其他的 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分钟内不必重复发起预检请求”
完成了这一轮协商后,浏览器就可以判断是否要继续发起真正的跨域请求。
一些要注意的点:
- 如果服务器要求客户端可以携带凭证
Access-Control-Allow-Credentials: true
,那么Access-Control-Allow-Origin
不能设置为通配符*
,而是必须要设置完整的跨域请求源。 Access-Control-Allow-Methods
、Access-Control-Allow-Headers
、Access-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 提供了一个 CorsFilter
的 Filter
实现类,可以让我们更加灵活地,以编程式的方式来配置跨域。这也是我较为推荐的一种方式。
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 不同,所以这是一个跨域请求。
如你所见,成功读取到了服务器的响应,跨域配置生效。
查看 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