1. 背景
我们的项目作为一个通用型的低代码网关,为很多业务方提供着通过简单托拉拽来动态封装REST接口的能力。以前提供的大多数场景都是直接将封装给后端进行使用,所以不会有跨域问题。而这次场景比较特殊,由于接口过于简单,并且后端资源紧张,前端直接调用了低代码网关提供的动态REST接口,因此出现了跨域问题。
2. 什么是跨域
出于安全考虑,默认情况下浏览器会限制通过脚本向非同源的URL发起请求。
上面定义中有两个关键词 “通过脚本” 和 “非同源”。
通过脚本是指通过javascript来发起请求,因此在页面上的form表单和img的src是不受跨域限制的。因此我们可以很轻松的盗链其他网站好看的图片(当然前提是被盗的网站没有进行防盗链)。
同源只的是协议、域名、端口号要完全相同,因此以上三个条件只要有一个不满足,就是非同源。示例如下:
当前页面url | 被请求页面url | 是否跨域 | 原因 |
---|---|---|---|
http://www.9527.com/a | http://www.9527.com/b | 否 | 同源,协议、域名、端口一致 |
http://www.9527.com/a | https://www.9527.com/b | 是 | 非同源,协议不同 |
http://www.9527.com/a | http://test.9527.com/b | 是 | 非同源,域名不同 |
http://www.9527.com:80/a | htttp://www.9527.com:8080/a | 是 | 非同源,端口不同 |
3.跨域解决办法
- 通过jsonp解决跨域
- 通过document.domain + iframe解决跨域
- location.hash + iframe跨域
- window.name + iframe跨域
- postMessage解决跨域
- 跨域资源共享(CORS)
以上方案都有各自的局限性,相关内容可以参考【参考内容1】中的介绍,这里不再赘述。本文中使用的方案为CORS。
4. CORS方案解决问题
4.1 天真的springboot方案
出现这个问题以后,感觉就是一个很简单的跨域问题,就委托其他人去处理了。在处理的过程中因为我们的项目时springboot的原因,该同学就复制了网上通用的springboot的跨域解决代码来进行解决,网上代码大体如下:
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 CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600)
.allowedHeaders("*");
}
这种方式是基于SpringMVC的Interceptor来实现的,因此在一些情况下会由于Interceptor的加载和执行顺序不同而导致跨域问题解决失败。具体内容请参考【参考内容2】。
由于这种由于这种方式是基于SpringMVC来配置的,那么只能处理所有经过DispatcherServlet进行处理的请求(也就是springMVC自身的Controller)。但是是我们的服务比较特殊,虽然项目本身是用springboot写的,而且项目本身提供的各种REST接口也是基于springMVC的controller对外提供的,但是我们低代码部动态生成的REST接口却是基于动态Servlet来提供的。
因此,不管配置的对不对,对于我们的场景来说问题就是白配!!!首战失利!!!
4.2手动给response中添加CORS响应头
由于我们项目的核心是“低代码的动态服务”,所有对外提供的REST、WEBSERVICE等等能力都由用户在画布上进行流程编排后动态进行发布的。所以我们可以很容易滴对response相应头进行控制,即很容易想相应头中插入必要的信息。最终插入了如下的信息。
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials","true");
这段代码设置了几乎所有CORS的响应头,看似可以完美解决相关问题。但是现实又狠狠地打了脸。
在进行访问的时候,通过浏览器进行测试,在浏览器的控制台中依然报跨域问题,而查看浏览器的network相关的请求,发现没有得到任何返回结果。
但是从浏览器控制台中的报错信息,敏感地捕捉到了 preflight request,也就是浏览器发起的是预请求。
没了解过什么是预请求,于是进行了抓包,发现浏览器发出的请求如下(已经翻译成了CURL命令)
curl -H "Host: baize-pre.crxn.cn" -H "Accept: */*" -H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,sign,timestamp,x-requested-with" -H "Origin: http://b2coper-uat.crxn.cn" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" \
-H "Sec-Fetch-Mode: cors" -H "Referer: http://b2coper-uat.crxn.cn/" \
-H "Accept-Language: zh-CN,zh;q=0.9" -X OPTIONS --compressed "http://xxxxxxxx"
从该请求中可以发现,浏览器并没有真正的进行我们的POST请求,而是发起了一个OPTIONS请求,也就是所谓的预请求。于是果断研究了预请求,详情可以参考【参考内容4】,这里对结果进行简单的阐述。
CORS请求分为 简单请求 和 非简单请求,浏览器在发起非简单请求时,会先发出预请求来进行跨域验证。
-
简单请求:同时满足以下2个条件的,即为简单请求。
(1) 请求方法为HEAD/GET/POST。
(2) HTTP的头信息不超出以下几种字段:Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain)。
对于简单请求,浏览器自动在头信息之中添加一个Origin字段,用于说明本次请求来自哪个源(协议+域名+端口)。服务器根据Origin字段,决定是否同意这次请求。服务器响应消息中包含“Access-Control-Allow-Origin”时,表示同意请求。
- 非简单请求:不满足以上2个条件的,都为非简单请求。
对于非简单请求,在正式通信之前,浏览器会增加一次HTTP查询请求,称为预检请求。浏览器查询服务器,当前页面所在的源是否在服务器的许可名单之中,以及可以使用哪些HTTP请求方法和头信息字段。预检通过后,浏览器向服务器发送简单请求。
我们的客户端和服务端交互的时候使用的json格式,因此必然不是简单请求,需要对OPTIONS请求进行携带跨域头信息的200返回。
4.3使用Filter来解决问题
为了统一收口,决定使用Servlet Filter来解决跨域问题。Filter的代码如下:
@WebFilter(filterName = "corsFilter", urlPatterns = "/*")
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) servletResponse;
HttpServletRequest request = (HttpServletRequest) servletRequest;
if (request.getMethod().equals("OPTIONS")) {
fillCorsHeaders(servletRequest, servletResponse);
response.setStatus(200);
return;
}
filterChain.doFilter(servletRequest, servletResponse);
fillCorsHeaders(servletRequest, servletResponse);
}
private void fillCorsHeaders(ServletRequest servletRequest, ServletResponse servletResponse) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials","true");
}
}
在该拦截器中对OPTIONS请求进行了响应,会返回带有跨域头的200响应。同时也为所有的请求在执行后添加跨域响应头。
其实参考springMVC的实现,也是如此,只不过用的是Interceptor而已,代码如下。
//作为RequestMappingHandlerMapping的父类,掌控着cors的请求和响应
public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport implements HandlerMapping, Ordered {
//所有的corsRegistry最终都会进入这个属性中
private final UrlBasedCorsConfigurationSource globalCorsConfigSource = new UrlBasedCorsConfigurationSource();
//实际拦截请求进行cors处理的地方,
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(new CorsInterceptor(config));
}
return chain;
}
想要这个Filter生效,只需要在springboot的application上加上@ServletComponentScan注解即可:
@SpringBootApplication
@ServletComponentScan
public class ApplicationForWeb extends SpringBootServletInitializer {
/**
* SpringBoot 集成容器启动方式
*/
public static void main(String[] args) {
SpringApplication.run(ApplicationForWeb.class);
}
/**
* 外部容器启动方式 (Tomcat, JBoss 等)
*/
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(ApplicationForWeb.class);
}
}
4.4 来自Access-Control-Allow-Credentials的教训
本以为自定义了Filter以后就会一切顺利,但是在测试到时候又收到了来自Access-Control-Allow-Credentials的教训。
Access-Control-Allow-Credentials的作用是允许跨域传递cookies信息,但是将Access-Control-Allow-Credentials设置为true以后需要注意一下两点。
- (1)CORS响应头中的Access-Control-Allow-Origin不能为*号,也就是不能再指定允许任意来源的跨域请求,必须明确来源,而我们的需求是任意来源,因此做出来如下修改。直接使用请求头中的Origin信息来作为允许跨域的响应头即可。
- (2)CORS响应头中的Access-Control-Allow-Headers不能为*号,也就是不能指定允许任意的请求头,必须明确允许的请求头。
最终修改后的代码如下:
private void fillCorsHeaders(ServletRequest servletRequest, ServletResponse servletResponse) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "User-Agent,X-Requested-With,X-Stage,X-Sdk-Date");
response.setHeader("Access-Control-Allow-Credentials","true");
}
4.5 临门一脚,来自版本的教训
经过以上的修改,在本地已经可以可以成功地进行跨域请求了。但是部署到测试环境以后却一直报Filter初始化失败,导致服务无法启动。
本地和线上的区别在于,本地是基于springboot直接启动的,而线上则是基于外部tomcat来启动的。而springboot内嵌的tomcat的版本是9以上的版本,版本较高,而外部的tomcat是8的版本,版本较低。
tomcat8是用1.7的JDK编写和编译的,而在1.7的版本中在interface中没有default方法,而我本地环境的Filter接口是这样的。
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {}
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
从代码中可以看到,init 和 destroy方法都用了default,也就导致我自定义的Filter只需要实现doFilter方法即可。这在本地是本有问题的,但是到了线上的tomcat8环境,我的Filter就没有覆写init和destroy方法,所以导致服务无法启动。
最终在自定义的Filter中覆写了以上方法,问题完美解决。
5. 总结
- CORS请求有简单请求和非简单请求,非简单请求中OPTIONS预请求操作,需要进行处理。
- CORS在允许cookies跨域后,允许的Origin和Header是不能为*的。
参考内容
1.什么是跨域
2. SpringMVC的Interceptor和Cors冲突【优先级配置】
3. SpringBoot-实现CORS跨域原理及解决方案
4. 配置跨域访问API