从 CORS 到 Nginx:跨域解决方案详解
Category(分类): Backend Status: 已读
原文:从CORS到Ngnix(掘金)。 本文为整理版本,保留全部跨域方案讲解与示例代码。
一、什么是跨域
同源策略及其限制
同源策略是一个安全策略。所谓的同源,指的是协议、域名、端口相同。浏览器出于安全考虑,只允许本域名下的接口交互,不同源的客户端脚本在没有明确授权的情况下,不能读写对方的资源。
同源策略限制的内容有:
- Cookie、LocalStorage、IndexedDB 等存储性内容;
- DOM 节点;
- AJAX 请求发送后,非同源会被浏览器拦截。
但有三个标签允许跨域加载资源:
<img src=XXX>
<link href=XXX>
<script src=XXX>
这也就是 JSONP 的来源。
常见的跨域场景
当协议、子域名、主域名、端口号中任意一个不相同时,都算作不同源。
特别注意三点:
- 如果是协议和端口造成的跨域问题,"前端"是无能为力的;
- 跨域仅通过"URL 的首部"(协议、域名、端口)来识别,不会根据域名对应的 IP 是否相同来判断;
- 跨域并不是请求发不出去——请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。
二、跨域解决方案
1. JSONP
原理:利用 <script> 标签没有跨域限制的漏洞,网页可以得到从其他来源动态产生的 JSON 数据。JSONP 请求一定需要对方的服务器做支持。
与 AJAX 对比:都是客户端向服务端发送请求、从服务端获取数据的方式,但 AJAX 属于同源策略,JSONP 属于非同源策略。
优缺点:优点是简单兼容性好;缺点是仅支持 GET 方法,且不安全,可能遭受 XSS 攻击。
实现流程:
- 声明一个回调函数,函数名(如
show)作为参数值传递给跨域请求数据的服务器,函数形参为目标数据; - 创建一个
<script>标签,把跨域的 API 地址赋值给 src,并在地址中传递函数名(?callback=show); - 服务器接收到请求后做特殊处理:把函数名和它需要返回的数据拼接成字符串,如
show('我不爱你'); - 服务器把数据通过 HTTP 返回给客户端,客户端再调用执行之前声明的回调函数。
封装一个 JSONP 函数:
// 封装 JSONP 函数
function jsonp({ url, params, callback }) {
return new Promise((resolve, reject) => {
let script = document.createElement('script')
params = JSON.parse(JSON.stringify(params))
let arrs = []
for (let key in params) {
arrs.push(`${key}=${params[key]}`)
}
arrs.push(`callback=${callback}`)
script.src = `${url}?${arrs.join('&')}`
document.body.appendChild(script)
window[callback] = function (data) {
resolve(data)
document.body.removeChild(script)
}
})
}
// 前端调用
jsonp({
url: 'http://localhost:3000/say',
params: { wd: 'I Love you' },
callback: 'show'
}).then(data => { console.log(data) })
// 后端响应(express)
router.get('/say', function (req, res, next) {
let data = { username: 'zs', password: 123456 }
let { wd, callback } = req.query
res.end(`${callback}(${JSON.stringify(data)})`)
})
2. CORS
CORS 需要浏览器和后端同时支持(IE8/9 需通过 XDomainRequest 实现)。浏览器会自动进行 CORS 通信,实现 CORS 的关键是后端:只要后端设置了 Access-Control-Allow-Origin 就可以开启 CORS。
CORS 请求分为简单请求和复杂请求两种:
简单请求需同时满足两个条件:
- 条件 1:方法为 GET / HEAD / POST 之一;
- 条件 2:Content-Type 仅限于
text/plain、multipart/form-data、application/x-www-form-urlencoded三者之一。
复杂请求:不符合以上条件的请求。复杂请求会在正式通信之前增加一次 HTTP 查询请求,称为"预检"请求(OPTIONS 方法),通过该请求来知道服务端是否允许跨域。
后端完整配置示例:
let express = require('express')
let app = express()
let whitList = ['http://localhost:3000'] // 设置白名单
app.use(function (req, res, next) {
let origin = req.headers.origin
if (whitList.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin) // 设置哪个源可以访问我
res.setHeader('Access-Control-Allow-Headers', 'name') // 允许携带哪个头访问我
res.setHeader('Access-Control-Allow-Methods', 'PUT') // 允许哪个方法访问我
res.setHeader('Access-Control-Allow-Credentials', true) // 允许携带 cookie
res.setHeader('Access-Control-Max-Age', 6) // 预检的存活时间
res.setHeader('Access-Control-Expose-Headers', 'name') // 允许返回的头
if (req.method === 'OPTIONS') {
res.end() // OPTIONS 请求不做任何处理
}
}
next()
})
app.put('/getData', function (req, res) {
res.setHeader('name', 'jw') // 返回自定义响应头
res.end(JSON.stringify({ username: 'zs', password: 123456 }))
})
app.listen(4000)
3. postMessage
postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,是为数不多的可跨域操作的 window 属性,可用于解决:
- 页面和其打开的新窗口的数据传递;
- 多窗口之间消息传递;
- 页面与嵌套的 iframe 消息传递;
- 以上三个场景的跨域数据传递。
otherWindow.postMessage(message, targetOrigin, [transfer])
message:将要发送到其他 window 的数据;targetOrigin:指定哪些窗口能接收到消息事件,可以是*(无限制)或一个 URL。目标窗口的协议、主机地址或端口任意一项不匹配,消息就不会发送;transfer(可选):一串和 message 同时传递的 Transferable 对象,所有权将转移给接收方。
4. WebSocket
WebSocket 是基于 TCP 的协议。建立连接时,客户端先向服务器发起一个 HTTP 请求,其中附加头 Upgrade: WebSocket 表明这是一个申请协议升级的请求;服务器解析后产生应答,连接就建立起来了,双方可以自由传递信息,直到一方主动关闭。
// 前端
let socket = new WebSocket('ws://localhost:4000')
socket.onopen = function () {
socket.send(JSON.stringify({ username: 'hw', password: 456789 }))
}
socket.onmessage = function (e) {
console.log(JSON.parse(e.data))
}
// 后端
let WebSocket = require('ws')
let wss = new WebSocket.Server({ port: 4000 })
wss.on('connection', function (ws) {
ws.on('message', function (data) {
console.log(JSON.parse(data))
ws.send(JSON.stringify({ username: 'zs', password: 123456 }))
})
})
5. Node 中间件代理(两次跨域)
实现原理:同源策略是浏览器需要遵循的标准,而服务器向服务器请求就无需遵循同源策略。 代理服务器需要做以下几步:
- 接受客户端请求;
- 将请求转发给目标服务器;
- 拿到服务器响应数据;
- 将响应转发给客户端。
// 代理服务器(http://localhost:3000)
const http = require('http')
const server = http.createServer((request, response) => {
// 代理服务器直接和浏览器交互,需要设置 CORS 首部字段
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': 'Content-Type'
})
if (request.method === 'options') {
response.end()
}
// 将请求转发给真正的服务器
const proxyRequest = http.request(
{
host: '127.0.0.1',
port: 4000,
url: '/',
method: request.method,
headers: request.headers
},
serverResponse => {
let body = ''
serverResponse.on('data', chunk => { body += chunk })
serverResponse.on('end', () => {
response.end(body) // 将响应结果转发给浏览器
})
}
).end()
})
server.listen(3000)
三、Nginx 反向代理
Nginx 与 Node.js
Nginx 是一款轻量级的 HTTP 服务器,采用事件驱动的异步非阻塞处理方式框架,具有极好的 IO 性能,时常用于服务端的反向代理与负载均衡。Nginx 更擅长于底层服务端资源的处理(静态资源处理转发、反向代理、负载均衡等),Node.js 更擅长于上层具体业务逻辑的处理,两者可以结合使用。
正向代理与反向代理
正向代理:把浏览器访问外部服务器的请求代理到一个可以访问该网站的代理服务器,代理服务器把内容获取后转发给客户端(典型例子:翻墙工具)。
反向代理:客户端发送的请求被发送到代理服务器,这个代理服务器把请求代理到和自己属于同一个 LAN 下的内部服务器上。向外部客户端提供一个统一的代理入口,至于内网真正访问哪台服务器,由 proxy 控制。代理服务器和真正的服务器可以直接访问、属于一个内网;代理对用户是透明的。
为什么要 Nginx 反向代理
- 安全及权限:用户端无法直接访问真正的内容服务器,必须先经过 Nginx,可以在 Nginx 层过滤危险或无权限的请求;
- 负载均衡:Nginx 将客户端请求"均匀地"分配到集群中的所有服务器(内部模块提供多种负载均衡算法),并且带有健康检查功能,发现异常服务器后不再向其分发请求,保证访问稳定性。
用 Nginx 解决跨域
在众多解决跨域的方式中,都不可避免需要服务端支持,而使用 Nginx 可以纯服务端配置解决跨域问题:
server {
listen 3002;
server_name localhost;
location /ok {
proxy_pass http://localhost:3000;
# 指定允许跨域的方法,* 代表所有
add_header Access-Control-Allow-Methods *;
# 预检命令的缓存,如果不缓存每次会发送两次请求
add_header Access-Control-Max-Age 3600;
# 带 cookie 请求需要加上这个字段,并设置为 true
add_header Access-Control-Allow-Credentials true;
# 表示允许这个域跨域调用(客户端发送请求的域名和端口)
# $http_origin 动态获取请求客户端的域;不用 * 的原因是带 cookie 的请求不支持 * 号
add_header Access-Control-Allow-Origin $http_origin;
# 表示请求头的字段,动态获取
add_header Access-Control-Allow-Headers $http_access_control_request_headers;
# OPTIONS 预检命令,预检命令通过时才发送真正的请求
if ($request_method = OPTIONS) {
return 200;
}
}
}
附:Nginx 访问控制
location / {
deny 192.168.1.100; # 禁止
allow 192.168.1.10/200; # 允许
allow 10.110.50.16;
deny all; # 禁止所有
}
deny 和 allow 是内置模块 ngx_http_access_module 的语法,从上到下匹配,匹配到就跳出不再继续匹配。
总结
- CORS 支持所有类型的 HTTP 请求,是跨域 HTTP 请求的根本解决方法;
- JSONP 只支持 GET 请求,优势在于支持老式浏览器以及可以向不支持 CORS 的网站请求数据;
- Node 中间件代理和 Nginx 反向代理的核心都是利用"同源策略对服务器不加限制";
- 日常工作中,用得最多的跨域方案还是 CORS 和 Nginx 反向代理。