前端CORS和XMLHttpRequest全方位详解

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)规范。若请求满足所有下述条件,则该请求可视为“简单请求”:

  • 使用下列方法之一:
    • GET
    • HEAD
    • POST
  • 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

如果我们不做任何配置,那么由于跨域原因,浏览器将会对我们请求做报错处理(注意:并不是服务器请求错误,是请求成功了并且返回了,但是浏览器做了报错处理)。具体请求抓包如下:

//请求报文
GET /?xx=1&yy=2 HTTP/1.1
Host: 127.0.0.1:5389
Pragma: no-cache
Cache-Control: no-cache
Origin: http://localhost:8081
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: */*
Referer: http://localhost:8081/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7

//返回报文
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 86
ETag: W/"56-Zk2w81kjYiGkrYmIocyhxiyDmXY"
Date: Wed, 07 Feb 2018 11:33:20 GMT
Proxy-Connection: Keep-alive

{"name":"隔壁老黄","password":"123456","requestParams":"{\"xx\":\"1\",\"yy\":\"2\"}"}

从上面的报文来看,我们的请求没有任何问题。但是浏览器却给我报了一个错误,就是因为跨域的原因。

img

出现这个的原因是我的服务器没有对跨域支持。我只需要在服务器添加对简单跨域的支持就可以了。

    //=============服务端代码如下============
    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');
        // res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
        if (req.method == 'OPTIONS') {
            res.send(200);
        } else {
            next();
        }
    });
    //get请求
    app.get('/', (req, res) => {
        //console.log(req.query);
        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-OriginAccess-Control-Allow-Headers其中。他们的意义如下:

  • Access-Control-Allow-Origin根据请求头中的OriginAccess-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。具体客户端代码如下:

    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请求:

img

两个网络请求的报文如下:

//================OPSTIONS请求报文=================
Host: 127.0.0.1:5389
Pragma: no-cache
Cache-Control: no-cache
Access-Control-Request-Method: POST
Origin: http://localhost:8081
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: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7
//================OPSTIONS响应报文=================
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: text/plain; charset=utf-8
Content-Length: 2
ETag: W/"2-nOO9QiTIwXgNtWtBJezz8kv3SLc"
Date: Wed, 07 Feb 2018 12:23:42 GMT
Proxy-Connection: Keep-alive

OK
//===============POST请求报文=================
POST / HTTP/1.1
Host: 127.0.0.1:5389
Content-Length: 55
Pragma: no-cache
Cache-Control: no-cache
X-PINGOTHER: pingpong
Origin: http://localhost:8081
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
Content-Type: application/xml
Accept: */*
Referer: http://localhost:8081/
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>
//================POST响应报文=================
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的跨域网络请求报文:

//=================OPTIONS请求报文=========================
OPTIONS / HTTP/1.1
Host: 127.0.0.1:5389
Pragma: no-cache
Cache-Control: no-cache
Access-Control-Request-Method: POST
Origin: http://localhost:8081
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: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7
//=================OPTIONS响应报文=========================
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: http://localhost:8081
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
Content-Type: text/plain; charset=utf-8
Content-Length: 2
ETag: W/"2-nOO9QiTIwXgNtWtBJezz8kv3SLc"
Date: Thu, 08 Feb 2018 06:42:55 GMT
Proxy-Connection: Keep-alive

OK
//=================POST请求报文=========================
POST / HTTP/1.1
Host: 127.0.0.1:5389
Content-Length: 55
Pragma: no-cache
Cache-Control: no-cache
X-PINGOTHER: pingpong
Origin: http://localhost:8081
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
Content-Type: application/xml
Accept: */*
Referer: http://localhost:8081/
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>
//=================POST响应报文=========================
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: http://localhost:8081
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-CookieAccess-Control-Allow-Credentials这两个域。
  • 如果网络请求要携带cookie信息。对于客户端需要在创建XMLHttpRequest对象的时候,通过httpRequest.withCredentials = true;来配置。对于服务端,需要在响应头中通过res.header('Access-Control-Allow-Credentials',true);来配置。只有客户端和服务端都配置好以后,才能使用跨域cookie。

由于CORS标准的限制,在客户端很多响应域都是不能获取到的。比如cookie我们就不能通过getResponseHeader或者getAllResponseHeaders获取,只能获取到协议允许我们获取到的与。我这里试了一下,通过getAllResponseHeaders只能获取如下几个:

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对象):
//创建XMLHttpRequest对象的方法
function createAjax() {
    let httpRequest;
    if (window.XMLHTTPRequest) {
        httpRequest = new XMLHttpRequest();
    }else if(window.ActiveXObject){
        httpRequest = new ActiveXObject('Microsoft.XMLHTTP');
    }
    return httpRequest;
}
/**
 * 创建get请求。返回一个promise对象
 * @param 请求地址 url 
 * @param 请求参数,可以使字符串或者对象 params 
 * @param 配置,这里暂时没有实现 config 
 */
function get(url,params,config) {
    return new Promise((resolve,reject) =>{
        try {
            let httpRequest = new XMLHttpRequest();
            if (httpRequest) {
                let query;
                //拼接get请求的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) {
                            // console.log(httpRequest.responseText);
                            let response = JSON.parse(httpRequest.responseText);
                            resolve({err:null,data:response});
                        }else{
                            reject({err:{message:"请求出错"},data:null})
                        }
                    } 
                }
                //把query添加到url后面
                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})
        }
    });
}
/**
 * 自定义XMLHttpRequest的POST请求,返回一个Promise对象
 * @param 请求的地址 url 
 * @param 没用 params 
 * @param 没用 config 
 */
function post(url,params,config) {
    return new Promise((resolve,reject) =>{
        try {
            let httpRequest = new XMLHttpRequest();
            //让请求支持cookie信息的携带
            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})
                        }
                    } 
                }
                //post请求
                httpRequest.open('POST',url,true);
                //添加自定义的请求头域
                httpRequest.setRequestHeader('X-PINGOTHER', 'pingpong');
                //设置body的类型为xml
                httpRequest.setRequestHeader('Content-Type', 'application/xml');
                //post请求的请求体
                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
}

  • 客户端(如何使用):
//引入模块
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);
},
//post请求的发送
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);
}
  • 服务端的实现代码:
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');
        //允许携带cookie信息
        res.header('Access-Control-Allow-Credentials',true);
        //CORS认证的有效期
        res.header("Access-Control-Max-Age", "3600");
        if (req.method == 'OPTIONS') {
            res.send(200);
        } else {
            next();
        }
    });
    //get请求
    app.get('/', (req, res) => {
        res.json({
            name: "隔壁老黄",
            password: "123456",
            "requestParams":JSON.stringify(req.query)
        });
    });
    //post请求
    app.post('/', (req, res) => {
        //设置cookie,只有客户端和服务端都配置好以后才能成功
        res.cookie((new Date()), "隔壁老黄");
        //设置自定义的响应头域,可以存放认证信息或者响应给客户端的信息。
        res.header("token",(new Date()));
        res.json({
            name: "隔壁老黄",
            password: "123456",
            "requestParams":JSON.stringify(req.body)
        });
    });
}

第一部分到此结束。

Loading Disqus comments...
Table of Contents