一、前言
昨天晚上临近下班之前,公司前端同事突然跟我说,他从前端发送的请求,方法变成了OPTIONS,我看到他的代码里明明也写着的是POST请求,可是为什么会变成了OPTIONS请求,而且返回的http状态码也是200,但是没有任何数据的返回,这就表明请求根本没有进入到方法中就被拦截返回了。这究竟是哪里出现了错误呢?
二、原因探究
经过早上不懈的查找资料,在以下这篇博文中找到了原因:https://www.cnblogs.com/cc299/p/7339583.html。
我简要的说明下原因:
跨域(CORS)问题出现的原因主要是:前端发出Ajax请求的页面与后端处理Ajax请求的服务器的协议,域名,端口之间存在任意一组不同,便会出现跨域问题。具体参见下表(来源):
再说回我遇到这个问题的情况,由于我们的项目是前后端分离的项目,前端页面与后端服务在开发阶段及正式上线时都极有可能不在一台主机上,即会存在跨域的问题。
同时浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
(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
凡是不满足以上两种情况的就是非简单请求。
关于简单请求和非简单请求浏览器的处理手段这里我只提一点:非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。
具体请参照:阮一峰《跨域资源共享 CORS 详解》
以下是我模拟前端发送请求的ajax代码。
$("#test").click(function(){ htmlobj=$.ajax({ method:"POST", headers: { Accept: "application/json; charset=utf-8", authorization:"123456" }, url:"http://localhost:8769/appBuyerSaleScontract/addSaleScontractWithCodeByShopping", data:{"aaa":"bbb"} }); });得到的结果如下图所示:
在ajax代码中我定义发出的请求方法为POST,但是在这里却变成了OPTIONS,这是因为我们发送的是一个非简单请求,所以浏览器在发送真正的ajax请求之前发送了一次“预检”请求,而预检请求的请求方法就是OPTIONS。这次预检请求得到的结果是403,也就是后端不接受这个请求。而返回的结果也显示了这是一次非法的跨域请求。
三、解决方案
既然知道了原因,那么我们就可以开始着手解决问题了。既然返回值是403就表示确实进入了我们的程序,只是被spring拦截并拒绝了,所以没有进入业务代码。
我们知道springMVC根据请求方式的不同,用不同的handler来处理请求,而我们发送的OPTIONS请求则会自动被corsProcessor对象(类型为:DefaultCorsProcessor)的processRequest方法来处理。
注:下图代码块中的返回值,若是true,则表示该请求被允许访问,若为“预检”请求,则直接返回200结果,交由浏览器判断是否能够被执行(具体是判断返回的响应头中的Access-Control-Allow-XXX是否包含请求头中对应的Access-Control-Request-XXX的值)若是false,则表示该请求被拒绝。
接着,我们进入到processRequest方法中看看。
以上标出的四点是这个方法的关键也是我们的“预检”请求能不能成功执行的关键。
1.
if (!CorsUtils.isCorsRequest(request)) { return true; }
这一步的目的是判断这个请求是不是一个跨域请求,其中的isCorsRequest(request)方法的逻辑也非常简单。
public static boolean isCorsRequest(HttpServletRequest request) { return request.getHeader("Origin") != null; }
只是判断是否存在请求头Origin即可。
正常的跨域请求是能够返回true的。再经过!一转换变成false。
如果进入了 执行了return true;则表明这不是一个跨域请求,spring会直接返回200的http状态码。以下三步中执行return true.时也是同样的结果。
接下来执行的逻辑便是else中的逻辑了。
2.
if (this.responseHasCors(serverResponse)) { logger.debug("Skip CORS processing: response already contains \"Access-Control-Allow-Origin\" header"); return true; }
这一步的目的是判断response中是否已经包含了"Access-Control-Allow-Origin"这个响应头信息。如果已经包含了该头信息则直接返回true;并执行return true;
其执行代码如下:
private boolean responseHasCors(ServerHttpResponse response) { try { return response.getHeaders().getAccessControlAllowOrigin() != null; } catch (NullPointerException var3) { return false; } }
3.
if (WebUtils.isSameOrigin(serverRequest)) { logger.debug("Skip CORS processing: request is from same origin"); return true; }
这一步的主要目的是判断发送请求的套接字(IP+端口)地址与接收请求的套接字(IP+端口)地址是否相同(此处不对比协议)。
如果相同依旧返回true。执行 return true;
否则返回false。执行第4步。
主要逻辑如下:
public static boolean isSameOrigin(HttpRequest request) { String origin = request.getHeaders().getOrigin(); if (origin == null) { return true; } else { UriComponentsBuilder urlBuilder; if (request instanceof ServletServerHttpRequest) { HttpServletRequest servletRequest = ((ServletServerHttpRequest)request).getServletRequest(); urlBuilder = (new UriComponentsBuilder()).scheme(servletRequest.getScheme()).host(servletRequest.getServerName()).port(servletRequest.getServerPort()).adaptFromForwardedHeaders(request.getHeaders()); } else { urlBuilder = UriComponentsBuilder.fromHttpRequest(request); } UriComponents actualUrl = urlBuilder.build(); UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build(); return actualUrl.getHost().equals(originUrl.getHost()) && getPort(actualUrl) == getPort(originUrl); } }
4.
第四步,也是最关键的一步来了。以上三步分别判断是否存在请求头Origin,响应中是否存在"Access-Control-Allow-Origin"这个响应头信息,请求的套接字地址与请求源的套接字地址是否相同。只要为做过拦截和加工,基本都能够进入到第4步中。第4步中的主要逻辑就是判断是否存在config(类型为:CorsConfiguration)
if (config == null)
我们就来看看CorsConfiguration这个类到底有什么东西。
public class CorsConfiguration { public static final String ALL = "*"; private static final List<HttpMethod> DEFAULT_METHODS; private List<String> allowedOrigins; private List<String> allowedMethods; private List<HttpMethod> resolvedMethods; private List<String> allowedHeaders; private List<String> exposedHeaders; private Boolean allowCredentials; private Long maxAge;
以上是这个类中的所有字段,我们主要关心的字段有如下几个:
// CorsConfiguration public static final String ALL = "*"; // 允许的请求源 private List<String> allowedOrigins; // 允许的http方法 private List<String> allowedMethods; ——-->我们可以加入的http方法,我们注册时加入的允许方法。 // 允许的http方法 private List<HttpMethod> resolvedMethods; ----->我们不能加入的http方法,后面比对的时候只能比对这个。 // 允许的请求头 private List<String> allowedHeaders; // 返回的响应头 private List<String> exposedHeaders; // 是否允许携带cookies private Boolean allowCredentials; // 预请求的存活有效期 private Long maxAge;
说了这么久终于说道重点上了。
在Spring MVC4 中为我们提供了这么一个配置类来完成跨域请求。
在springboot中只需要加上下面这个类就可以完成允许跨域请求。其中allowedHeaders(String ... headers)这个方法中设置的允许带上的请求头可以各位看官老爷们自己定义。
@Configuration public class CORSConfiguration extends WebMvcConfigurerAdapter { @Override public void addCorsMappings(CorsRegistry registry) { registry .addMapping("/**") .allowedMethods("*") .allowedOrigins("*") .allowedHeaders("authorization","Accept"); } }
这里有一点要提出。allowedMethods和resolvedMethods其实是一样的。
当我们调用 allowedMethods(String ... method)时其实调用的是 CorsRegistration.setAllowedMethods(List<String> allowedMethods)方法下面是这个方法的源码:
public void setAllowedMethods(List<String> allowedMethods) { this.allowedMethods = allowedMethods != null ? new ArrayList(allowedMethods) : null; if (!CollectionUtils.isEmpty(allowedMethods)) { this.resolvedMethods = new ArrayList(allowedMethods.size()); Iterator var2 = allowedMethods.iterator(); while(var2.hasNext()) { String method = (String)var2.next(); if ("*".equals(method)) { this.resolvedMethods = null; break; } this.resolvedMethods.add(HttpMethod.resolve(method));//将我们输入的"GET","POST"等转化为HttpMethod对象并添加到resolvedMethods中 } } else { this.resolvedMethods = DEFAULT_METHODS; } }
这其中allowedMethods和resolvedMethods的转化逻辑我已经注释出来了。
接下来我们继续看执行逻辑,即是else代码块中的
return this.handleInternal(serverRequest, serverResponse, config, preFlightRequest);handleInternal方法中执行着主要的,判断跨域请求是否被允许的逻辑。
以下是方法的主要逻辑,我会重点讲解。
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response, CorsConfiguration config, boolean preFlightRequest) throws IOException { String requestOrigin = request.getHeaders().getOrigin(); //获取请求源地址 String allowOrigin = this.checkOrigin(config, requestOrigin); //检查是否有与requestOrigin一致的,我们设置的请求源,如果没有则返回null; //此处还有这样一段逻辑:如果你设置的allowOrigin为*,并且你设置的allowCredentials(是否允许带cookies)为false,则返回的allowOrigin会是* // 如果你设置的allowOrigin为*,并且你设置的allowCredentials(是否允许带cookies)为true,则返回的allowOrigin会是requestOrigin // 如果你设置的allowOrigin为真正的url,则会遍历整个List判断每个元素忽略大小写时是否相等。如相等则返回requestOrigin,否则返回null // 如果你没有设置allowOrigin或者requestOrigin为空则直接返回null; HttpMethod requestMethod = this.getMethodToUse(request, preFlightRequest);//获取真正ajax发送时的请求方法 List<HttpMethod> allowMethods = this.checkMethods(config, requestMethod); //检查是否有允许的请求方法,如果resolveMethod为null,则返回requestMethod,否则进行对比,如果没有,则返回null List<String> requestHeaders = this.getHeadersToUse(request, preFlightRequest);//获取真正ajax发送时的需要携带的请求头 List<String> allowHeaders = this.checkHeaders(config, requestHeaders); //检查是否有允许的请求头信息 //此处还有这样一段逻辑:如果你设置的allowHeaders为*,则直接返回requestHeaders // 否则逐个比对,知道全部匹配为止 if (allowOrigin == null || allowMethods == null || preFlightRequest && allowHeaders == null) { //preFlightRequest:只要符合请求头包含Origin,请求方法为OPTIONS和请求头包含"Access-Control-Request-Method"即为true,我们发送的"预检"请求都包含这三样 this.rejectRequest(response); return false; } else { HttpHeaders responseHeaders = response.getHeaders(); responseHeaders.setAccessControlAllowOrigin(allowOrigin); responseHeaders.add("Vary", "Origin"); if (preFlightRequest) { responseHeaders.setAccessControlAllowMethods(allowMethods); //设置响应头的Access-Control-Allow-Methods } if (preFlightRequest && !allowHeaders.isEmpty()) { responseHeaders.setAccessControlAllowHeaders(allowHeaders);//设置响应头的Access-Control-Allow-Headers } if (!CollectionUtils.isEmpty(config.getExposedHeaders())) { responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());//设置响应头的Access-Control-Expose-Headers } if (Boolean.TRUE.equals(config.getAllowCredentials())) { responseHeaders.setAccessControlAllowCredentials(true);//设置响应头的Access-Control-Allow-Credentials } if (preFlightRequest && config.getMaxAge() != null) { responseHeaders.setAccessControlMaxAge(config.getMaxAge());//设置响应头的maxAge } response.flush(); return true; } }
protected void rejectRequest(ServerHttpResponse response) throws IOException { response.setStatusCode(HttpStatus.FORBIDDEN); response.getBody().write("Invalid CORS request".getBytes(UTF8_CHARSET)); }
其中rejectRequest方法执行的逻辑就是为什么我们会得到403 forbidden!的结果。
三、疑问与测试
1.filter实现跨域问题的解决
我看了好多网上说的方法,其中有一种方法也非常的热门,就是用filter拦截
于是我写了下面这个类
@Component public class CORSFilter implements Filter{ @Override public void init(FilterConfig filterConfig) throws ServletException { System.out.println("Init"); } @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain filterChain) throws IOException, ServletException { HttpServletResponse response = (HttpServletResponse)resp; HttpServletRequest request = (HttpServletRequest) req; System.out.println(request.getHeader("Access-Control-Request-Method")); if( request.getHeader("Access-Control-Request-Method") != null && request.getHeader("Access-Control-Request-Headers") != null ){ response.setHeader("Access-Control-Allow-Origin","http://localhost:8080"); response.setHeader("Access-Control-Allow-Methods",request.getHeader("Access-Control-Request-Method")); response.setHeader("Access-Control-Allow-Headers",request.getHeader("Access-Control-Request-Headers")); response.setHeader("Access-Control-Max-Age","300"); response.setHeader("Access-Control-Allow-Credentials","true"); } filterChain.doFilter(request,response); } @Override public void destroy() { System.out.println("destroy"); } }
下面是访问的结果。
同样的,使用filter也能够成功。同时也能够自定义设置值。但是这种方式不便于后期维护,同时看起来也比较乱,相比之下,我推荐上面的使用CorsRegistry 注册的方式。