1 CORS的详解
1.1 CORS的基本套路
上一篇文章,我详细讲解了JSONP
的实现和原理,但是毕竟它是一个很古老的东西,就是所谓的落伍了,现在浏览器已经有更好的套路来支持跨域请求了。俗话说得好,上帝为你关上一扇门的时候,必然会为你开启一扇窗
,我们日常使用的浏览器也是这个套路,浏览器开启的窗户就是CORS
,这个东西呢,可以说是前端独有的,我在做iOS的时候,是没有听说过这个东西的。也就是说这是一个专门针对浏览器的标准并且现在的浏览器都实现了这个标准。CORS
就是cross-origin sharing standard
,翻译成中文就是跨域资源共享标准
。
跨域资源共享标准新增了一组HTTP首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的HTTP请求方法(特别是GET以外的HTTP请求,或者搭配某些MIME类型的POST请求),浏览器必须首先使用OPTIONS方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的HTTP请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括Cookies和HTTP认证相关数据)。下面将会针对每种情况做处理。
后面我会通过http://localhost:8081(客户端地址)
和http://127.0.0.1:5389(服务端地址)
来模拟演示整个CORS请求。客户端我是通过Vue框架,并且自己实现XMLHttpRequest
来实现。服务端我是通过Node的Express框架来实现
。具体代码地址,请看文章最后面。
1.1.1 简单的请求访问控制
某些请求不会触发CORS预检请求(具体后面会说,其实就是不会发起options请求)。本文称这样的请求为“简单请求”,请注意,该术语并不属于Fetch(其中定义了CORS)规范。若请求满足所有下述条件,则该请求可视为“简单请求”:
- 使用下列方法之一:
- Fetch规范定义了对CORS安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:
- Accept
- Accept-Language
- Content-Language
- Content-Type (需要注意额外的限制)
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
- Content-Type的值仅限于下列三者之一:
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
如果我们不做任何配置,那么由于跨域原因,浏览器将会对我们请求做报错处理(注意:并不是服务器请求错误,是请求成功了并且返回了,但是浏览器做了报错处理
)。具体请求抓包如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| GET /?xx=1&yy=2 HTTP/1.1 Host: 127.0.0.1:5389 Pragma: no-cache Cache-Control: no-cache Origin: http: User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1 Accept: *
|
从上面的报文来看,我们的请求没有任何问题。但是浏览器却给我报了一个错误,就是因为跨域的原因。
出现这个的原因是我的服务器没有对跨域支持。我只需要在服务器添加对简单跨域的支持就可以了。
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 27 28 29 30 31 32 33 34
| app.all('*', function (req, res, next) { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , X-PINGOTHER'); if (req.method == 'OPTIONS') { res.send(200); } else { next(); } });
app.get('/', (req, res) => { res.json({ name: "隔壁老黄", password: "123456", "requestParams":JSON.stringify(req.query) }); });
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: * Access-Control-Allow-Headers: Content-Type, Content-Length, Authorization, Accept, X-Requested-With , X-PINGOTHER Content-Type: application/json; charset=utf-8 Content-Length: 86 ETag: W/"56-Zk2w81kjYiGkrYmIocyhxiyDmXY" Date: Wed, 07 Feb 2018 11:42:30 GMT Proxy-Connection: Keep-alive
{"name":"隔壁老黄","password":"123456","requestParams":"{\"xx\":\"1\",\"yy\":\"2\"}"}
|
从上面的返回报文我们可以发现。返回的响应头多了Access-Control-Allow-Origin
和Access-Control-Allow-Headers
其中。他们的意义如下:
Access-Control-Allow-Origin
根据请求头中的Origin
和Access-Control-Allow-Origin
就能完成最简单的访问控制。本例中,服务端返回的 Access-Control-Allow-Origin: * 表明,该资源可以被任意外域访问。如果服务端仅允许来自 http://foo.example 的访问,则Access-Control-Allow-Origin: http://foo.example
。
Access-Control-Allow-Headers
表示允许用户自己指定的请求头。其中X-PINGOTHER
是我添加的自定义请求头。如果网络请求有这个请求域,但是服务端没有对这个请求域包含,同样也违反CORS导致失败。
Access-Control-Allow-Methods
表示服务器允许的跨域请求方法列表。意味着在列表里面的方法都是支持的。
if (req.method == 'OPTIONS') {
这句话的意思是如果是OPTIONS
请求,也就是跨域请求的预检。则返回200
(http状态码,表示请求成功)表示服务端允许当前的跨域请求。如果是非简单跨域(后面会解释)浏览器自己调用的,我们并不需要手动调用。
1.1.2 非简单的请求访问控制
当请求满足下述任一条件时,即应首先发送预检请求,比如说POST
请求会首先发起一个OPTIONS(预检)
请求:
- 使用了下面任一HTTP方法:
- PUT
- DELETE
- CONNECT
- OPTIONS
- TRACE
- PATCH
- 人为设置了对CORS安全的首部字段集合之外的其他首部字段。该集合为:
- Accept
- Accept-Language
- Content-Language
- Content-Type
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
- Content-Type 的值不属于下列之一:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
下面我会做一个非简单的跨域请求访问控制,我会制定自定义的请求头域和content-type
来触发预检CORS。具体客户端代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| let httpRequest = new XMLHttpRequest(); if (httpRequest) { httpRequest.onreadystatechange = function() { if (httpRequest.readyState === XMLHttpRequest.DONE) { if (httpRequest.status === 200) { console.log(httpRequest.responseText); let response = JSON.parse(httpRequest.responseText); resolve({err:null,data:response}); }else{ reject({err:{message:"请求出错"},data:null}) } } } httpRequest.open('POST',url,true); httpRequest.setRequestHeader('X-PINGOTHER', 'pingpong'); httpRequest.setRequestHeader('Content-Type', 'application/xml'); let body = '<?xml version="1.0"?><person><name>Arun</name></person>'; httpRequest.send(body); } else { reject({err:{message:"没有AJAX环境"},data:null}) } httpRequest.send(body);
|
整个网络请求的过程如下图,首先有一个OPTIONS
请求,返回200
以后,然后再发送我们自己需要的POST
请求:
两个网络请求的报文如下:
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| Host: 127.0.0.1:5389 Pragma: no-cache Cache-Control: no-cache Access-Control-Request-Method: POST Origin: http: User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1 Access-Control-Request-Headers: content-type,x-pingother Accept: *
* Referer: http: Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7
<?xml version="1.0"?><person><name>Arun</name></person>
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: * Access-Control-Allow-Headers: Content-Type, Content-Length, Authorization, Accept, X-Requested-With , X-PINGOTHER Content-Type: application/json; charset=utf-8 Content-Length: 40 ETag: W/"28-gEkkRvjiOndybUpThNV94uAc6yA" Date: Wed, 07 Feb 2018 12:23:42 GMT Proxy-Connection: Keep-alive
{"name":"隔壁老黄","password":"123456"}
|
上面四个报文的解释如下:
OPTIONS请求报文
中的Access-Control-Request-Method
表示客户端发起的跨域请求是POST
,Origin
表示客户端的地址,Access-Control-Request-Headers
表示客户端请求用户手动修改过的请求头域。
OPTIONS响应报文
中的Access-Control-Allow-Origin:*
表示允许所有客户端做跨域访问。Access-Control-Allow-Headers
表示支持用户自定义的请求头域的集合。如果客户端自己处理的请求头域超过了这个范围我这里的content-type,x-pingother
,则违反了CORS规则导致请求失败。
POST请求报文
没事好解释的,关注Origin
,X-PINGOTHER
,Content-Type
这三个关键域的值就可以了。
POST响应报文
的响应头和OPTIONS响应报文
一样的,不一样的就是响应体。后者返回我们真正需要的数据。
1.1.3 cookie和Access-Control-Allow-Credentials
还有一点是必须要强调额,就是cookie。我们通常使用它来记录一些认证信息,但是由于跨域的原因,CORS
对于cookie的使用就更加严格了。而且好像我们并不能获取到cookie(至少我想了各种办法也没有找到通过XMLHttpRequest获取跨域cookie的原因)。貌似是从协议上就禁止了。那如果我们需要在跨域服务器上使用cookie的话,需要做那些配置呢?这就需要借助Access-Control-Allow-Credentials
。下面我就通过一个真实案列来讲解跨域的cookie。下面是一个带cookie的跨域网络请求报文:
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| OPTIONS / HTTP/1.1 Host: 127.0.0.1:5389 Pragma: no-cache Cache-Control: no-cache Access-Control-Request-Method: POST Origin: http: User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1 Access-Control-Request-Headers: content-type,x-pingother Accept: *
* Referer: http: Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7
<?xml version="1.0"?><person><name>Arun</name></person>
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: http: Access-Control-Allow-Headers: Content-Type, Content-Length, Authorization, Accept, X-Requested-With , X-PINGOTHER Access-Control-Allow-Methods: PUT, POST, GET, DELETE, OPTIONS Access-Control-Expose-Headers: token Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Set-Cookie: Thu Feb 08 2018 14:42:55 GMT+0800 (CST)=%E9%BB%84%E6%88%90%E9%83%BD; Path=/ token: Thu Feb 08 2018 14:42:55 GMT+0800 (CST) Content-Type: application/json; charset=utf-8 Content-Length: 40 ETag: W/"28-gEkkRvjiOndybUpThNV94uAc6yA" Date: Thu, 08 Feb 2018 06:42:55 GMT Proxy-Connection: Keep-alive
{"name":"隔壁老黄","password":"123456"}
|
从上面的的带cookie信息的非简单跨域请求,我们发现和前面不带cookie的请求有几个区别:
POST请求报文
没啥好说的,只需要关注Origin
就可以了。
POST响应报文
中最关键的1 Access-Control-Allow-Credentials: true
,只有他为true的时候,表示服务器允许客户端的请求带cookie,否则不允许带cookie。2 Access-Control-Allow-Origin: http://localhost:8081
的值不能是*
了,必须是特定的客户端域名一个或者多个。只有在满足这两个条件的情况下,才支持跨域cookie的携带。
POST响应报文
的响应报文中,就包含了Set-Cookie
和Access-Control-Allow-Credentials
这两个域。
- 如果网络请求要携带cookie信息。对于客户端需要在创建
XMLHttpRequest
对象的时候,通过httpRequest.withCredentials = true;
来配置。对于服务端,需要在响应头中通过res.header('Access-Control-Allow-Credentials',true);
来配置。只有客户端和服务端都配置好以后,才能使用跨域cookie。
由于CORS
标准的限制,在客户端很多响应域都是不能获取到的。比如cookie我们就不能通过getResponseHeader
或者getAllResponseHeaders
获取,只能获取到协议允许我们获取到的与。我这里试了一下,通过getAllResponseHeaders
只能获取如下几个:
1 2
| content-type: application/json; charset=utf-8 token: Thu Feb 08 2018 14:55:24 GMT+0800 (CST)
|
其中content-type
是系统带的域,是协议允许获取的。token
就是我自己定义,如果我想要获取到这个域。就需要服务端在响应头里面通过Access-Control-Expose-Headers: token
来控制。比如我这里表示只允许获取到自定义的token
域。由于我们并不能获取到cookie,所以可以通过自定义的域来存放认证信息。
下面我将贴出我所有客户端和服务端的最关键的代码:
- 客户端(自己实现的XMLHpptRequest对象):
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| function createAjax() { let httpRequest; if (window.XMLHTTPRequest) { httpRequest = new XMLHttpRequest(); }else if(window.ActiveXObject){ httpRequest = new ActiveXObject('Microsoft.XMLHTTP'); } return httpRequest; }
function get(url,params,config) { return new Promise((resolve,reject) =>{ try { let httpRequest = new XMLHttpRequest(); if (httpRequest) { let query; if (params instanceof String) { query = "?" + params; } else if(params instanceof Object){ query = "?"; for (let [key,value] of Object.entries(params)) { query = query + key + "=" + encodeURIComponent(value) + "&"; } query= query.substring(0,query.length - 1); } httpRequest.onreadystatechange = function() { if (httpRequest.readyState === XMLHttpRequest.DONE) { if (httpRequest.status === 200) { let response = JSON.parse(httpRequest.responseText); resolve({err:null,data:response}); }else{ reject({err:{message:"请求出错"},data:null}) } } } if (query) { url = url + query; } httpRequest.open('GET',url,true); httpRequest.send(); } else { reject({err:{message:"没有AJAX环境"},data:null}) } } catch (error) { reject({err:error,data:null}) } }); }
function post(url,params,config) { return new Promise((resolve,reject) =>{ try { let httpRequest = new XMLHttpRequest(); httpRequest.withCredentials = true; if (httpRequest) { httpRequest.onreadystatechange = function() { console.log("========cookie=======",httpRequest.getAllResponseHeaders(),httpRequest.getResponseHeader("token")); if (httpRequest.readyState === XMLHttpRequest.DONE) { if (httpRequest.status === 200) { console.log(httpRequest.responseText); let response = JSON.parse(httpRequest.responseText); resolve({err:null,data:response}); }else{ reject({err:{message:"请求出错"},data:null}) } } } httpRequest.open('POST',url,true); httpRequest.setRequestHeader('X-PINGOTHER', 'pingpong'); httpRequest.setRequestHeader('Content-Type', 'application/xml'); let body = '<?xml version="1.0"?><person><name>Arun</name></person>'; httpRequest.send(body); } else { reject({err:{message:"没有AJAX环境"},data:null}) } } catch (error) { reject({err:error,data:null}) } }); }
export { get,post }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import {get,post} from '../XMLHttpRequest/request.js';
//get请求的发送 async getRequest() { let {err,data} = await get('http://127.0.0.1:5389', {"xx": 1,"yy": 2}).catch((err) => { console.log("出错了", err); }); console.log(err, data); },
async postRequest() { let {err,data} = await post('http://127.0.0.1:5389', {"xx": 1,"yy": 2}).catch((err) => { console.log("出错了", err); }); console.log(err, data); }
|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| module.exports = function (app) { app.all('*', function (req, res, next) { res.header('Access-Control-Allow-Origin', 'http://localhost:8081'); res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , X-PINGOTHER'); res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS'); res.header('Access-Control-Expose-Headers', 'token'); res.header('Access-Control-Allow-Credentials',true); res.header("Access-Control-Max-Age", "3600"); if (req.method == 'OPTIONS') { res.send(200); } else { next(); } }); app.get('/', (req, res) => { res.json({ name: "隔壁老黄", password: "123456", "requestParams":JSON.stringify(req.query) }); }); app.post('/', (req, res) => { res.cookie((new Date()), "隔壁老黄"); res.header("token",(new Date())); res.json({ name: "隔壁老黄", password: "123456", "requestParams":JSON.stringify(req.body) }); }); } `
|
第一部分到此结束。