背景
前段时间帮同事分析了一个跨域问题,正好系统分析和整理一下。
1.跨域
理解同源策略是理解跨域的前提。同源策略定义如下: 在同一来源的页面和脚本之间进行数据交互时,浏览器会默认允许操作,而不会造成跨站脚本攻击;不同源之间进行限制。
不同源之间形成跨域,包括:协议、域名、端口。http和https,localhost和127.0.0.1也会形成跨域(即使经过域名解析后相同)。
由于浏览器引擎实现了同源策略,即对跨域访问进行了限制,因此存在跨域问题。
注意:注意区分浏览器引擎和V8引擎的区别,浏览器引擎包括解析HTML/JS/CSS和渲染等功能,而V8只是一个JS解析器;因此:浏览器中存在跨域问题,而基于Chrome-V8的Nodejs中不存在跨域问题。
案例说明:
2.浏览器处理跨域步骤
根据请求类型不同,浏览器有不同的处理策略,可以分为简单请求和复杂请求:
2.1 简单请求
满足以下条件的为简单请求:
(1) 方法取值范围:GET,POST,HEAD;
(2) Context-Type取值范围: text/plain
, application/x-www-form-urlencoded
, multipart/form-data
(3) 不包含自定义头域; 即只能包含HTTP自带的Accept, Accept-Language, Content-Type …
详见: CORS
对于简单请求,浏览器会直接向服务器发送请求。
2.2 复杂请求
简单请求之外的HTTP请求为复杂请求。此时,浏览器会正式请求之前会先发送一个OPTIONS类型的预检请求。
2.3 跨域请求头域
如果ajax是跨域请求,浏览器收到HTTP请求响应后对响应头进行分析——是否支持跨域:支持-请求正常,否则-抛出异常。
响应头包含以下几个部分:
(1) Access-Control-Allow-Origin
指定哪些域可以访问请求的资源, 多个用逗号分开; 取值为"file://"时,表示只允许来自本地文件系统的跨域请求, 而* 表示允许所有源访问。
(2) Access-Control-Allow-Credentials
取值范围有true和false; 表示是否允许客户端使用认证信息(如cookies、HTTP身份验证等)进行跨域请求。即取值为true时,客户端可以携带认证信息,如cookies,以进行身份验证和个性化等操作。
(3) Access-Control-Allow-Methods
取值范围为HTTP的方法类型,如GET和POST;指定允许的HTTP请求方法,多个使用逗号分隔。
(4) Access-Control-Allow-Headers
这个头域用于指定允许客户端访问的响应头, 多个值用逗号分隔;
例如,Access-Control-Expose-Headers: X-Custom-Header, Content-Type表示允许客户端访问X-Custom-Header和Content-Type响应头。
上述4个属性是浏览器判断是否跨域的依据。
注意:当指定多个Access-Control-Allow-Origin时,浏览器会报错如下:
Access to XMLHttpRequest at 'http://localhost:8181/a/b/c' from origin 'http://localhost:8182' has been blocked by CORS policy:
The 'Access-Control-Allow-Origin' header contains multiple values '*, *',
but only one is allowed.
2.4 跨域解决方式
2.4.1 前端解决跨域
前端可通过使用JSONP和代理服务器方式解决。如 vue项目中使用Axios实现跨域的原理是代理服务器,在vue项目中,通常会使用webpack-dev-server作为开发服务器,它内置了HTTP代理功能。当Axios发出跨域请求时,它会将请求发送到webpack-dev-server的代理服务器上,代理服务器将请求转发到目标服务器。在转发过程中,代理服务器会处理跨域请求,从而绕过浏览器的同源策略限制。
2.4.2 后端-服务器解决跨域
服务端处理跨域问题的核心是在HTTP响应中加入指定的响应头,使得浏览器正常校验跨域。
可通过过滤器(Filter)和SpringMVC的拦截器()来实现。
案例1-使用过滤器Filter:
Filter可以自定义,也可使用开源解决方案:
<dependency>
<groupId>com.thetransactioncompany</groupId>
<artifactId>cors-filter</artifactId>
<version>2.9</version>
</dependency>
配置并注册到web容器中:
<filter>
<filter-name>CORS</filter-name>
<filter-class>com.thetransactioncompany.cors.CORSFilter</filter-class>
<init-param>
<param-name>cors.allowOrigin</param-name>
<param-value>*</param-value>
</init-param>
<init-param>
<param-name>cors.supportedMethods</param-name>
<param-value>GET, POST, HEAD, PUT, DELETE</param-value>
</init-param>
<init-param>
<param-name>cors.supportedHeaders</param-name>
<param-value>Accept, Origin, X-Requested-With, Content-Type, Last-Modified</param-value>
</init-param>
<init-param>
<param-name>cors.exposedHeaders</param-name>
<param-value>Set-Cookie</param-value>
</init-param>
<init-param>
<param-name>cors.supportsCredentials</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CORS</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
案例2-使用拦截器:
// 定义跨域拦截器
public class CrossInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
response.addHeader("Access-Control-Allow-Origin", "*");
response.addHeader("Access-Control-Allow-Methods", "*");
response.addHeader("Access-Control-Max-Age", "100");
response.addHeader("Access-Control-Allow-Headers", "Content-Type");
response.addHeader("Access-Control-Allow-Credentials", "false");
return true;
}
}
// 注册拦截器
@Configuration
public class MyInterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CrossInterceptor());
}
}
相对于过滤器和拦截器,SpringMVC提供了颗粒度更小的解决方案,使用@CrossOrigin
注解即可解决跨域问题。
如下所示:
@RestController
@RequestMapping("/api/crossDemo")
public class CrossController {
@CrossOrigin(origins = "*", maxAge = 3600)
@RequestMapping(value = "/put", method = RequestMethod.PUT)
public String put() {
return "success";
}
}
@CrossOrigin
可注解在方法上对方法生效,也可作用在类上对类中所有方法生效;当类和方法都存在@CrossOrigin
注解时,方法上的注解会覆盖类上的注解。
3.@CrossOrigin原理介绍
@CrossOrigin
注解本质上是对SpringMVC的调用链添加一个拦截器,在拦截器中对HTTP的响应头进行跨域设置。
3.1 @CrossOrigin注解
@CrossOrigin
注解包含以下属性:
[1] value和origins属性:
String[]类型; 指定允许请求源列表; 一般设置为*,表示对所有的网址开放。与Access-Control-Allow-Origin头域保持一致。
[2] allowedHeaders属性:
String[]类型;请求中允许的请求头列表。如果设置成“*”,则表示允许所有的请求头。
[3] exposedHeaders属性:
String[]类型;@CrossOrigin
注解的exposedHeaders属性用于指定允许暴露的响应头列表。这个属性主要用于控制客户端(如浏览器)可以访问哪些响应头。如果设置成“*”,则表示允许暴露所有的响应头。
例如,假设我们有一个API接口,需要暴露响应头"Content-Length"给客户端,可以这样设置:
@CrossOrigin(origins = "*", allowedHeaders = "*", exposedHeaders = "Content-Length")
在这个例子中,我们允许来自"http://example.com"的请求访问我们的API,并允许请求头"Content-Type"。同时,我们指定了响应头"Content-Length"可以被客户端访问。在实际的CORS请求中,响应头"Content-Length"将被存储在Access-Control-Expose-Headers列表中,客户端可以通过这个头获取"Content-Length"信息。
默认情况下,暴露的响应头有:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果需要暴露其他的响应头,需要在@CrossOrigin注解中显式指定。
[4] methods属性:
RequestMethod[]类型;methods属性用于指定允许的HTTP方法。它是一个字符串数组,表示允许跨域请求的HTTP方法列表。这个属性主要用于控制哪些HTTP请求方法可以被客户端(如浏览器)使用。与Access-Control-Allow-Methods头域保持一致。如果API接口只允许GET和POST方法进行跨域请求,可以按如下方式进行设置:
@CrossOrigin(origins = "*", allowedHeaders = "*", methods = "GET,POST")
[5] allowCredentials属性:
String类型;与Access-Control-Allow-Credentials头域保持一致,表示是否允许携带认证信息(如cookies、HTTP身份验证等)进行跨域请求。
[6] maxAge属性:
long类型; 预检请求的有效期(单位: 秒),有效期内不必再次发送预检请求,默认是-1。
当maxAge值为-1时,表示预检请求没有有效期限制。即浏览器接收到预检响应后,无论经过多长时间,只要浏览器与服务器之间的连接保持打开状态,都不需要再次发送预检请求。
3.2 项目初始化
RequestMappingHandlerMapping类实现了InitializingBean接口,在初始化阶段会调用afterPropertiesSet
钩子方法:
public void afterPropertiesSet() {
initHandlerMethods();
}
initHandlerMethods()
方法核心是调用register
方法进行Controller接口url的注册,该过程会同时设置跨域信息:
public void register(T mapping, Object handler, Method method) {
// register url 和 method关系
CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping);
if (corsConfig != null) {
this.corsLookup.put(handlerMethod, corsConfig);
}
// ...
}
通过initCorsConfiguration
方法获取跨域配置,保存在内存(corsLookup属性)中。
initCorsConfiguration
方法逻辑如下:
@Override
protected CorsConfiguration initCorsConfiguration(Object handler, Method method, RequestMappingInfo mappingInfo) {
HandlerMethod handlerMethod = createHandlerMethod(handler, method);
Class<?> beanType = handlerMethod.getBeanType();
// 从Controller类上获取@CrossOrigin注解
CrossOrigin typeAnnotation = AnnotatedElementUtils.findMergedAnnotation(beanType, CrossOrigin.class);
// 从接口方法上获取@CrossOrigin注解
CrossOrigin methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, CrossOrigin.class);
// 没有注解,表示不进行跨域处理
if (typeAnnotation == null && methodAnnotation == null) {
return null;
}
CorsConfiguration config = new CorsConfiguration();
// 先根据类的注解信息进行构造,再使用方法注解信息覆盖,因此优先级方法高于类
updateCorsConfig(config, typeAnnotation);
updateCorsConfig(config, methodAnnotation);
if (CollectionUtils.isEmpty(config.getAllowedMethods())) {
for (RequestMethod allowedMethod : mappingInfo.getMethodsCondition().getMethods()) {
config.addAllowedMethod(allowedMethod.name());
}
}
// 默认设置
return config.applyPermitDefaultValues();
}
Note: 通过注解未设置时,applyPermitDefaultValues
方法进行默认设置:
allowedOrigins跨域源设置为*, allowedMethods和resolvedMethods设置为GET、HEAD、POST;allowedHeaders设置为*;maxAge设为为1800L, 即30分钟。
3.3 HTTP接口被调用
当请求进入DispatcherServlet的doDispatch
方法中:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
//...
// Determine handler adapter for the current request.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
//...
// 调用拦截器的preHandle方法
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
//...
// 调用目标Controller接口
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
//...
}
getHandlerAdapter(mappedHandler.getHandler())
根据被调用的接口获取HandlerAdapter对象,该对象包含一个调用链,@CrossOrigin注解关联的拦截器添加在该链路中。
构造调用链的逻辑如下所示:
public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
Object handler = getHandlerInternal(request);
// 根据被调用的Controller接口构造执行链
HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);
// 向执行链中添加跨域拦截器
if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) {
CorsConfiguration config = (this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(request) : null);
// 从内存中获取跨域-拦截器对象(上一节中的保存为了这里的获取)
CorsConfiguration handlerConfig = getCorsConfiguration(handler, request);
config = (config != null ? config.combine(handlerConfig) : handlerConfig);
executionChain = getCorsHandlerExecutionChain(request, executionChain, config);
}
return executionChain;
}
至此,@CrossOrigin注解的实现原理已梳理完成。
注意:Spring在不同版本实现有区别(最近定位问题时发现一个因版本升级导致的问题-促使我发现这个问题):
5.2.8.RELEASE中:
protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request, HandlerExecutionChain chain, @Nullable CorsConfiguration config) {
if (CorsUtils.isPreFlightRequest(request)) {
HandlerInterceptor[] interceptors = chain.getInterceptors();
chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
} else {
chain.addInterceptor(0, new CorsInterceptor(config));
}
return chain;
}
4.3.20.RELEASE版本中:
protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request, HandlerExecutionChain chain, CorsConfiguration config) {
if (CorsUtils.isPreFlightRequest(request)) {
HandlerInterceptor[] interceptors = chain.getInterceptors();
chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
} else {
chain.addInterceptor(new CorsInterceptor(config));
}
return chain;
}
区别在于5.2.8.RELEASE版本将跨域拦截器CorsInterceptor放在了拦截器首部,而4.3.20.RELEASE将CorsInterceptor加在了拦截器尾部。
执行顺序不同,业务上可能会引入问题。