我叫你去重新再验跨域的问题,有什么结果?
大人请转身。
啊!
大人,你看,只有在前端页面上请求接口会出问题,用postman调用都很正常,以我豹头当差三十年的经验来看,一定是前端搞的鬼,所以bug只能在页面上复现,而流不到postman。一般人从外表是看不出来的,依我看分明是出于资深前端之手。
前端,你都听到了?
大人,这次关我的事了。他刚才说,bug全出自资深前端之手,我们常公子根本就不会前端,又怎么可能写bug呢?
他要是不会前端,为什么那天晚上代码上有他的提交记录?
我天生聪慧啊。
是吗?来人。大刑伺候。如果你不写,就夹死你;如果你写,就证明你会前端。
什么是跨域
跨域问题一直是浏览器的专属问题,只要开发前端,这块肯定是绕不开,躲不过的点。要谈起跨域的概念,前端同学肯定是朗朗上口,同协议,同域名,同端口
,一键三连,那还不是章口就莱。
但是咱要讲的还是这跨域从何而来,否则跨域这个锅还不是动不动就甩到咱们头上来,只有了解跨域,掌握跨域,这样咱们才能和后端同学晓之以情,动之以理嘛~
跨域从而何来
现在我们假设这样一个场景,你登录进了银行的网站,然后这时候我通过某些手段在你的窗口弹起了一个小弹窗,美女荷官,在线发牌
,相信正直的你一定会点击这个弹窗,誓与邪恶不同戴天。
这个时候,我通过我的小弹窗网站使用脚本发送请求到你的银行网站执行转账操作,当然不要误会啊,咱不是在这个小网站上学习外语了嘛,这是学习所产生的费用,相信你也很想进步嘛!
那么我们想想,这个操作是不是非常容易,这就是曾经非常著名的CSRF攻击(跨站请求伪造)。与之同名的还有XSS攻击(跨站脚本攻击),这也非常好理解,就是在你的网页里给你注入一些善意的脚本,帮你备份一遍你的相关信息啊之类的。
那么为了解决这些问题,增加安全性,同源策略应运而生了~那么它的基本原则就是上述所说的,同协议,同域名,同端口
,只有当请求的源(协议、域名和端口)与当前页面的源完全相同,浏览器才允许该请求。http和https是不同的协议,example.com和xxx.example.com是不同的域名,example.com:3000 和example.com:3001 是不同的端口。
需要跨域时应该怎么办
尽管同源策略有效的解决了许多安全问题,但是在实际应用中,尤其是前后端分离的现况下,跨域请求也是确实需要的。所以为了在保持安全性的同时也能满足实际需求,W3C引入了跨域资源共享(CORS, Cross-Origin Resource Sharing)机制,通过在 HTTP 响应头中添加特定的字段就允许跨域请求了。
跨域的解决方案
对于一些不常用的解决方案这里就不提了,比如jsonp之类的。
前端
大家在平时开发的时候,不管是vue还是react,基本都是使用webpack-dev-server
来解决前端跨域的问题,它相当于一个小型的开发服务器,我们可以配置代理(proxy)来解决跨域问题,当然在生产环境下我们会有自己实际的服务器,比如Nginx、Apache 或 Node.js 服务器,而这些服务器都有自己的方式来解决跨域问题,所以这也是某种意义上来说,proxy代理在生产环境下不生效的原因,因为没有必要~
// webpack.config.js
module.exports = {
// 其他配置...
devServer: {
proxy: {
'/web': { // /web是前端请求的前缀
target: 'http://example.com:11180/', // 后端服务器的地址
pathRewrite: {
'^/web/': '', // 重写路径,去掉/web前缀
},
changeOrigin: true, // 更改请求的 host 头为目标服务器的 host
onProxyRes(proxyRes, req, res) {
proxyRes.headers['x-real-url2'] =
`http://example.com:11180/${req.url}`
}, // 在响应被发送回客户端之前对响应进行修改,添加一个新的头部 `x-real-url2`
},
},
},
};
那么当我们在前端进行接口请求的时候只需要请求/web + url
就可以了,这时候我们再在浏览器中观察我们的接口,就会发现不会出现跨域问题了,并且请求的响应头里会出现下图的头字段,告诉我们真正代理的地址是什么,因为代理配置允许开发服务器将请求转发到实际的后端服务器,服务器之间是没有同源策略的,而我们本地的请求会变成localhost:前端项目启动的端口号+/web + url,这样自然就满足了上述的三同原则。
后端
后端我们以Express为例:
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
cors 是一个nodejs的包,可以用于提供中间件,当我们按照上述使用后,可以看到在接口的响应头里出现 Access-Control-Allow-Origin: *
,当浏览器接收到响应后,会检查Access-Control-Allow-Origin
头的值和Origin
的值是否匹配,如果匹配,浏览器就会允许前端脚本访问响应的内容,如果不匹配,就会拒绝前端脚本访问并抛出一个跨域错误,当然我们也可以更精细的去控制Access-Control-Allow-Origin
头。
const corsOptions = {
origin: 'http://example.com', // 允许的源
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的请求头
credentials: true // 是否允许发送 cookies
};
app.use(cors(corsOptions));
这里我们需要注意一个点,如果你使用 credentials: true
,即允许发送 cookies,Access-Control-Allow-Origin
不能设置为 *
,在这种情况下,你需要指定具体的源,这在跨域资源共享(CORS)规范中有明确规定。
跨域请求对于后端来说是不是一个完整的请求?
我们可以在Express中写一个简单的log中间件来验证一下:
const myLogger = function (req, res, next) {
const now = new Date().toISOString();
const logMessage = `${now} - ${req.method} - ${req.url}`;
console.log("logMessage==>", logMessage);
console.log("Request Headers:", req.headers);
next();
};
module.exports = myLogger;
当前端发出一个跨域请求后,我们可以在控制台里看看是否有日志打印出来~
从结果来论证,可以看出对于后端来说,跨域请求也是一个完整的请求,服务器能够执行接口代码,只是返回的数据会被浏览器拦截丢弃!
既然对于服务端不管跨域还是不跨域都是一次完整的请求,那么我们就要引出CORS中另外一个概念,就是预检请求
,简单来说,预检请求就是一种特殊的OPTIONS
请求,在实际请求之前发送,用于确认实际请求是否安全。当然,浏览器对于一些简单请求是不会发送预检请求的,会直接发送真实请求。简单请求就是指GET
、HEAD
或 POST
,请求头只包含Accept,Accept-Language,Content-Language,Content-Type(但仅限于 application/x-www-form-urlencoded、multipart/form-data或 text/plain)
。
好了,了解了上面的概念后,我们可以在上述的代码中再增加一条,就是对预检请求的处理:
const corsOptions = {
origin: 'http://frontend.example.com', // 允许的源
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的请求头
credentials: true, // 是否允许发送 cookies
optionsSuccessStatus: 204 // 返回 204 No Content 状态码
};
app.use(cors(corsOptions));
虽然说cors这个库已经帮我们处理好了预检请求,但是我们还是可以自定义一下预检请求成功后返回的状态码,204不返回任何内容更恰当。
Nginx代理
从上文中我们可以知道浏览器其实关心的是头部信息里面有没有Access-Control-Allow-Origin
可以与Origin
的值相匹配,并且这个匹配是全匹配,那么我们就需要在Nginx中对跨域做相应的配置,下面就直接贴入代码和注释:
location / {
root html;
index index.html index.htm;
set $cors_origin '';
if ($http_origin ~* (http://172.18.130.211:10086)) {
set $cors_origin $http_origin;
}
# 允许所有来源的跨域请求
add_header 'Access-Control-Allow-Origin' $cors_origin;
# 允许的请求方法
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# 允许的请求头
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, x-timestamp, x-signature';
# 预检请求的缓存时间
add_header 'Access-Control-Max-Age' 1728000;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, x-timestamp, x-signature';
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8';
return 204;
}
# 代理到后端服务
proxy_pass http://localhost:3003;
# 将客户端请求的主机名传递给后端服务
proxy_set_header Host $host;
# 将客户端的真实 IP 地址传递给后端服务
proxy_set_header X-Real-IP $remote_addr;
# 将客户端的真实 IP 地址以及中间代理的 IP 地址列表传递给后端服务
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 将客户端请求的协议(HTTP 或 HTTPS)传递给后端服务
proxy_set_header X-Forwarded-Proto $scheme;
}
这里我们要注意的是允许的请求方法里需要包含OPTIONS预检请求,同时对于预检请求,我们也需要对其做跨域的相关处理,确保响应体为空,才符合预检请求的标准行为。对于OPTIONS请求最好我们就设置Content-Length为0,否则浏览器可能会尝试读取响应体,即使我们的OPTIONS请求是无任何响应内容的,这样可以避免一些不必要的延迟和错误。
现在我们再看上述的代码,可能会觉得每一项和跨域相关的配置都写入了,应该是没有问题了,但是其中仍然是有bug的~
假设我们的后端请求返回正常,我们看前端是没有任何问题的,跨域问题也解决了,但是假如我们的后端返回一个500 http状态码,这时候就会发现一些异常了,我们在前端无法查看到接口的响应内容,这就有点尴尬了,所以我们还需要做以下改动:
# 添加 CORS 头
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, x-timestamp, x-signature' always;
和上述的改动就在于增加了一个always
关键词,确保即使在错误响应中也需要包含 CORS 头。
后端跨域和nginx代理之间可能出现的问题
在平时开发的时候,我们一般很少会遇到这种冲突问题,按照上述的nginx配置一下或者后端处理一下就好了。但是当你既配置了nginx代理,又在后端处理了跨域后,就会发生一些奇妙的化学反应~
呐,神奇的跨域问题又出现了~我们可以在nginx的代理服务里看一下,会发现出现了多个重复的请求头:
虽然我们目前还不知道是什么问题,但是当出现多个相同的请求头的时候,明显感觉应该是有问题的。在查询了HTTP1.1规范后,找到了问题所在:
在
HTTP1.1 RFC7230
规范中有明确提到,发件人不得在消息中生成具有相同字段名称的多个标头字段,除非使用逗号分隔开~
谁能想到,既在后端配置了跨域,又使用nginx代理配置了跨域,也会导致跨域,emmm~