一、配置方式
在 Spring 框架下解决 CORS 问题,前面试了两种方法,发现在一种场景下,HTTP Response header 始终未应答 Access-Control-Allow-Origin:*
(1)第一种方式,通过在 Controller 层增加 @CrossOrigin 注解。
@CrossOrigin
@RestController
@RequestMapping(“/file”)
public class FileController {
…
}
(2)第二种方式,利用 Spring 的 WebMvcConfigurer 中的 addCorsMappings
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
- 支持跨域资源共享-CORS 配置
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer { @Override
public void addCorsMappings(CorsRegistry registry) {
/**
* addMapping: /** 表示所有路径及子路径下的 HTTP 应答都进行 Access-Control 标头包装
* allowedOrigins: response header 中增加 Access-Control-Allow-Origin: * (表示允许所有 Origin 来源的跨域请求)
* allowedMethods:response header 中增加 Access-Control-Allow-Methods: * (表示允许所有 HTTP Method)
* allowedHeaders:response header 中增加 Access-Control-Allow-Headers: *
* maxAge:response header 中增加 Access-Control-Max-Age: 1800 (表示建议浏览器缓存预检【Options请求】结果 1800s,可以降低服务端处理预检请求的压力)
*
* 配置解释参考:https://cloud.tencent.com/developer/article/1513418
/ registry.addMapping(“/“) .allowedOrigins(““)
.allowedMethods(““) .allowedHeaders(““)
.maxAge(1800);
}
}
二、问题踩坑
这两种方式测下来发现都有一个问题,就是如果 Origin 和 请求的 Url 地址是同源的( HTTP Method + host + port 完全一致则认为同源),则 Spring 框架并不会在 Response Header 中应答 Access-Control-Allow-Origin: * ,“同源访问时Spring不会返回Access-Control-Allow-Origin标头”,这个下的判断源码依据下面再谈,只是通过测试下的判断。Tips:翻看源码发现第一种加 @CrossOrigin 注解的方式跟第二种通过WebMvcConfigurer .addCorsMappings()配置, Spring 内部实现,其实是走的同一套,所以两种方式都会碰到同样的问题。
然后正好我们有个场景,是内部的前端 Ajax 跨域调用到这个服务(假设叫 S1 服务,对应服务地址为 https://hello.com/file )上来,并且域名用的是一样的都是 hello.com(假设其他服务请求地址为:hello.com/server),但是这个域名支持 https和 http两种方式访问。结果发现,用 https在 Origin: https://hello.com/server的 Origin header 下访问https://hello.com/file 是会返回 Access-Control-Allow-Origin: *,但是换成 http 下去请求 https://hello.com/file就出问题了,并没有返回 Access-Control-Allow-Origin: *,导致前端Ajax请求被浏览器因 CORS 问题而挡掉。
三、原因分析
按照刚才的判断: “同源访问时Spring不会返回Access-Control-Allow-Origin标头”,理论上Origin: https://hello.com/server 下请求 https://hello.com/file 是同源,不会出现 Access-Control-Allow-Origin: *,但偏偏刚好相反,origin = http & url = https 这个搭配出不来 Access-Control,origin = https & url = https 这个搭配却出来了 Access-Control。
后来回想了下,也注意到了 Response 内容里的 Server: nginx/1.13.5,突然想起来 nginx 这类外部网关,会把外部进来的 https 请求解密解包,然后以 http 的方式转发给内网服务。
这就解释通了上面的相反现象,origin = http & url = https 这种搭配下,因为 url 中的 https 经过 nginx 处理成 http 了,到了 Spring 层实际是个 http 请求,所以 Spring 判断其实是同源,于是没有应答 Access-Control-Allow-Origin: *;而origin = https & url = https 这种搭配,恰恰因为 nginx 处理成 http,到了 Spring 层判断时发现 origin 的是 https 地址,而 url 是 http,不同源,所以才有Access-Control。
当然,以上坑也只有在内部同域名下Ajax访问才有出现。提供给到外部服务时,一般两边域名就不一样,无论是 https / http 与否,Spring 肯定都会判断是 cross origin,所以都会有Access-Control。
结合Spring CrossOrigin源码解析其跨域判断: (Spring源码版本:spring-web:5.2.15.RELEASE)
判断是否跨域源码:org.springframework.web.cors.CorsUtils.java isCorsRequest()
/**
* Returns {@code true} if the request is a valid CORS one by checking {@code Origin}
* header presence and ensuring that origins are different.
*/
public static boolean isCorsRequest(HttpServletRequest request) {
String origin = request.getHeader(HttpHeaders.ORIGIN);
if (origin == null) {
return false;
}
UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build();
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
return !(ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) &&
ObjectUtils.nullSafeEquals(host, originUrl.getHost()) &&
getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort()));
}
可以发现,Spring @CrossOrigin 判断请求是否跨域,是根据 Header.Origin 域和 request.uri 判断的,Origin 就是前端送在 Header上的 Orgin,标识是从哪个源host过来的;而 request 那就是从Servlet容器(比如Tomcat)取出来的,也就是经过了 Nginx 等网关转发过的 request,所以一旦 Nginx 把 https 协议转成了 http,那 Spring @CrossOrigin 判断请求是否跨域就会出问题。
然后再查一下CorsUtils.isCorsRequest()的调用栈,可以在DefaultCorsProcessor.processRequest()发现一行:
if (!CorsUtils.isCorsRequest(request)) {
return true;
}
DefaultCorsProcessor是 Spring 实现 CORS 的核心处理器,在processRequest()方法最后进行的handleInternal()操作就是执行了往response填充Access-Control-Allow-Origin等header信息,源码如下,所以这里判断不是 CorsRequest 就直接返回了,不会填充Access-Control-Allow-Origin,也就印证了上面我的判断:
DefaultCorsProcessor.handleInternal():
/**
* Handle the given request.
*/
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response,
CorsConfiguration config, boolean preFlightRequest) throws IOException {
String requestOrigin = request.getHeaders().getOrigin();
String allowOrigin = checkOrigin(config, requestOrigin);
HttpHeaders responseHeaders = response.getHeaders();
if (allowOrigin == null) {
logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
rejectRequest(response);
return false;
}
HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
if (allowMethods == null) {
logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
rejectRequest(response);
return false;
}
List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
List<String> allowHeaders = checkHeaders(config, requestHeaders);
if (preFlightRequest && allowHeaders == null) {
logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
rejectRequest(response);
return false;
}
responseHeaders.setAccessControlAllowOrigin(allowOrigin);
if (preFlightRequest) {
responseHeaders.setAccessControlAllowMethods(allowMethods);
}
if (preFlightRequest && !allowHeaders.isEmpty()) {
responseHeaders.setAccessControlAllowHeaders(allowHeaders);
}
if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
}
if (Boolean.TRUE.equals(config.getAllowCredentials())) {
responseHeaders.setAccessControlAllowCredentials(true);
}
if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
response.flush();
return true;
}
四、解决方案
解决方案很简单,就是换一种方式,能够强制给 response header 加上Access-Control-Allow-Origin: *,不管 同源还是 Cross Origin 与否。
可以自己在 Controller 方法上手工给 HttpServletResponse增加 header:
response.setHeader(“Access-Control-Allow-Origin”, “*”);
1
这种办法很傻很原始,而且每个方法都得写一遍,更关键的是,针对 OPTIONS 预检请求(浏览器针对跨域请求在 POST 之前一般会先自动发 OPTIONS 预检请求,详细解释见 CORS跨域资源共享(一):模拟跨域请求以及结果分析,理解同源策略)也得加个方法处理 response,否则OPTIONS请求就会因为跨域而被 Block 掉。
所以最后采用的是针对所有请求都强制给 response 加上该 header,使用了Java web 三大组件之一的 filter-过滤器来实现:
*自定义一个CorsFilter *
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebFilter(filterName = “CorsFilter”)
@Configuration
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader(“Access-Control-Allow-Origin”,”*”);
response.setHeader(“Access-Control-Allow-Credentials”, “true”);
response.setHeader(“Access-Control-Allow-Methods”, “POST, GET, PATCH, DELETE, PUT”);
response.setHeader(“Access-Control-Max-Age”, “3600”);
response.setHeader(“Access-Control-Allow-Headers”, “Origin, X-Requested-With, Content-Type, Accept”);
chain.doFilter(req, res);
}
此外别的方案就是,也可以配置Nginx可以实现不把HTTPS转成HTTP协议,参考文章:
Nginx SSL+tomcat集群,request.getScheme() 取到https正确的协议
参考文献:
CORS跨域资源共享(一):模拟跨域请求以及结果分析,理解同源策略
SpringBoot-实现CORS跨域原理及解决方案