非权威指南-浏览器跨域请求解决方案
前言
本文所使用的完整代码.可在github仓库获得
博主的个人博客,欢迎来玩
1. 同源策略
1.1 起源
谈到跨域问题就不得不先谈谈同源策略。
同源策略(Same Origin Policy) 是 Netscape 提出的目前所有浏览器都在实行的一个安全政策。同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互,是一种用于隔离潜在恶意文件的重要机制。
同源策略会限制不同源的页面获取如下数据:
- Cookie、LocalStorage 和 IndexDB
- DOM
- AJax请求
1.2 目的
虽然同源策略也会阻隔一些正常的请求(正是后面将谈到的跨域请求),但是它却是浏览器安全的基石。同源策略的目的是为了保证用户信息的安全(主要是 Cookie 信息),防止恶意网站窃取数据。
假如我们登陆网站A,网站A会用 Cookie 记录我们的身份,如果这时我们又浏览了恶意网站B,如果没有同源策略,网站B就可以读取网站A记录的代表我们身份的Cookie,从而伪装成我们去访问网站A。这样不仅我们的个人隐私得不到保障,更可能威胁我们电子账户的安全。
1.3 判断是否同源
判断两个页面是否同源的充要条件是:
- 协议相同
- 域名(主机)相同
- 端口相同
相对 http://127.0.0.1/index.html
检测下列页面是否同源:
URL | 结果 | 原因 | |
---|---|---|---|
http://127.0.0.1/page1.html | 同源 | 只有路径不同 | |
https://127.0.0.1/index.html | 不同源 | 协议不同(http->https) | |
http://127.0.0.1:3000/index.html | 不同源 | 端口不同(80->3000) | |
http://127.0.0.2/index.html | 不同源 | 主机不同(127.0.0.1->127.0.0.2) |
IE 特殊性
- 两个高度互信的域名,如公司域名,不受同源策略限制
- 只是端口不同的两个URL属于同源并且不受任何限制
2. 跨域请求解决方案
让我们进入正题。
在生产开发时,web服务器与数据服务器通常部署在两个服务器上,这意味着它们主机不同,如果 web服务器上的某个页面请求数据服务器的接口就属于跨域请求。如果使用和同源一样配置的 Ajax请求是不可能获得数据的。我们不妨来做一个小实验。
服务器-01_serverStart.js(node + express)
由于接下来的几种方法会不停变动服务器的配置,建议使用 supervisor
热部署
const express = require('express')
const app = new express()
// 监听端口为3000
app.listen(3000, function () {
console.log('Service Started ')
})
// 请求路径为 'account' 的 get 请求
app.get('/account', function (req, res) {
res.send('Account balance: 100')
})
// 请求路径为 'withdraw' 的 post 请求
app.post('/withdraw', function (req, res) {
const money = req.body
res.send('Account balance minus' + money)
})
客户端-01_跨域请求初体验.html(使用 axios 发送 Ajax 请求)
<body>
<script src="https://cdn.bootcss.com/axios/0.19.0-beta.1/axios.min.js"></script>
<script type="text/javascript">
// 使用 axios 发送 Ajax 请求
axios.get('http://127.0.0.1:3000/account').then(response => {
console.log(response)
})
</script>
</body>
不出所料,浏览器返回了错误信息
接下来我们将关注于如何解决跨域请求,获取数据。
2.1 JSONP
2.1.1 实现原理
JSONP(JSON With Padding) 即填充式JSON,是利用<script>
标签不受同源策略限制的特性实现的。不受同源策略限制的常用标签还有:
<link>
<img>
<video> & <audio>
<frame> & <iframe>
JSON的工作原理示意图如下:
- 客户端利用
<script>
标签向非同源服务器发送请求,并传递一个回调函数Callback
作为请求参数 - 服务器处理请求,将数据作为实参填充
Callback
(填充式JSON由此得名),返回"Callback(data1, data2)"
(注意是字符串) - 浏览器接收到响应,解析执行
Callback(data1, data2)
2.1.2 实现
我们使用原生JS实现一个简单的JSONP
服务器-02_JSONP.js
app.get('/account', function (req, res) {
let {callback} = req.query // 获取回调函数名
let data = 100 // 模拟数据
res.send(`${callback}(${data})`) // 响应字符串形式函数调用
})
客户端- 02_JSONP.html
<body>
<script type="text/javascript">
function showMoney (money) {
console.log(`I have $ ${money}`)
}
</script>
<!--发送请求-->
<script type="text/javascript"
src="http://127.0.0.1:3000/account?callback=showMoney">
</script>
</body>
查看结果
可以看到成功实现跨域请求,获取到数据
2.1.3 优缺点
优点:
- 兼容性好,不需要 XMLHttpRequest或ActiveX的支持 ,可以在古老的浏览器运行
- 在请求完毕后可以通过调用callback的方式回传结果。将回调方法的权限给了调用方,利于重用
缺点:
- 只支持 GET 请求,不支持 POST 等其他类型的 HTTP 请求
- 只支持跨域 HTTP 请求, 不能解决不同域的两个页面间进行 JavaScript 调用的问题
- 没有错误处理机制
2.2 CORS
跨域资源共享(Cross-Origin Resource Sharing)是一种机制,它使用额外的 HTTP 首部字段告诉浏览器允许Web应用发送跨域请求。
2.2.1 实现原理
服务器通过一些新增的 HTTP 首部字段告诉浏览器哪些源站有权限访问哪些资源,然后由浏览器控制是否允许客户端跨域请求。
2.2.3 实现
客户端
整个 CORS 通信过程,都是浏览器自动完成,客户端不需要任何处理
服务器
需要配置响应首部,新增的 HTTP 首部 有:
-
Access-Control-Allow-Origin: <origin>|*
指定该响应的资源是否被允许与给定的 origin 共享
-
Access-Control-Allow-Methos: <methods>
用于响应预检请求,指定实际请求允许的 HTTP 方法
-
Access-Control-Allow-Headers
用于响应预检请求,指定实际请求允许携带的首部字段
-
Access-Control-Expose-Headers: <Headers>
指定服务器允许浏览器访问的响应头
-
Access-Control-Max-Age: <seconds>
表示预检请求的结果在多少秒内有效
-
Acces-Control-Allow-Credentials: true | false
指定当浏览器的
credentials
设置为 true 时是否允许浏览器读取 response 的内容,用在预检请求相应中时指定实际的请求是否可以使用credentials
为了方便实验,我们先配置一个允许所有源访问的服务器
03_CORS.js
// 设置通用响应头
app.all('*', function(req, res) {
// 指定返回 utf-8 编码的 json 数据
res.header("Content-Type", "application/json;charset=utf-8");
// 允许所有源访问
res.header("Access-Control-Allow-Origin", "*");
})
浏览器
浏览器对不同的请求方式的处理行为不同。为了方便理解,我们先介绍几个用于控制跨域请求的 HTTP 请求首部字段
-
Origin
指示预检请求或实际请求的源站 -
Access-Control-Request-Method
用于预检请求,将实际请求所使用的 HTTP 方法告诉服务器 -
Access-Control-Request-Headers
用于预检请求,将实际请求所携带的首部字段告诉服务器
简单请求
请求为简单请求的充要条件是:
- 请求方式为
GET
或HEAD
或POST
- 首部字段只包含:
Accept
Accept-Language
Content-Language
-
Content-Type(值仅限下列三者之一):
text-plain
multipart/form-data
application/x-www.form-urlencoded
DPR
Downlink
Save-Data
Viewport-Width
Width
- 请求中任意
XMLHttpRequestUpload(XMLHttpRequest.upload)
对象均没注册任何事件监听器 - 请求中没有使用
ReadableStream
对象
看一个不附带身份凭证简单请求的例子:
03_CORS简单请求.html
<body>
<script type="text/javascript">
// 发送请求
const xmlhttp = new XMLHttpRequest()
const url = 'http://127.0.0.1:3000/account'
xmlhttp.open('get', url, true)
xmlhttp.send()
// 处理响应
xmlhttp.onreadystatechange = function () {
if(xmlhttp.readyState === 4){
if(xmlhttp.status >= 200 && xmlhttp.status < 300 || xmlhttp.status === 304){
console.log(`I have $ ${xmlhttp.responseText}`)
}
}
}
</script>
</body>
可以看到我们成功发送了请求,并取回了数据
接下来我们看看浏览器是如何控制是否允许跨域的
请求报文
Origin 表示请求来源于 http://localhhost:7890
响应报文
浏览器检视 Origin
和 Access-Control-Allow-Origin
就可以完成简单的访问控制
我们再来看一个附带身份凭证简单请求的例子:
如果要发送(接收) Cookie 信息(Cookie 必须是同源的,也就是说客户端自己的Cookie 无论如何发送不到服务器,但是服务器给客户端设置的Cookie,客户端可以之后再发给服务器):
客户端需设置 XMLHttpRequest
实例的 withCredentials = true
,
服务器需要设置Access-Control-Allow-Origin
为客户端 ip (不能设置为 *
),同时设置Access-Control-Allow-Credentials
为 ture (如果不为 true , 客户端无法用js操作返回的cookie)
调试时将localhost
改为 127.0.0.1
可避免一些问题
03_CORS带凭证的简单请求.html
const xmlhttp = new XMLHttpRequest()
xmlhttp.open(method, url, true)
xmlhttp.withCredentials = true;
xmlhttp.send()
03_CORS带凭证的简单请求.js
app.get('/setCookie', function (req, res) {
res.cookie('money','1100'); // 设置 cookie 为 money:1100
res.end('set Cookie ok')
})
app.get('/account', function (req, res) {
let money = req.headers.cookie.split('=')[1] // 读取 cookie
money = money * 1 + 100
res.cookie('money', money) // 更改 cookie
res.end('ok')
})
观察如下请求报文,可以看到客户端获取了服务器设置的 cookie 并将其随着请求再次传递给服务器
观察如下应答报文,可以看到服务器获取到了客户端传递的 cookie 并修改后再次传递给了客户端。被第一个红色边框选中的 HTTP 首部 正是我们在服务器上所配置的。
需预检请求
不符合简单请求条件的请求都属于需预检请求,顾名思义,需预检请求需要浏览器先用 OPTIONS
方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。
我们看一个预检请求的例子(我们这次使用 POST请求,同时验证 CORS 支持 POST请求):
03_需预检请求.html
因为 Content-Type
的值不为简单请求的三种之一,所以该请求为需预检请求
// 发送请求
const xmlhttp = new XMLHttpRequest()
const url = 'http://127.0.0.1:3000/withdraw'
xmlhttp.open('POST', url, true)
xmlhttp.setRequestHeader('Content-Type', 'application/json')
xmlhttp.send(JSON.stringify({"cost": '100'}))
03_需预检请求.js
// 使用 body-parser 获取 post请求数据
const bodyParser=require("body-parser");
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended:false }))
app.all('*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "*");
res.header("Access-Control-Allow-Methods", "*");
res.header("Content-Type", "application/json;charset=utf-8");
next()
})
app.post('/withdraw', function (req, res) {
const money = req.body.cost
res.send('Account balance minus $' + money)
})
我们来看看需预检请求的示意图:
-
如果客户端的请求被判定为需预检请求,浏览器就会先发送一个请求方式为 OPTION 的预检请求,并包含以下字段:
-
Access-Control-Request-Method
表明实际请求使用的请求方式 -
Access-Control-Request-Headers
表明实际请求中不属于简单请求的 HTTP 首部字段 -
Origin
请求的源地址
-
-
服务发送一个响应,包含以下字段:
-
Access-Control-Allow-Method
服务器允许的请求方式 -
Access-Control-Allow-Headers
服务器允许的首部字段 -
Access-Control-Allow-Origin
服务器允许的源地址 -
Access-Control-Max-Age(可选,默认值5s)
指示预检请求的结果在多少秒内有效
-
-
浏览器匹配对应的请求头和响应头,匹配成功会再发送一个实际请求获取服务器数据,如果匹配失败则不再发送。
2.2.4 优缺点
优点:
- CORS支持所有类型的HTTP请求,功能完善
- CORS错误处理机制较完善(可以监听错误,也可以在控制台看到简略的错误信息)
缺点:
- 对于需预检请求,需要发送两次请求
- 不支持老的浏览器(现在常用的主流浏览器都已支持)
2.3 代理
2.3.1 实现原理
同源策略是浏览器要遵循的,服务器向服务器请求无需遵循同源策略,也就不存在跨域问题
2.3.2 实现
Node.js 代理
使用 http-proxy-middleware
代理
启动03_CROS需预检请求
(端口3000)作为服务器,为防止影响注释掉其中设置CORS响应头的代码。我们只需要再创建一个代理服务器(端口5000)就可以跨域访问了,此时客户端请求'http://127.0.0.1:5000/withdraw'
会被代理到 http://127.0.0.1:3000/withdraw
04_代理-Node.js.js
const express = require('express')
const proxy = require('http-proxy-middleware')
const app = new express()
// 此时客户端和代理服务器属于跨域,需要使用上一节介绍的 CORS 实现请求
app.all('*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "*");
res.header("Access-Control-Allow-Methods","*");
res.header("Content-Type", "application/json;charset=utf-8");
next()
})
app.listen(5000, function () {
console.log('Proxy Service Started ')
})
app.use(
'/', // 代理所有请求
proxy({
target: 'http://127.0.0.1:3000', // 被代理的服务器地址
})
)
最终我们成功获得了 127.0.0.1:4000
返回的数据
webpack-dev-server 代理
webpack-dev-server 是基于 express 框架的小型 node.js 服务器,是http-proxy-middleware
的再一层封装所以配置方式与前一种类似
webpack.config.js
devServer: {
contentBase: 'dist',
proxy: {
'/withdraw': {
target: 'http://127.0.0.1:3000', // 被代理的服务器地址
}
}
}
2.4 PostMessage
window.postMessage()
可以安全得实现跨源通信。
2.4.1 实现原理
我们先介绍几个API:
类似于发布-订阅模型。一个窗口发布消息,另一个窗口接收消息
-
otherWindow.postMessage(message, targetOrigin, [transfer])
-
otherWindow
要发送消息到的窗口 -
message
将要发送到 otherWindow 的消息 -
targetOrigin
指定能收到消息的窗口 -
transfer(可选)
与 message 同时传递的 Transfersble 对象
-
-
window.addEventListener('message', callback)
监听其他窗口发来的消息
2.4.1 实现
为了方便演示,我们在 IIS(或任意web服务器) 托管两个网站:
- page1:
http://127.0.0.1:3000
- page2:
http://127.0.0.1:5000
page1
注意! postMessage 要在 127.0.0.1:5000
加载完毕后才能发送,所以我们需要加个延时器等待页面加载完毕
<script type="text/javascript">
var otherWindow = window.open('http://127.0.0.1:5000')
setTimeout(function () {
otherWindow.postMessage("从3000发给5000", "http://127.0.0.1:5000")
window.addEventListener("message", function (event) {
// 增强安全性
if(event.origin !== 'http://127.0.0.1:5000') {
return
}
console.log(event.data)
})
}, 200)
</script>
page2
<script type="text/javascript">
window.addEventListener("message", function (event) {
// 增强安全性
if(event.origin !== 'http://127.0.0.1:3000') {
return
}
console.log(event.data)
event.source.postMessage("从5000发给3000",'http://127.0.0.1:3000');
})
</script>
打开控制台我们可以发现 page1 和 page2 互相发送的消息:
上一篇: C++深度解析(16)—类的静态成员
推荐阅读