CORS原理及实现

CORS跨域的原理

跨域资源共享(CORS)是一种机制,是W3C标准。它允许浏览器向跨源服务器,发出XMLHttpRequestFetch请求。并且整个CORS通信过程都是浏览器自动完成的,不需要用户参与。

而使用这种跨域资源共享的前提是,浏览器必须支持这个功能,并且服务器端也必须同意这种"跨域"请求。因此实现CORS的关键是服务器需要服务器。通常是有以下几个配置:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials
  • Access-Control-Max-Age

具体可看:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS#Preflighted_requests

过程分析:

  • 浏览器先根据同源策略对前端页面和后台交互地址做匹配,若同源,则直接发送数据请求;若不同源,则发送跨域请求。

  • 服务器收到浏览器跨域请求后,根据自身配置返回对应文件头。若未配置过任何允许跨域,则文件头里不包含 Access-Control-Allow-origin 字段,若配置过域名,则返回 Access-Control-Allow-origin + 对应配置规则里的域名的方式

  • 浏览器根据接受到的 响应头里的 Access-Control-Allow-origin 字段做匹配,若无该字段,说明不允许跨域,从而抛出一个错误;若有该字段,则对字段内容和当前域名做比对,如果同源,则说明可以跨域,浏览器接受该响应;若不同源,则说明该域名不可跨域,浏览器不接受该响应,并抛出一个错误。

另外在CORS中有简单请求非简单请求,简单请求是不会触发CORS的预检请求的,而非简单请求会。

“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

CORS的哪些是简单请求?

简单请求不会触发CORS的预检请求,若请求满足所有下述条件,则该请求可视为“简单请求”:

简单回答

  • 只能使用GETHEADPOST方法。使用POST方法向服务器发送数据时,Content-Type只能使用application/x-www-form-urlencodedmultipart/form-datatext/plain编码格式。
  • 请求时不能使用自定义的HTTP Headers

详细回答

  • (一) 使用下列方法之一

    • GET
    • HEAD
    • POST
  • (二) 只能设置以下集合中的请求头

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(但是有限制)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  • (三) Content-Type的值仅限于下面的三者之一

    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
  • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

  • 请求中没有使用 ReadableStream 对象。

除了上面这些请求外,都是非简单请求。

CORS的预检请求具体是怎样的?

若是跨域的非简单请求的话,浏览器会首先向服务器发送一个预检请求,以获知服务器是否允许该实际请求。

整个过程大概是:

  • 浏览器给服务器发送一个OPTIONS方法的请求,该请求会携带下面两个首部字段:
    • Access-Control-Request-Method: 实际请求要用到的方法
    • Access-Control-Request-Headers: 实际请求会携带哪些首部字段
  • 若是服务器接受后续请求,则这次预请求的响应体中会携带下面的一些字段:
    • Access-Control-Allow-Methods: 服务器允许使用的方法
    • Access-Control-Allow-Origin: 服务器允许访问的域名
    • Access-Control-Allow-Headers: 服务器允许的首部字段
    • Access-Control-Max-Age: 该响应的有效时间(s),在有效时间内浏览器无需再为同一个请求发送预检请求
  • 预检请求完毕之后,再发送实际请求

这里有两点要注意:

一:

Access-Control-Request-Method没有s

Access-Control-Allow-Methodss

二:

关于Access-Control-Max-Age,浏览器自身也有维护一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效,而是以最大有效时间为主。

CORS简单请求的案例

还是在原本JSONP的那个案例上。

我在根目录下新建了一个文件夹cors,并往里面添加了一个index.html文件:

/cors/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CORS</title>
</head>
<body>
  <button id="getName">获取name</button>
  <script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
  <script>
    getName.onclick = () => {
      // 简单请求
      axios.get("http://127.0.0.1:8080/api/corsname");
    }
  </script>
</body>
</html>

为了后面也方便调试,用node简单写了一个前端的本地服务和后端的本地服务。

在根目录下新建client.js文件,并写入:

./client.js:

const Koa = require('koa');
const fs = require('fs');
const app = new Koa();

app.use(async (ctx) => {
  if (ctx.method === 'GET' && ctx.path === '/') {
    ctx.body = fs.readFileSync('./index.html').toString();
  }
  if (ctx.method === 'GET' && ctx.path === '/cors') {
    ctx.body = fs.readFileSync('./cors/index.html').toString();
  }
})
console.log('client 8000...')
app.listen(8000);

在根目录下新建index.html文件,并写入:

./index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Client</title>
</head>
<body>
  <ul>
    <li>
      <a href="/cors">CORS跨域</a>
    </li>
  </ul>
</body>
</html>

(以上:实现了一个简单的前端路由效果)

在根目录下新建server.js文件,并写入:

