全解跨域请求处理办法

89

为什么会有跨域问题

我们试想一下以下几种情况:

  1. 我们打开了一个天猫并且登录了自己的账号,这时我们再打开一个天猫的商品,我们不需要再进行一次登录就可以直接购买商品,因为这两个网页是同源的,可以共享登录相关的 cookie 或 localStorage 数据;
  2. 如果你正在用支付宝或者网银,同时打开了一个不知名的网页,如果这个网页可以访问你支付宝或者网银页面的信息,就会产生严重的安全的问题。如果该未知网站是黑客的工具,那他就可以借此发起 CSRF 攻击了。显然浏览器不允许这样的事情发生;
  3. 想必你也有过同时登陆好几个 qq 账号的情况,如果同时打开各自的 qq 空间浏览器会有一个小号模式,也就是另外再打开一个窗口专门用来打开第二个 qq 账号的空间。

为了解决不同域名相互访问数据导致的不安全问题,Netscape提出的一个著名的安全策略——同源策略,它是指同一个“源头”的数据可以自由访问,但不同源的数据相互之间都不能访问。

同源策略

很明显,上述第1个和第3个例子中,不同的天猫商店和 qq 空间属于同源,可以共享登录信息。qq 为了区别不同的 qq 的登录信息,重新打开了一个窗口,因为浏览器的不同窗口是不能共享信息的。而第2个例子中的支付宝、网银、不知名网站之间是非同源的,所以彼此之间无法访问信息,如果你执意想请求数据,会提示异常:

No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'null' is therefore not allowed access.

那么什么是同源的请求呢?同源请求要求被请求资源页面和发出请求页面满足3个相同:

协议相同
host相同
端口相同

简单理解一下:

/*以下两个数据非同源,因为协议不同*/
http://www.abc123.com.cn/item/a.js
https://www.abc123.com.cn/item/a.js

/*以下两个数据非同源,因为域名不同*/
http://www.abc123.com.cn/item/a.js
http://www.abc123.com/item/a.js

/*以下两个数据非同源,因为主机名不同*/
http://www.abc123.com.cn/item/a.js
http://item.abc123.com.cn/item/a.js

/*以下两个数据非同源,因为协议不同*/
http://www.abc123.com.cn/item/a.js
http://www.abc123.com.cn:8080/item/a.js

/* 以下两个数据非同源,域名和 ip 视为不同源
 * 这里应注意,ip和域名替换一样不是同源的
 * 假设www.abc123.com.cn解析后的 ip 是 195.155.200.134
 */
http://www.abc123.com.cn/
http://195.155.200.134/

/*以下两个数据同源*/                               /* 这个是同源的*/
http://www.abc123.com.cn/source/a.html
http://www.abc123.com.cn/item/b.js

HTTP 简单请求和非简单请求

http 请求满足一下条件时称为简单请求,否则是非简单请求:

  1. 请求方法是 HEAD,GET,POST 之一
  2. HTTP的头信息不超出以下几种字段:

    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type
  3. Content-Type 取值仅限于 application/x-www-form-urlencoded, multipart/form-data, text/plain

非简单请求在发送之前会发送一次 OPTION 预请求,如果在跨域操作遇到返回 405(Method Not Allowed) 错误,需要服务端允许 OPTION 请求。

HTTP 跨域访问的处理办法及适用条件

JSOP

适用条件:请求的 GET 接口需要支持 jsonp 访问

这里需要强调的是,jsonp 不属于 Ajax 的部分,它只是把 url 放入 script 标签中实现的数据传输,不受同源策略限制。由于一般库也会把它和 Ajax 封装在一起,由于其和 Ajax 根部不是一回事,所以这里不讨论。下面是一个 jsonp 的例子:

window.jsonpCallback = console.log;
var JSONP = document.createElement("script");
JSONP.src = "http://tcc.taobao.com/cc/json/mobile_tel_segment.htm?tel=13122222222&t=" + Math.random() + "&callback=jsonpCallback";;
document.body.appendChild(JSONP);

后端支持jsonp方式(Nodejs)

var querystring = require('querystring');
var http = require('http');
var server = http.createServer();

