跨域和OPTIONS这对欢喜冤家

简介

我相信做过前端开发的同事,包括做小程序或者小游戏的码友们应该都看过类似下面的错误,这个错误是由于JavaScript代码向服务器发送了HTTP请求引起的。

1
2
3
Access to XMLHttpRequest at 'http://www.xxx.com/yyy' from origin 'null' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

如果是第一次遇到,你肯定会觉得很好奇,忍不住会去一探究竟~

还有些同事会遇到另一个神奇的错误,即发送GET或者POST请求之前,居然先给服务器发送了一个 OPTIONS 请求,让人不可思议的是这个 OPTIONS 请求是自动发的,服务器在没有任何设置的条件下直接将这个请求夭折掉,如下返回 403 错误,也可能是其他错误。

1
OPTIONS http://www.xxx.com/yyy 403

引起这些问题的罪魁祸首就是 跨域 ,今天我跟大家一起以实际的例子来看看这个神奇的 跨域 问题。

文中使用的代码都可以在 Github 找到,大家根据需要自行采纳。

同源策略

在说 跨域 之前,我们还是要了解一下 同源策略 是个什么鬼!

在百度百科里面是这样定义 同源策略 的,如下:

1
2
3
同源策略(Same origin policy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。
可以说Web是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。

听起来,这玩意挺高大上的,简单理解 同源策略 就是一种安全策略。它是由 Netscape 提出的一个著名的安全策略,现在所有支持JavaScript的浏览器都会使用这个策略,也是必须遵守的一个策略。

那么 同源策略 中的 同源 是指 域名协议端口 三者必须相同,如果有任何一个不同就会引起跨域。

下表给出了相对 http://a.xx.com/yy/zz.html 同源检测的示例:

URL 结果 原因
http://a.xxx.com/ff/other.html 成功 域名、协议、端口(默认80)一致
http://a.xxx.com/gg/hh/another.html 成功 域名、协议、端口(默认80)一致
https://a.xxx.com/secure.html 失败 不同协议 ( HTTPS和HTTP )
http://a.xxx.com:81/dir/etc.html 失败 不同端口 ( 81和80)
http://a.wpq.com/yy/other.html 失败 不同域名 ( xxx和wpq)
http://123.21.122.12/dir 失败 域名IP不等同于域名
http://xx.xxx.com/dir2/ 失败 主域相同,子域不同

简单来说,HTML代码运行在一个web主机上面(假设域名是 http://a.xx.com/yy/zz.html),而HTML代码中有需要请求服务器某个API接口(http://api.user.com/name)的,那么就会造成跨域问题。

同源策略会影响:

(1) Cookie、LocalStorage 和 IndexDB 无法读取。

(2) DOM 无法获得。

(3) AJAX 请求不能正常发送,有可能还会引起 OPTIONS 请求。

OPTIONS请求

大家所熟知的HTTP请求最多的应该就是GET和POST请求,这两种请求也是软件开发中用的最多的。

GET:向特定的资源发出请求,一般对服务器来说是一个只读的请求,不会对资源进行写操作。

POST:向指定资源提交数据进行处理请求,例如提交表单或者上传文件,数据被包含在请求体(body)中,该请求可能会对服务器资源进行读写操作。

除了这两种请求外,HTTP还有其他种类的请求,如下:

PUT:向指定资源位置上传其最新内容,一般用于资源的整体更新,而下面的PATCH用于资源的部分更新。

DELETE:请求服务器删除所标识的资源。

HEAD:向服务器索要与GET请求相一致的响应,只不过响应体将不会被返回,可以在不必传输整个响应内容的情况下,就可以获取包含在响应消息头中的元信息。

TRACE:回显服务器收到的请求,主要用于测试或诊断。

OPTIONS:返回服务器针对特定资源所支持的HTTP请求方法。也可以利用向Web服务器发送’*’的请求来测试服务器的功能性。该请求不会修改服务器资源,相对比较安全。

CONNECT:是 HTTP/1.1 协议预留的,能够将连接改为管道方式的代理服务器。通常用于 SSL 加密服务器的链接与非加密的HTTP代理服务器的通信。

PATCH:是对 PUT 方法的补充,用来对已知资源进行局部更新。当资源不存在时,PATCH 会创建一个新的资源,而 PUT 只会对已存在的资源进行更新。

其中 GET, POST 和 HEAD 方法是 HTTP1.0 定义的三种请求方法,在 HTTP1.1 又新增了六种请求方法,即 OPTIONS、PUT、PATCH、DELETE、TRACE 和 CONNECT 方法。如果想了解更多 HTTP 历史的朋友,可以阅读我之前的写的一篇文章 HTTP 演进史,嘿哈。

再说一下 OPTIONS 请求,该请求与 HEAD 请求有点类似,一般也是用于客户端查看服务器的性能。

OPTIONS 方法会请求服务器返回该资源所支持的所有HTTP请求方法,该方法会用来代替资源名称,向服务器发送 OPTIONS 请求,可以测试服务器功能是否正常。JavaScript的XMLHttpRequest对象进行CORS跨域资源共享时,就是使用 OPTIONS 方法发送嗅探请求,以判断是否有对指定资源的访问权限。

那么需要满足哪些条件才会触发 OPTINS 请求呢?

实例验证

在没有回答上面的问题之前,我们还是来做个实验吧~

你需要将 Chrome 的审查视图打开,最好把 Disable Cache 也勾选上禁止 Chrome 使用网络缓存,这样才不会影响下面的实验。

下面是 Springboot 关于登录的一个示例代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "signin")
public class MSSigninController {
@RequestMapping(value = "/name", method = RequestMethod.GET)
public MSResponse sigin(@RequestParam(value = "username") String userName, @RequestParam(value = "userpwd") String userPwd) {
MSResponse response = new MSResponse();
MSUser user = null;
if (null == userName || null == userPwd || userName.length() <= 0 || userPwd.length() <= 0) {
MSResponseEnum responseEnum = MSResponseEnum.Login4SiginInvalidInfo;
response.setCode(responseEnum.getCode());
response.setMsg(responseEnum.getMsg());
} else {
user = MSUserUtil.createDefaultUser(userName, userPwd);
MSResponseEnum rspEnum = MSResponseEnum.SUCCESS;
response.setCode(rspEnum.getCode());
response.setMsg(rspEnum.getMsg());
}
response.setResults(user);
return response;
}
}

你大可不必去了解这个代码的具体逻辑,现在你只需要知道他是用来给JavaScript调用的一个登录API即可。

再来一个HTML文件,模拟请求登录的API,请求HTTP使用Ajax,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
function getReq() {
var url = "http://localhost:8080/signin/name?username=jack&userpwd=123";
$.ajax({
url: url,
type: 'GET',
dataType: 'json',
}).done(function (result) {
console.log("success");
console.log(result);
}).fail(function () {
console.log("error");
})
}
</script>

使用Chrome浏览器直接打开这个HTML文件即可,然后启动Java服务,在浏览器中点击按钮进行GET请求。

此时请求会报下面的错误:

1
Access to XMLHttpRequest at 'http://localhost:8080/signin/name?username=jack&userpwd=123' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

确实是造成了跨域请求,导致请求失败。

但是令人遗憾的是并没有看到发出OPTIONS请求,使用Fiddler抓包,可以看到只有GET请求,如图所示:

难道是自己写代码的姿势不对吗?!

其实,在HTML中使用HTTP请求,发生OPTIONS请求是需要几个条件的:

  • 1、必须是跨域请求
  • 2、自定义了请求头
  • 3、请求头中的 content-typeapplication/x-www-form-urlencodedmultipart/form-datatext/plain 之外的格式

满足1和2或者满足1和3就会发生OPTIONS请求,首先我们确定了上面的示例是跨域请求,但是不满足后面的两个条件之一。

我们修改一下HTML代码增加一个 content-type,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
function getReq() {
var url = "http://localhost:8080/signin/name?username=jack&userpwd=123";
$.ajax({
url: url,
type: 'GET',
dataType: 'json',
contentType: 'application/json',
}).done(function (result) {
console.log("success");
console.log(result);
}).fail(function () {
console.log("error");
})
}
</script>

此时在浏览器中(需要使用Chrome的审查视图)可以看到报错信息:

1
2
3
OPTIONS http://localhost:8080/signin/name?username=jack&userpwd=123 403
Access to XMLHttpRequest at 'http://localhost:8080/signin/name?username=jack&userpwd=123' from origin 'null' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

抓包工具中也可以看到发生了OPTIONS请求,如下图:

也可以自定义Header头来进行验证,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
function getReq() {
var url = "http://localhost:8080/signin/name?username=jack&userpwd=123";
$.ajax({
url: url,
type: 'GET',
dataType: 'json',
headers: {
token: "yu7rX98xxxx_iii^ddd",
userId: 123,
openid: 231232
}
}).done(function (result) {
console.log("success");
console.log(result);
}).fail(function () {
console.log("error");
})
}
</script>

验证结果和上面一致,也会发生OPTIONS请求。

再聊OPTIONS

RFC2616-HTTP/1.1 中关于OPTIONS有详细的描述,感兴趣的可以看一下 9.2 OPTIONS 小节。

OPTIONS请求方法的主要用途有两个:

1、获取服务器支持的HTTP请求方法。

2、用来检查服务器的性能,如上面例子中的AJAX进行跨域请求时的预检,需要向另外一个域名的资源发送一个HTTP OPTIONS请求头,用以判断实际发送的请求是否安全。

HTTP的OPTIONS请求,有很多地方也被称之为预请求或者预检请求,换句话说就是试探性的请求不算是正式请求。

为了避免对服务器产生一些副作用,类似上面例子中的网页中的请求就会产生OPTIONS请求,也算是一种对服务器的保护。只有当服务器允许后,浏览器才会发出正式的请求,否则不发送正式请求。

我们可以使用curl模拟OPTIONS请求,例如下面请求谷歌:

1
curl -i -v -X OPTIONS https://www.google.com

可以看到请求的响应情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
< HTTP/2 405
< allow: GET, HEAD
< date: Sat, 31 Aug 2019 02:17:03 GMT
< content-type: text/html; charset=UTF-8
< server: gws
< content-length: 1592
< x-xss-protection: 0
< x-frame-options: SAMEORIGIN
< alt-svc: quic=":443"; ma=2592000; v="46,43,39"
<
{ [5 bytes data]
100 1592 100 1592 0 0 13606 0 --:--:-- --:-- 13606HTTP/2 405
allow: GET, HEAD
date: Sat, 31 Aug 2019 02:17:03 GMT
content-type: text/html; charset=UTF-8
server: gws
content-length: 1592
x-xss-protection: 0
x-frame-options: SAMEORIGIN
alt-svc: quic=":443"; ma=2592000; v="46,43,39"

SpringBoot解决跨域

解决跨越的问题,在网上有很多的路子,目前大概有下面几种解决方案,如下:

  • JSONP
    • 简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。但它仅支持GET方法不支持POST等其他请求方法,而且可能会遭受XSS攻击。
  • cors
  • postMessage
  • websocket
  • Node中间件代理
  • nginx反向代理
  • window.name+iframe
  • location.hash+iframe
  • document.domain+iframe

今天我们使用SpringBoot自带的注解来解决这个问题。

在说解决方案之前,还是先了解一下CORS(Cross-origin resource sharing),其全称是”跨域资源共享”,是W3C的一个标准。

CORS允许浏览器向跨源服务器发出 XMLHttpRequest 请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。幸运的是目前几乎所有的浏览器都支持该功能,唯一美中不足的是IE浏览器的版本不能低于IE10。

实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。SpringBoot自带注解 CrossOrigin 可以用来解决跨域问题。

修改一下Controller的代码,增加 CrossOrigin 注解,示例代码如下:

1
2
3
4
5
@CrossOrigin
@RequestMapping(value = "/name", method = RequestMethod.GET)
public MSResponse sigin(@RequestParam(value = "username") String userName, @RequestParam(value = "userpwd") String userPwd) {
// 省略
}

重新启动服务,抓包工具可以看到OPTIONS和GET请求都正常执行,返回码都是200。

可以针对某个方法添加 CrossOrigin 注解,也可以对整个Controller添加该注解。

关于 CrossOrigin 注解,大家可以自行实践,这里不再赘述。


要一直在学习的路上努力~

坚持原创技术分享!