CORS — 詳解 & 實(shí)戰(zhàn)

WEB開(kāi)發(fā)們都知道,出于安全原因,瀏覽器有個(gè)同源策略,不同源的客戶端腳本在沒(méi)有明確授權(quán)的情況下,不能讀寫(xiě)對(duì)方資源。一個(gè)HTTP請(qǐng)求的URL的協(xié)議、域名、端口三者中的任何一個(gè)與當(dāng)前源不同,則視為跨域請(qǐng)求。如果不做處理,我們看到chrome拋出一個(gè)錯(cuò)誤:


cross-origin-request-error

而在實(shí)際的場(chǎng)景中,我們會(huì)有很多情況下需要進(jìn)行跨域請(qǐng)求,所以跨域解決方案和其原理幾乎是WEB開(kāi)發(fā)必須要掌握的知識(shí)。下面列舉幾種常見(jiàn)的跨域方案:

跨域的幾種解決方案

  • JSONP:這是跨域請(qǐng)求的一個(gè)經(jīng)典方案,其主要原理是通過(guò)JS動(dòng)態(tài)創(chuàng)建<script>標(biāo)簽獲取指定資源,然后前后端約定一個(gè)callback來(lái)獲取json數(shù)據(jù),<script>、<iframe>這些具有src屬性的標(biāo)簽都是可直接跨域獲取資源的,這種方式其實(shí)只是巧妙地繞過(guò)跨域限制,而且有其局限性,比如很明顯的,只能發(fā)送GET請(qǐng)求,而且要判斷請(qǐng)求是否失敗也比較棘手。
  • Proxy代理:由于同源策略只是瀏覽器的限制,服務(wù)器端并沒(méi)有這個(gè)限制,所以只要A域客戶端將請(qǐng)求發(fā)送一個(gè)代理服務(wù)器,然后由代理服務(wù)器去請(qǐng)求B域服務(wù)器就行了,比如前后端分離的工程,本地調(diào)試的時(shí)候我們啟用nodejs代理服務(wù)、線上部署通過(guò)nginx代理轉(zhuǎn)發(fā)等,都屬于這個(gè)跨域模式。同樣的,這個(gè)本質(zhì)上也只是繞過(guò)瀏覽器的跨域限制而已。
  • CORS(Cross-Origin Resource Sharing):跨域資源共享標(biāo)準(zhǔn),本文重點(diǎn)研究對(duì)象。

CORS初嘗試

假設(shè)現(xiàn)在服務(wù)端有個(gè)獲取股票列表的接口,并已設(shè)置允許跨域(后文將介紹如何設(shè)置),其中
客戶端地址:http://localhost:3000
服務(wù)端地址:http://localhost:7001

頁(yè)面上設(shè)置了個(gè)按鈕用以獲取股票列表:


獲取股票列表的前端代碼:

axios({
  url: 'http://localhost:7001/api/getStocks',
}).then((res) => {
  const data = res.data;
  this.setState((prevState) => ({
    list: prevState.list.concat(data.data),
  }));
});

此時(shí)發(fā)送的請(qǐng)求狀態(tài)為:


可以看到請(qǐng)求直接成功并返回了數(shù)據(jù),乍看之下除了Response Headers多了一些Access-Control-Allow-*字段外,和普通請(qǐng)求沒(méi)什么區(qū)別。
過(guò)了段時(shí)間,出于安全角度考慮,現(xiàn)在要對(duì)這個(gè)接口進(jìn)行token驗(yàn)證,,所以增加了一個(gè)請(qǐng)求頭字段 access-token

axios({
  url: 'http://localhost:7001/api/getCounts',
  headers: {
    'access-token': 'abcdefg',
  },
}).then((res) => {
  const data = res.data;
  this.setState({
    count: data.data,
  });
});

這時(shí)再查看請(qǐng)求的發(fā)送情況,奇怪的事情出現(xiàn)了,現(xiàn)在瀏覽器竟然發(fā)出去了兩個(gè)請(qǐng)求!查看之后,會(huì)發(fā)現(xiàn)第一個(gè)請(qǐng)求方法為OPTIONS,狀態(tài)碼為204,什么數(shù)據(jù)都沒(méi)有返回!第二個(gè)請(qǐng)求才是我們真正想要的請(qǐng)求,GET請(qǐng)求,且狀態(tài)碼為200,將股票列表返回了:

第一個(gè)請(qǐng)求

第二個(gè)請(qǐng)求

所以第一個(gè)OPTIONS請(qǐng)求是什么?為什么會(huì)發(fā)送這個(gè)請(qǐng)求?

CORS工作原理

