还记得我刚开始和后端同学合作开发时,每次解决跨域问题都是一段痛苦的经历,经常有这样的对话

我:在,CORS开了吗
对方:没呢,这就开
…几个小时后
我:开了吗?
对方:开了开了
我:为什么我这里不行的?
…几个小时后
对方:你再试试
我:还是不行??
….
我:好了好了大佬nb

当时的我不过后来这样的事情也渐渐莫得了,应该是后端同学终于成为了后端大佬吧

再后来,我学了nodejs,发现这玩意还有点复杂,所以整篇博客记录一下,不然万一明年师妹来问我CORS我不会那多尴尬(假装有师妹)

CORS

为什么要有CORS

要知道为什么要有CORS,我们需要了解什么是同源策略。所谓的同源策略是一种安全机制,为了预防某些恶意行为(例如 Cookie 窃取等),浏览器限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。而满足同源要具备三方面:协议相同、域名相同、端口相同。

如果对http://domain.com:80/dir/index.html 来进行同源判断

  1. http://domain.com/dir2/info.html(同源)
  2. https://domain.com/dir/index.html(非同源,协议不相同)
  3. http://www.domain.com/dir/index.html(非同源,域名不同)
  4. http://domain.com:233/dir/index.html(非同源,端口不同)

而浏览器规定了,不同源的某些资源间是不可见的,有哪些呢

要求同源的地方:

  • Ajax通信
  • Cookie
  • LocalStorage/SessionStorage/IndexDB
  • DOM操作

这个时候,如果域名为www.a.com 站点的要向域名为www.b.com 的服务器发Ajax请求,很不幸地,即使请求能到达服务器,服务器也做出了响应,但浏览器把响应拦截了,所以a站点的JS拿不到服务器响应的结果,所以我们要有CORS,来告诉浏览器,不要拦截我的资源。

什么是CORS

CORS是一种跨域解决方案,它的全称是Cross-Origin Resource Sharing,翻译过来是跨域资源共享。它的原理画张图来表示是这样的

file

CORS主要是后端设置问题,只要服务器做好了配置,浏览器和服务器就会自动完成这个过程。要知道,一个请求可以附带很多信息,而针对携带的信息的多少和分类,CORS规定了三种不同的交互模式,分别是

  • 简单请求
  • 需要预检的请求
  • 携带cookie的请求

这三种模式从上到下层层递进,请求可以做的事越来越多,要求也越来越严格。

下面分别说明三种请求模式的具体规范。

简单请求

当请求同时满足以下条件时,浏览器会认为它是一个简单请求:

  1. 请求方法属于下面的一种:

    • get
    • post
    • head
  2. 请求头仅包含安全的字段,常见的安全字段如下:

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  3. 请求头如果包含Content-Type,仅限下面的值之一

    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

如果以上三个条件同时满足,浏览器判定为简单请求。

当发出一个跨域的简单请求时,请求头中会自动附带上Origin字段,比如说,如果你在http://localhost:8080/index.html 里向 http://localhost:5000 这个服务器发送请求,那么你的请求头就会是这样的

1
2
3
4
5
6
GET /api/news/ HTTP/1.1
Host: crossdomain.com
Connection: keep-alive
...
Referer: http://localhost:8080/index.html
Origin: http://localhost:8080

这个Origin字段会告诉服务器,是哪个地址在做跨域请求

当服务器收到请求后,如果允许该请求跨域访问,需要在响应头中添加Access-Control-Allow-Origin字段

该字段的值可以是:

  • 一个 * 号 :表示我很开放,什么人我都允许访问
  • 具体的源 :比如http://baidu.com , 表示服务器允许来自 http://baidu.com 的跨域请求

我们搭建起一个简单的express服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const express = require("express");
const app = express();
const port = 5000;

// 处理CORS的中间件
app.use(require("./corsMW.js"));
app.get("/data", (req, res) => {
res.send({
code : 0
});
})

app.post("/data", (req, res) => {
res.send({
code : 0
})
})

app.listen(port, () => {
console.log(`server listen on ${port}`);
});

为了方便阅读,我们把CORS的代码全部抽离到成一个中间件中

corsMW.js

1
2
3
4
5
6
7
8
let set = new Set(["http://localhost:9000"])
module.exports = function (req, res, next) {
// 处理简单请求
if ("origin" in req.headers && set.has(req.headers.origin)) {
res.header("access-control-allow-origin", req.headers.origin);
}
next();
}

http://localhost:9000

1
2
3
4
5
<script>
axios.get("http://localhost:5000/data").then((res) => {
console.log(res);
});
</script>

请求成功
file

非简单请求