./server.js:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", ctx.header.origin);
  // ctx.set("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
  // ctx.set("Access-Control-Request-Method", "PUT,POST,GET,DELETE,OPTIONS");
  ctx.set(
    "Access-Control-Allow-Headers", 
    "Origin, X-Requested-With, Content-Type, Access, cc"
  )
  if (ctx.method === 'OPTIONS') {
    ctx.status = 204;
    return;
  }
  await next();
  if (ctx.path === '/api/corsname') {
    ctx.body = {
      data: 'LinDaiDai'
    }
    return;
  }
})
console.log('server 8080...')
app.listen(8080);

并给package.json中配置两个启动指令:

package.json:

{
  "scripts": {
    "client": "node ./client.js",
    "server": "node ./server.js"
    }
}

OK👌,来分别启动一下npm run clientnpm run server

并打开页面的127.0.0.1:8000/cors(或者打开127.0.0.1:8000然后点击CORS这个a标签)

点击获取name按钮,可以看到能够正常获取到本地服务器的数据了。

CORS非简单请求的案例

接着让我们来改造一下./cors/index.html中的按钮点击请求,让它变成一个非简单请求:

./cors/index.html:

getName.onclick = () => {
  // 简单请求
  // axios.get("http://127.0.0.1:8080/api/corsname");

  // 非简单请求
  axios.get('http://127.0.0.1:8080/api/corsname', {
    headers: {
      cc: 'lindaidai'
    }
  })
}

此时,打开页面点击按钮会发现发送了两次corsname的请求:

(一)预检请求:

cors1.png

(二)实际请求:

cors2.png

CORS附带身份凭证的案例

对于跨域 XMLHttpRequestFetch 请求,浏览器不会发送身份凭证信息。如果要发送凭证信息,需要设置 XMLHttpRequest的某个特殊标志位。

例如我们想要在跨域请求中带上cookie,需要满足3个条件:

  • web(浏览器)请求设置withCredentialstrue
  • 服务器设置首部字段Access-Control-Allow-Credentialstrue
  • 服务器的Access-Control-Allow-Origin不能为*

所以为了模拟这个效果,让我们来写一个小小的登录+获取数据的功能吧。

首先对于web端,我新增了一个登录按钮,并且配置了一下axios

./cors/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CORS</title>
</head>
<body>
  <button id="getName">获取name</button>
  <button id="login">登录</button>
  <script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
  <script>
    axios.defaults.withCredentials = true;
    axios.defaults.baseURL = 'http://127.0.0.1:8080'
    login.onclick = () => {
      axios.post('/api/login')
    }
    getName.onclick = () => {
      axios.get('/api/corsname').then(res => console.log(res.data))
    }
  </script>
</body>
</html>

接着为了更方便的模拟后台请求,我需要在项目中安装两个中间件:

cnpm i --save-dev koa-router koa-body

接着修改一下server.js的后台配置:

./server.js:

const Koa = require("koa");
const router = require("koa-router")();
const koaBody = require("koa-body");
const app = new Koa();
const TOKEN = "112233"; // 模拟写死一个token

app.use(async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", ctx.header.origin);
  // ctx.set("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
  ctx.set("Access-Control-Request-Method", "PUT,POST,GET,DELETE,OPTIONS");
  ctx.set(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Access, cc"
  );
  ctx.set("Access-Control-Allow-Credentials", true); // 这步很重要
  if (ctx.method === "OPTIONS") {
    ctx.status = 204;
    return;
  }
  await next();
});
app.use(async (ctx, next) => {
  // 若是登录接口则跳过后面的token验证
  if (ctx.path === "/api/login") {
    await next();
    return;
  }
  // 对所有非登录的请求验证token
  const cookies = ctx.cookies.get("token");
  console.log(cookies);
  if (cookies && cookies === TOKEN) {
    await next();
    return;
  }
  ctx.body = {
    code: 401,
    msg: "权限错误",
  };
  return;
});
// 如果不加multipart:true ctx.request.body会获取不到值
app.use(koaBody({ multipart: true }));

router.get("/api/corsname", async (ctx) => {
  ctx.body = {
    data: "LinDaiDai",
  };
});

router.post("/api/login", async (ctx) => {
  ctx.cookies.set("token", TOKEN, {
    expires: new Date(+new Date() + 1000 * 60 * 60 * 24 * 7),
  });
  ctx.body = {
    msg: "成功",
    code: 0,
  };
});

app.use(router.routes());
console.log("server 8080...");
app.listen(8080);

现在让我们重启一下服务,然后打开页面看看效果:

(一)点击登录:

cors3.png

(二)点击获取name:

cors4.png

(三)查看cookie:

cors5.png

如何减少CORS预请求的次数?

方案一:发出简单请求(这不是废话吗...)

方案二:服务端设置Access-Control-Max-Age字段,在有效时间内浏览器无需再为同一个请求发送预检请求。但是它有局限性:只能为同一个请求缓存,无法针对整个域或者模糊匹配 URL 做缓存。

参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352