前言
前端跨域这个问题,一直是我的"雷区"。 每次开发时碰到它,我都直接求助搜索引擎,从没静下心想过它背后的原因,也没想过怎么真正解决它、或者提前避开。 总是靠临时的搜索结果应付,而不是从根上把它搞明白。
同源策略
在了解跨域问题之前,首先需要了解浏览器的同源策略。
同源策略是浏览器为保障用户信息安全设下的一道防线。所谓同源,就是协议、域名和端口三者完全相同。 例如,example.com:8080 和 example.com:80 就不是同源,因为端口不同;example.com 和 sub.example.com 也不是同源,因为域名不同。
同源策略的存在,目的是防止一个网站的恶意脚本获取或修改另一个网站的资源。 如果没有同源策略的限制,恶意网站可能会窃取用户在其他网站上的敏感信息,如登录凭证、个人资料等,从而给用户带来巨大的安全风险。
跨域引发的问题
跨域问题的根源,就是触碰了浏览器的同源策略。前后端分离时,前端和后端通常不在同一台服务器上,域名或端口也往往对不上——而同源的定义,正是协议、域名、端口三者都相同。
举个例子,拿本站的源来做对比,不同 URL 的不同结果如下表
| 源 URL | 请求 URL | 是否跨域 | 说明 |
|---|---|---|---|
| https://hsinyau.com/posts | https://hsinyau.com/guestbook | 否 | 同协议同域名同端口号,不同请求,不算跨域请求 |
| https://hsinyau.com/posts | https://hsinyau.com:2333/guestbook | 是 | 端口不同 |
| https://hsinyau.com/posts | http://hsinyau.com/guestbook | 是 | 协议不同 |
| https://hsinyau.com/posts | https://api.hsinyau.com/api | 是 | 主域名相同,但是子域名不相同 |
| https://hsinyau.com/posts | https://example.cc/posts | 是 | 域名不相同 |
解决方案
目前主流的解决办法基本都是基于 CORS(跨源资源共享)来实现的。
在对 CORS 展开讨论之前,得先搞清楚 HTTP 中的简单请求和非简单请求。简单请求包括 GET、HEAD、POST 这几种,同时它们的 HTTP 头信息不能超出以下这些:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type(application/x-www-form-urlencoded、multipart/form-data、text/plain)
不符合以上这些条件的就属于复杂请求。
CORS
CORS(Cross-Origin Resource Sharing,跨源资源共享)就是用来解决跨域资源访问的机制:服务器可以明确声明哪些源(Origin)能访问它的资源。
当浏览器发起跨域请求时,会先发送一个“预检”请求(OPTIONS 请求)。这个预检请求会携带一些关于即将发送的实际请求的信息,如请求方法(GET、POST 等)、自定义请求头等。 服务器接收到这个预检请求后,会检查这些信息,并通过在响应头中设置相应的字段来告知浏览器是否允许这个跨域请求。
常见的 CORS 响应头包括:
- Access-Control-Allow-Origin:指定允许访问该资源的源。可以是具体的域名,如 http://example.com, 也可以是通配符 *,表示允许任何源访问,但这种方式存在一定的安全风险,应谨慎使用。
- Access-Control-Allow-Methods:指定允许的请求方法,如 GET、POST、PUT 等。
- Access-Control-Allow-Headers:指定允许的自定义请求头。
下面是一个服务端和客户端完整的信息交互。首次交互是预检请求 / 响应:
OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
从上面的报文可以看到,首先用 OPTIONS 方法发送了预检请求。浏览器会根据实际请求用到的参数来决定要不要发,这样服务器就能回应是否接受用这套参数发请求。
发送预检请求时一般会带有以下头部信息
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
标头字段 Access-Control-Request-Method 告知服务器:实际请求将使用 POST 方法。 标头字段 Access-Control-Request-Headers 告知服务器:实际请求会携带两个自定义标头 X-PINGOTHER 与 Content-Type。服务器据此决定要不要放行这个实际请求。
第二个是预检请求的响应,表明服务器接受后续实际请求的方法(POST)和请求头(X-PINGOTHER)。
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
响应里的 Access-Control-Allow-Origin: https://foo.example 限定了请求的源域;Access-Control-Allow-Methods 表明服务器允许客户端用 POST 和 GET 发起请求(作用类似 Allow 响应标头,但带严格的访问控制)。
标头字段 Access-Control-Allow-Headers 表明服务器允许请求携带 X-PINGOTHER 与 Content-Type。和 Access-Control-Allow-Methods 一样,它的值是用逗号分割的列表。
最后,标头字段 Access-Control-Max-Age 给出这个预检请求的缓存时长,单位是秒,默认值 5 秒。有效时间内,浏览器不必为同一个请求再发预检。上面例子里有效期是 86400 秒,也就是 24 小时。如果该字段的值超过了最大有效时间,就不会生效。
了解了背后的原理,问题其实已经解决了一半。
前端代理
在开发环境中,我们可以使用 webpack-dev-server 或 vue-cli 提供的代理配置功能,将跨域请求转发到实际的服务器。
module.exports = {
// ...
devServer: {
proxy: {
"/api": {
target: "https://hsinyau.com/api",
pathRewrite: { "^/api": "" },
},
},
},
};
服务端配置
这里以 Express 为例
// 允许跨域
app.all("*", (req, res, next) => {
res.header("Access-Control-Allow-Origin", req.headers.origin);
// res.header("Access-Control-Allow-Origin", '*');
res.header(
"Access-Control-Allow-Headers",
"Content-Type,Content-Length, Authorization, Accept,X-Requested-With",
);
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
res.header("Access-Control-Allow-Credentials", "true");
res.header("X-Powered-By", " 3.2.1");
if (req.method === "OPTIONS") res.send(200); /* 让options请求快速返回 */
else next();
});
通过服务器配置
以 Nginx 为例
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
if ($request_method = 'OPTIONS') {
return 204;
}
}
反向代理
以 Nginx 为例
server {
listen 80;
server_name www.hsinyau.com;
location / {
proxy_pass www.hsinyau.com; #反向代理
proxy_cookie_demo www.hsinyau.com www.hsinyau.com;
add_header Access-Control-Allow-Origin www.hsinyau.com;
add_header Access-Control-Allow-Credentials true;
}
}
WebSocket
WebSocket 是一种全双工通信协议,不受同源策略限制。
用它建立一个持久连接就能做实时数据通信。不过它的适用场景比较特定,主要是需要实时双向通信的场景,比如在线聊天、实时游戏。