CORS新增了一組 HTTP 首部字段,允許服務(wù)器聲明哪些源站有權(quán)限訪問(wèn)哪些資源。另外,規(guī)范要求,對(duì)那些可能對(duì)服務(wù)器數(shù)據(jù)產(chǎn)生副作用的 HTTP 請(qǐng)求,瀏覽器必須首先使用 OPTIONS 方法發(fā)起一個(gè)預(yù)檢請(qǐng)求(preflight request),從而獲知服務(wù)端是否允許該跨域請(qǐng)求。預(yù)檢請(qǐng)求頭中Access-Control-Request-Method字段告訴服務(wù)器實(shí)際請(qǐng)求的方法,Access-Control-Request-Headers字段告知服務(wù)器實(shí)際請(qǐng)求中需要攜帶的自定義參數(shù)。服務(wù)器確認(rèn)允許之后,才發(fā)起實(shí)際的 HTTP 請(qǐng)求。在預(yù)檢請(qǐng)求的返回中,服務(wù)器端也可以通知客戶端,是否需要攜帶身份憑證(包括 Cookies 和 HTTP 認(rèn)證相關(guān)數(shù)據(jù))。

簡(jiǎn)單請(qǐng)求和非簡(jiǎn)單請(qǐng)求

一般把無(wú)需發(fā)送OPTIONS的請(qǐng)求叫做簡(jiǎn)單請(qǐng)求,把需要發(fā)送OPTIONS的請(qǐng)求稱(chēng)為非簡(jiǎn)單請(qǐng)求復(fù)雜請(qǐng)求

其中簡(jiǎn)單請(qǐng)求必須滿足以下幾個(gè)條件(不滿足所有下面條件的即為非簡(jiǎn)單請(qǐng)求):

  1. 請(qǐng)求方式只限于 GET、 HEAD、POST;
  2. 除以下頭部信息外,不能自定義其他請(qǐng)求頭字段 :
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(需要注意額外的限制)
    • Last-Event-ID
  3. Content-Type 的值只限于以下三種:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

附帶身份憑證的請(qǐng)求

CORS (還有Fetch )的一個(gè)有趣特性是,可以基于 HTTP cookies 和 HTTP 認(rèn)證信息發(fā)送身份憑證。一般而言,對(duì)于跨域 XMLHttpRequestFetch請(qǐng)求,瀏覽器不會(huì)發(fā)送身份憑證信息。如果要發(fā)送憑證信息,需要設(shè)置某個(gè)特殊標(biāo)志位,例如我們的代碼 axios 中可以加入withCredentials字段表示跨域請(qǐng)求時(shí)需要攜帶憑證:

axios({
  url: 'http://localhost:7001/api/getStocks',
  withCredentials: true, // 設(shè)置攜帶憑證
}).then((res) => {
  const data = res.data;
  this.setState((prevState) => ({
    list: prevState.list.concat(data.data),
  }));
});

此時(shí)我們發(fā)送一個(gè)簡(jiǎn)單請(qǐng)求會(huì)發(fā)現(xiàn)一個(gè)奇怪的事情:



明明請(qǐng)求已經(jīng)返回了數(shù)據(jù),但是頁(yè)面上并沒(méi)有渲染出來(lái),事實(shí)上此時(shí)Chrome瀏覽器已經(jīng)在控制臺(tái)出現(xiàn)了報(bào)錯(cuò)信息:


這是因?yàn)槿绻缬蛘?qǐng)求想要附帶身份憑證,必須在服務(wù)端設(shè)置Access-Control-Allow-Credentialstrue,否則瀏覽器將不會(huì)把響應(yīng)內(nèi)容返回給請(qǐng)求的發(fā)送者。
另外,對(duì)于附帶身份憑證的請(qǐng)求,服務(wù)器不得設(shè)置Access-Control-Allow-Origin的值為*

CORS響應(yīng)頭字段

注:以下例子為NodeJs中Egg框架的設(shè)置方法(事實(shí)上,Egg框架中你會(huì)選擇egg-cors插件進(jìn)行跨域設(shè)置),不同語(yǔ)言和框架請(qǐng)參照各自的文檔。

1. Access-Control-Allow-Origin

語(yǔ)法為:Access-Control-Allow-Origin: <origin> | *,其中origin參數(shù)的值指定了允許訪問(wèn)該資源的外域 URI,如果跨域請(qǐng)求中攜帶了cookie,則不能指定其值為*。如:

ctx.set('Access-Control-Allow-Origin', 'http://localhost:3000');
2. Access-Control-Allow-Methods