server.on('request', function(req, res) {
    var params = qs.parse(req.url.split('?')[1]);
    var fn = params.callback;

    // jsonp返回设置
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    res.write(fn + '(' + JSON.stringify(params) + ')');

    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

document.domain

适用条件: host 中仅服务器不同的情况,域名本身应该相同

www.dom.comw1.dom.com 需要同源才能访问,可以将 document.domain 设置为 dom.com 解决该问题

document.domain = 'dom.com';

例如,我想开发一个浏览器插件,发现腾讯视频页有个 iframe 其本身的跨域的,无法获取其 iframe 的 DOM 对象。但域名部分相同,可以通过该方法解决.

注:如果你想设置它为完全不同的域名,那肯定会报同源错误的,注意使用范围!

嵌入 iframe

适用条件: host 中仅服务器不同的情况,域名本身应该相同

有了上面的例子就不难理解这个方法了,严格来说这不是一个新的方法,而是上一个方法的延伸。通过设置document.domain, 使同一个域名下不同服务器名的页面可以访问数据,但值得注意的是:这个数据访问不是相互的,外部页面可以访问 iframe 内部的数据,但 iframe 无法不能访问外部的数据。

location.hash

适用条件:iframe 和其宿主页面通信

一个完成的 url 中 # 及后面的部分为 hash, 可以通过修改这个部分完成iframe 的和宿主直接的数据传递,下面演示一下 iframe 页面(B.html)像宿主(A.html)传数据, 反之同理:

// A.html
data = ['book', 'map', 'shelf', 'knife'];
setTimeout(() => {
  location.hash = window.encodeURIComponent(data.join('/'));
}, 1000);

// B.html
window.parent.onhashchange = function (e) {
  var data = window.decodeURIComponent(e.newURL.split('#')[1]).split('/');
  console.log(data);  // ["book", "map", "shelf", "knife"]
}

*注意反向传递数据时应该使用 window.parent.location.hash

window.name

适用条件:宿主页面和 iframe 之间通信

window对象有个name属性,该属性有个特征:即在 window 的生命周期内,窗口载入的所有的页面 (iframe) 都是共享一个 window.name 的,每个页面对 window.name 都有读写的权限,window.name 是持久存在一个窗口载入过的所有页面中的,并不会因新页面的载入而进行重置。

这样在 window 中编辑 window.name 就可以在 iframe 中得到,但这个过程缺乏监听,宿主页面(A.html)和 iframe 页面(B.html)相互并不知道对方在什么时候修改该值:

// A.html
setTimeout(() => {
  window.parent.name = "what!";
}, 2000);

// B.html
setTimeout(() => {
  console.log(window.name);   // what!
}, 2500);

postMessage

适用条件:postMessage 是 H5 提出的一个消息互通的机制,解决 iframe 不能消息互通的问题,也可以跨 window 通信,语法如下:
// 在 www.siteA.com 中发出消息
// @message{any} 要发送的数据(注意:老版本浏览器只支持字符串类型)
// @targetOrigin{string} 规定接收数据的域,只有其指定的域才能收到消息,如果为"*"则没用域的限制
// transfer{any} 与 message 一同发送并转移所有权
window.postMessage(message, targetOrigin, [transfer]);

// 在另一个页面接受参数
window.onmessage = console.log;

这里暂不谈论第三个参数,因为你可能一辈子也用不到它。而 targetOrigin 最好不要使用 "*",除非你想让所有页面都收到你的消息。

一种你会用到的场景(iframe):

<!-- www.siteA.com/index.html -->
<script>
    window.addEventListener('message', function(e){
        console.log('Get message: "' + e.data.title + '" from ' + e.origin);  // 'Get message: "Saying hello to siteA!" from http://www.siteB.com'
    });
</script>
<iframe src="http://www.siteB.com"></iframe>


<!-- www.siteB.com/index.html -->
<script>
    function sendMessage(){
        window.postMessage({title: 'Saying hello to siteA!'}, 'http://www.siteA.com');
    }
    setTimeout(sendMessage, 2000);
</script>

这一种仅仅是没有了iframe,当你在同一个浏览器窗口同时打开 www.siteA.comwww.siteB.com 两个标签时也可以这样用

<!-- www.siteA.com/index.html -->
<script>
    window.addEventListener('message', function(e){
        console.log('Get message: "' + e.data.title + '" from ' + e.origin);  // 'Get message: "Saying hello to siteA!" from http://www.siteB.com'
    });
</script>


<!-- www.siteB.com/index.html -->
<script>
    function sendMessage(){
        window.postMessage({title: 'Saying hello to siteA!'}, 'http://www.siteA.com');
    }
    setTimeout(sendMessage, 2000);
</script>

反向代理服务器

页面需要访问一些跨域接口,由于代理的存在,在服务器看来请求是不跨域,所以使用各种请求。但需要注意 http 到 https 的兼容问题。

比如当我在一些在线平台开发网站后得到一个页面 www.site-A.com, 而这个页面需要请求我自己的数据服务器data.site-B.com上的数据, 这样同样会产生跨域问题,但是www.site-A.com这个页面是挂在第三方服务器上的,解决这个问题可以采用代理服务器的方法:

var express = require('express');
var request = require('request');
var app = express();

app.use('/api', function(req, res) {
  var url = 'http://data.site-B.com/api2' + req.url;
  req.pipe(request(url)).pipe(res);
});
app.use('/', function(req, res) {
  var url = 'http://data.site-C.com';
  req.pipe(request(url)).pipe(res);
});

当然还需要同时配置一个 host:

127.0.0.1 local.www.site-B.com

然后访问 local.www.site-B.com 就 OK 了。

CORS

适用条件:CORS 需要服务端支持,且存在一定的兼容性问题(如今你已经可以不考虑,但必要时不要忘了这个'bug')。其通过添加 http 头关键字实现跨域可访问,包括如下头内容:
# www.siteA.com/api 返回相应需要具有如下 http 头字段

Access-Control-Allow-Origin: 'http://www.siteB.com'    # 指定域可以请求,通配符'*'(必须)
Access-Control-Allow-Methods: 'GET,PUT,POST,DELETE'    # 指定允许的跨域请求方式(必须)
Access-Control-Allow-Headers: 'Content-Type'           # 请求中必须包含的 http 头字段
Access-Control-Allow-Credentials: true                 # 配合请求中的 withCredentials 头进行请求验证

通过 express 实现也很简单,在注册路由之前添加:

var cors = require('cors');   // 通过 npm 安装
app.use(cors());

当然你也可以自定义一个中间件:

// 自定义中间件
var cors = function (req, res, next) {
 // 自定义设置跨域需要的响应头。
 res.header('Access-Control-Allow-Origin', 'http://www.siteB.com');
 res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
 next();
};

app.use(cors); // 运用跨域的中间件

WebSocket 协议跨域

ws 协议是 H5 中的 web 全双工通信解决方案,常规 http 属于请求相应的过程,在客户端没有请求的情况下,服务端无法给客户端主动推送数据,ws 协议解决了这个问题,但处于安全考虑,其同样有同源策略的限制。

*这里不讨论通过长连接和服务端挂起请求等方法推送数据,本文只讨论跨域。

下面举个例子(依赖socket.io.js):

// 前端部分
socket.on('connect', function() {
  // 监听服务端消息
  socket.on('message', function(msg) {
    console.log('data from server: ' + msg);
  });

  // 监听服务端关闭
  socket.on('disconnect', function() {
    console.log('Server socket has closed.');
  });
});

document.getElementById('input').onkeyup = function(e) {
  if(!e.shiftKey && !e.ctrlKey && !e.altKey && e.keyCode === 13)
    socket.send(this.value);
};

// 后端部分(node.js)
var http = require('http');
var socket = require('socket.io');

// 启http服务
var server = http.createServer(function(req, res) {
  res.writeHead(200, {
    'Content-type': 'text/html'
  });
  res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

// 监听socket连接
socket.listen(server).on('connection', function(client) {
  // 监听客户端信息
  client.on('message', function(msg) {
    client.send('hello:' + msg);
    console.log('data from client: ' + msg);
  });

  // 监听客户端断开
  client.on('disconnect', function() {
    console.log('Client socket has closed.');
  });
});

HTML 标签中的 crossorigin 属性

HTML 中 <img>, <video><script> 具有 crossorigin 属性。添加属性会使相应添加 CORS 相关 http 头(需要服务器支持)。同时,其还有以下可能的取值:

  • user-credentials 该请求通过 cookie 交换 user-credentials,服务器相应需添加 Access-Control-Allow-Origin
  • anonymous 该请求不会通过 cookie 交换 user-credentials,服务器相应需添加 Access-Control-Allow-Credentials

当只写了 crossorigin 属性没有指定值时,其默认值为 "anonymous"。即以下两行代码等价:

<scirpt src="a.com/vendor.js" corssorigin></script>
<scirpt src="a.com/vendor.js" corssorigin="anonymous"></script>

几种不同的跨域方法比较

方法 使用条件 使用条件是否与后端交互 优点 缺点
JSONP 服务端支持 jsonp 请求 兼容所有浏览器 只支持 GET 请求,只能和服务端通信
CORS 服务器相应需要相关投资端支持 方便的错误处理,支持所有http请求类型 存在浏览器兼容性问题(如今可以忽略了)
document.domain 仅需要跨子域发起请求 使用便捷,没有兼容问题 对于完全不同的域名无法使用
postMessage 浏览器不同 window 间通信、 iframe 和其宿主通信 支持浏览器页面间或页面和 iframe 间同行 需要浏览器兼容 H5 接口
window.name iframe 和其宿主通信 简单易操作 数据暴露在全局不安全
location.hash iframe 和其宿主通信 简单易操作 数据在 url 中不安全并且有长度限制
反向代理 - 任何情况都可用 使用比较麻烦,需要自己建立服务

扩展:基于 webpack 的反向代理配置示例

添加 webpack 配置如下:

const config = {
  // ...
  devServer: {
    // ...
    proxy: {
      '/api': {
        target: 'https://data.site-B.com/api2',
        changeOrigin: true, // 允许跨域
        secure: false // 允许访问 https
      },
      '/': {
        target: 'https://data.site-C.com',
        changeOrigin: true,
        secure: false
      },
    }
  }
};
module.exports = config;

扩展:基于 Nginx 反向代理和CORS配置示例

  • CORS 配置
location / {
  add_header  Access-Control-Allow-Origin *;
  add_header Access-Control-Allow-Credentials true;
  add_header  Access-Control-Allow-Methods: GET,PUT,POST,DELETE;
}
  • 反向代理配置
server {
    listen  7001;
    server_name  www.domain1.com;

    location / {
        proxy_pass   http://www.B.com:7001;  #反向代理
    }
}

你可能感兴趣的

载入中...