当浏览器认为发出的不是一个跨域的简单请求时,就会进行更复杂的流程

  1. 浏览器发送预检请求,询问服务器是否允许
  2. 服务器允许
  3. 浏览器发送真实请求
  4. 服务器完成真实的响应

比如说,我们有下面的代码

1
2
3
4
5
6
7
8
9
10
11
<script>
axios.post("http://localhost:5000/data", {
name : 'sena'
}, {
headers : {
"content-type" : "application/json",
}
}).then((res) => {
console.log(res);
})
</script>

预检请求

首先,浏览器会发送一个预检请求,用于询问服务器是否允许后续的真实请求。

1
2
3
4
5
Host: localhost:5000
Origin: http://localhost:9000
...
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: POST

预检请求有以下特征:

  • 请求方法为OPTIONS
  • 没有请求体
  • 请求头中包含
    • Origin:请求的源,和简单请求的含义一致
    • Access-Control-Request-Method:后续的真实请求将使用的请求方法
    • Access-Control-Request-Headers:后续的真实请求中自定义的请求头

服务器允许后续请求

如果服务器允许后续请求,需要响应下面的消息格式

1
2
3
4
5
6
7
HTTP/1.1 200 OK
...
Access-Control-Allow-Origin: http://localhost:9000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 86400

对于预检请求,不需要响应任何的消息体,只需要在响应头中添加下面几个参数即可

  • Access-Control-Allow-Origin:和简单请求一样,表示允许的源
  • Access-Control-Allow-Methods:表示允许的后续真实的请求方法
  • Access-Control-Allow-Headers:表示允许自定义的请求头
  • Access-Control-Max-Age:告诉浏览器,多少秒内,对于同样的请求源、方法、头,都不需要再发送预检请求了

浏览器发送真实请求

预检请求被服务器允许后,浏览器会发送真实请求

1
2
3
4
5
6
7
8
POST /data HTTP/1.1
Host: localhost:5000
Connection: keep-alive
...
Referer: http://localhost:9000
Origin: http://localhost:9000

{"name": "sena"}

服务器响应真实数据

1
2
3
4
HTTP/1.1 200 OK
...
Access-Control-Allow-Origin: http://localhost:9000

可以看出,在完成预检后,后续的请求和简单请求相同

中间件写法

我们对之前在简单请求中使用的中间件改造一下就能搞定这种情况了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let set = new Set(["http://localhost:9000"])
module.exports = function (req, res, next) {
// 处理预检请求
if (req.method.toLowerCase() === "options") {
res.header(
`Access-Control-Allow-Methods`,
req.headers["access-control-request-method"]
);
res.header(
`Access-Control-Allow-Headers`,
req.headers["access-control-request-headers"]
);
}
// 处理简单请求
if ("origin" in req.headers && set.has(req.headers.origin)) {
res.header("access-control-allow-origin", req.headers.origin);
}
next();
}

搞定~

file

携带Cookie的请求

如果有下面的代码

1
2
3
4
5
6
7
8
9
10
<script>
axios.post("http://localhost:5000/data", {}, {
headers : {
"content-type" : "application/json",
},
withCredentials: true
}).then((res) => {
console.log(res);
})
</script>

这段代码在执行时会携带cookie到服务器(默认跨域是不携带的)。

当一个请求需要附带cookie时,无论它是简单请求,还是预检请求,都会在请求头中添加cookie字段,而服务器响应时,需要明确告知客户端:服务器允许这样的凭据。

告知的方式也非常的简单,只需要在响应头中添加:Access-Control-Allow-Credentials: true即可。

对于一个附带身份凭证的请求,若服务器没有明确告知,浏览器仍然视为跨域被拒绝。

另外要特别注意的是:对于附带cookie的请求,服务器不得设置Access-Control-Allow-Origin的值为*,否则仍然会报错

再次修改我们的中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let set = new Set(["http://localhost:9000"])
module.exports = function (req, res, next) {
// 预检请求
if (req.method.toLowerCase() === "options") {
res.header(
`Access-Control-Allow-Methods`,
req.headers["access-control-request-method"]
);
res.header(
`Access-Control-Allow-Headers`,
req.headers["access-control-request-headers"]
);
}
// 简单请求
if ("origin" in req.headers && set.has(req.headers.origin)) {
res.header("access-control-allow-origin", req.headers.origin);
}
// 告诉浏览器允许跨域携带cookie
res.header("Access-Control-Allow-Credentials", true);
next();
}

补充

在跨域访问时,JS只能拿到一些最基本的响应头,如:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要访问其他头,则需要服务器设置d对应的响应头。

Access-Control-Expose-Headers头让服务器把允许浏览器访问的头放入白名单,例如:

Access-Control-Expose-Headers: authorization

这样JS就能够访问指定的响应头了。