語(yǔ)法為:Access-Control-Allow-Methods: <method>[, <method>]*,用于預(yù)檢請(qǐng)求的響應(yīng)。其指明了實(shí)際請(qǐng)求所允許使用的 HTTP 方法。如:

ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS, DELETE');
3. Access-Control-Allow-Headers

語(yǔ)法為:Access-Control-Allow-Headers: <field-name>[, <field-name>]*,用于預(yù)檢請(qǐng)求的響應(yīng)。其指明了實(shí)際請(qǐng)求中允許攜帶的首部字段。如:

ctx.set('Access-Control-Allow-Headers', 'Content-Type, access-token');
4. Access-Control-Allow-Credentials

指定了當(dāng)瀏覽器的credentials設(shè)置為true時(shí)是否允許瀏覽器讀取response的內(nèi)容。當(dāng)用在對(duì)preflight預(yù)檢請(qǐng)求的響應(yīng)中時(shí),它指定了實(shí)際的請(qǐng)求是否可以使用credentials。請(qǐng)注意:簡(jiǎn)單GET請(qǐng)求不會(huì)被預(yù)檢;如果對(duì)此類(lèi)請(qǐng)求的響應(yīng)中不包含該字段,這個(gè)響應(yīng)將被忽略掉,并且瀏覽器也不會(huì)將相應(yīng)內(nèi)容返回給網(wǎng)頁(yè)。
如:

ctx.set('Access-Control-Allow-Credentials', true);
5. Access-Control-Expose-Headers

在跨域訪問(wèn)時(shí),XMLHttpRequest對(duì)象的getResponseHeader()方法只能拿到一些最基本的響應(yīng)頭:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果要訪問(wèn)其他頭,則需要服務(wù)器設(shè)置本響應(yīng)頭。如:

ctx.set('Access-Control-Expose-Headers', 'access-token');
6. Access-Control-Max-Age

語(yǔ)法為:Access-Control-Max-Age: <delta-seconds>,指定了preflight請(qǐng)求的結(jié)果能夠被緩存多久(單位:秒)。在有效時(shí)間內(nèi),瀏覽器無(wú)須為同一請(qǐng)求再次發(fā)起預(yù)檢請(qǐng)求。請(qǐng)注意,瀏覽器自身維護(hù)了一個(gè)最大有效時(shí)間,如果該首部字段的值超過(guò)了最大有效時(shí)間,將不會(huì)生效。如:

ctx.set('Access-Control-Max-Age', 86400);  // 86400秒內(nèi),即24小時(shí)內(nèi)都有效

CORS請(qǐng)求頭字段

1. Origin

origin 參數(shù)的值為源站 URI。它不包含任何路徑信息,只是服務(wù)器名稱(chēng)。

2. Access-Control-Request-Method

用于預(yù)檢請(qǐng)求。其作用是,將實(shí)際請(qǐng)求所使用的 HTTP 方法告訴服務(wù)器。

3. Access-Control-Request-Headers

用于預(yù)檢請(qǐng)求。其作用是,將實(shí)際請(qǐng)求所攜帶的首部字段告訴服務(wù)器。

源碼(Egg框架)

  1. router:
'use strict';

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/api/getStocks', controller.home.getStocks);
};
  1. controller:
'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    this.ctx.body = 'hello world';
  }

  async getStocks() {
    const { ctx } = this;
    const stocks = [{
      name: '上證指數(shù)',
      code: '1A0001'
    }, {
      name: '萬(wàn)科A',
      code: '000002'
    }, {
      name: '濱江集團(tuán)',
      code: '002244'
    }];
    ctx.body = {
      code: 0,
      message: 'success',
      data: stocks,
    };
  }
}

module.exports = HomeController;

  1. config/plugin
'use strict';

/** @type Egg.EggPlugin */
exports.validate = {
  enable: true,
  package: 'egg-validate',
};

exports.cors = {
  enable: true,
  package: 'egg-cors',
}
  1. config/config.default
'use strict';

/**
 * @param {Egg.EggAppInfo} appInfo app info
 */
module.exports = appInfo => {
  /**
   * built-in config
   * @type {Egg.EggAppConfig}
   **/
  const config = exports = {};
  config.keys = appInfo.name + '_1574314669249_9332';
  config.middleware = ['errorHandler'];
  
  config.cors = {
    origin: 'http://localhost:3000',
    allowMethods: 'GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS',
    allowHeaders: 'access-token',
    credentials: true,
  };

  config.security = {
    // 關(guān)閉csrf驗(yàn)證
    csrf: {
      enable: false,
    },
    // 白名單
    domainWhiteList: ['*']
  };

  const userConfig = {
    myAppName: 'cors',
  };

  return {
    ...config,
    ...userConfig,
  };
};

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容