CORS跨域資源共享(一):模擬跨域請求以及結果分析,理解同源策略【享學Spring MVC】

  • 2019 年 10 月 7 日
  • 筆記

版權聲明:本文為部落客原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接和本聲明。

本文鏈接:https://blog.csdn.net/f641385712/article/details/100999550

每篇一句

talk is cheap,show me the money.

前言

CORS的全稱是:跨域資源共享(Cross-origin resource sharing),它是瀏覽器的一個技術規範。 瀏覽器自己是可以發起跨域請求的(比如你可以外鏈一個外域的圖片或者影片),但是Javascript腳本是不能跨域去獲取這些資源的內容的。傳統的ajax請求只能獲取在同一個域名下的資源,但是Html5打破了這個限制:允許ajax發起跨域請求。跨域的解決方案有多種:JSONP、Flash、IFrame等,當然還有今天的主菜CORS

我有理由相信若你在前端使用過Ajax,你100%遇見過如下圖這樣的報錯:

若你看到這樣的報錯,那麼此次你的請求返回數據是失敗的(請務必理解這句話)。但是,但是,但是若你查看調試工具的Network欄,發現這個URL請求的response是有返回值的(並且http狀態碼是200,表示請求被服務端正常處理了),形如這樣:

看似相悖的結果,這到底怎麼回事???本文就告訴你答案




同源策略

同源策略限制了從同一個源載入的文檔或腳本如何與來自另一個源的資源進行交互。這是一個用於隔離潛在惡意文件的重要安全機制。該策略是瀏覽器最核心也最基本的安全功能,同源指的是:同協議、同域名、同埠。

它的核心思想可以理解為:我只相信我同一個域的資源,來自於其它域的我都不可信,所以同源策略主要還是出於安全考慮的~

JavaScriptCookie只能訪問同源(同協議、同域名、同埠下的內容。

CORS

CORS它是W3C(萬維網聯盟)的標準,它定義了在跨域訪問資源時瀏覽器和伺服器之間如何通訊。它是為突破同源策略的限制而出現的一種官方標準的跨域解決方案。在實戰場景中,跨域場景太為常見了(特別是當下前後端分離的開發模式),因此深入理解CORS變得就異常的重要了(反倒前端工程師不用太了解)。

若想實現CORS機制的跨域請求,是需要瀏覽器和伺服器同時支援的。關於瀏覽器對CORS的支援情況:現在都9012年了,so可以認為100%的瀏覽器都是支援的,再加上CORS的整個過程都由瀏覽器自動完成,前端無需做任何設置,所以你的ajax原來怎麼用現在還是怎麼用,它對前段開發人員是完全透明的。 所以呢,讓此種機制生效的關鍵就在於伺服器端,so作為服務端開發的我們,必須要玩轉CORS才可正常實現跨域通訊。

CORS機制的指導思想:自定義的HTTP頭部允許瀏覽器和伺服器相互了解對方,從而決定請求或響應成功與否

為何需要跨域請求???

這是跨域請求產生的背景,最主要是隨著互聯網的發展,忘了改善網路應用程式的環境增強其功能,開發人員要求瀏覽器供應商允許跨域請求,能帶來如下好處:

  • javascript可以使用ajax方式跨域訪問資源
  • CSS可以使用@font-face跨域調用字體
  • 通過canvas標籤,繪製圖表和影片

由此可見:跨域不僅僅是ajax的專屬

本地模擬跨域請求以及結果分析

上面都是成套成套的理論知識,過於抽象。那接下來我就是要通過本地的實例來模擬出跨域請求,從而依託於案例分析CORS各種不同的case情況下的結果分析。

1、寫一個前端HTML頁面放於idea(idea可充當靜態web伺服器)

<!DOCTYPE html>  <html>  <head>      <meta charset="UTF-8">      <title>CORS跨域</title>      <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>  </head>  <body>  <div style="text-align:center;margin-top: 100px;font-size: 60px;color: brown;cursor: pointer;">      <span onclick="sendAjaxReq()">發送Ajax請求</span>  </div>  <script type="text/javascript">      function sendAjaxReq() {          $.ajax({              type: "GET",              // contentType: "application/json",              url: "http://localhost:8080/demo_war_war/test/cors,              success: function (message) {                  console.log("成功!" + message);              },              error: function (a, b, c) {                  console.log("失敗!" + a.statusText);              }          });      }  </script>  </body>  </html>

2、寫一個控制器Controller處理頁面發送的ajax請求

@RestController  public class CorsController {        @GetMapping("/test/cors")      public Object testCors() {          return "hello cors";      }  }

3、利用idea的web伺服器能力運行html頁面,如下截圖(本例使用的是標準的63342靜態web埠)

請注意這個頁面的訪問地址的是http://localhost:63342...,而點擊這個"發送Ajax請求"按鈕要發送的地址是http://localhost:8080...兩者埠號不一樣說明是不同的域,因此此ajax請求它必定屬於跨域請求(CORS請求)。

4、點擊發送按鈕,查看控制台的結果 這個case的結果請完全參照文首的幾張截圖,此處就省略了

Tips:如果域名連不上服務端(比如服務端木有啟動),它的報錯一般都會是網路連接方面的問題,形如:GET http://localhost:8080/demo_war_war/test/cors net::ERR_CONNECTION_REFUSED,請注意區分~

如上結果,命名返回了200但瀏覽器偏偏還是報跨域異常,我相信這個讓你感覺到十分的詫異和不解,那麼接下來就圍繞它來解釋通這個問題。 但在我解釋此現象之前,必須先要弄明白兩個非常重要的CORS請求類型:簡單請求,非簡單請求(說明:這兩種請求都屬於CORS請求,這是大前提)。


簡單請求、非簡單請求

CORS發送出來的請求分為兩種:

  1. 簡單請求。需要同時滿足下面三個要求 1. 請求方法只能是GET、POST、HEAD 2. Content-Type只能是三個值的任意一個application/x-www-form-urlencoded、multipart/form-data、text/plain(備註:若使用jquery的ajax發送請求,沒指定Content-Type的情況下,默認它的值是application/x-www-form-urlencoded。源生的ajax請求請手動顯示指定) 3. 無自定義請求頭(除了Accept、Content-Type等等一些內置的頭之外的頭都叫自定義)
  2. 非簡單請求。除了簡單請求之外都是它(帶預檢,也就是我們常見的OPTIONS請求)。

很顯然,不滿足簡單請求三大要求的便都是非簡單請求嘍。在實際生產應用場景中我們最為常見的非簡單請求場景大致有如下三種case:

  1. ajax發送put、delete請求
  2. 發送json格式數據(Content-Typeapplication/json
  3. 自定義請求頭(比如自定義鑒權請求頭Authorization
簡單請求

對於這種請求,瀏覽器是直接發出請求,它的特點是:瀏覽器自動給加上一個Origin的請求頭,表示這個請求的來源(來自哪個源)。 比如上面案例的請求,它完全符合簡單的請求的三大要求,所以它是一個簡單請求,瀏覽器自動給它加上的頭是:Origin: http://localhost:63342。 服務端可拿到這個Origin源,然後判斷服務端是否能夠接受這個源從而決定是否同意這次請求(不同意or同意):

  • 不同意:伺服器會返回一個正常的HTTP回應(響應頭裡木有Access-Control-Allow-Origin這個頭),瀏覽器發現木有這個頭,就拋出一個錯誤XMLHttpRequest,進而進入ajax的onerror回到方法里(這就是為何你明明看到http狀態碼是200,response也有返回值,但偏偏你ajax里就是進入的error的原因~),它的現象是:伺服器正常返回了資源,但瀏覽器拒絕接收了。
  • 同意:伺服器的響應里會多出下面詳解的幾個響應頭,從而回調ajax的onsuccess方法,這就是真正意義上的成功了,瀏覽器也接收了這個返回結果。

和簡單請求相關的3個響應頭如下:

Access-Control-Allow-Origin

該響應頭是伺服器必須返回的。它的值要麼是請求時Origin的值(可從request里獲取),要麼是*這樣瀏覽器才會接受伺服器的返回結果。

Access-Control-Allow-Credentials

該響應頭非必須,值是bool類型,表示是否允許發送Cookie

  • true:表示伺服器允許你瀏覽器把cookie發給我(若伺服器想獲取Cookie的,請務必設置此值)
  • false :請注意此欄位只能設置為true,若不允許發送cookie,不要設置此響應頭即可

Tips:瀏覽器端默認情況下,Cookie不包括在CORS請求之中,若你想讓瀏覽器帶上Cookie,有需要的請自行研究一番吧~

Access-Control-Expose-Headers

該響應頭非必須。顧名思義它要把response中的哪些頭暴露給瀏覽器,讓它可以獲取到(默認情況下瀏覽器的XMLHttpRequest對象的getResponseHeader()方法只能獲取到那些Cache-Control、Expires等等幾個標準的響應頭,若需要拿其它key,需要在這裡指定)

請求成功案例

為了寫出一個完全正確CORS簡單請求,基於本例我只需要加一句程式碼即可:

@GetMapping("/test/cors")  public Object testCors(HttpServerResponse response) {  	// HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN      response.addHeader("Access-Control-Allow-Origin", "http://localhost:63342");      return "hello cors";  }

再次點擊按鈕發送ajax請求結果如下:

大功告成。服務端不僅僅正常處理了請求,瀏覽器也接受了返回值。

對於簡單請求請務必杜絕這種case:返回的狀態碼是200(服務端邏輯正常執行且正常返回了),瀏覽器不會接收結果,而是回調到error方法去~

非簡單請求

顧名思義,它比簡單請求就要複雜些,不是簡單請求的CORS請求都屬於"非簡單請求"(比如請求方法是PUTDELETE)。它最大的一個特點是:在發送正式請求通訊之前,增加一次HTTP OPTIONS請求,這個請求稱之為預檢(preflight)請求

發送OPTIONS預檢請求的過程完全由瀏覽器自動完成,開發者無需關心。

預檢請求:它的作用是試探服務端是否能接受真正的請求,若伺服器返回的狀態碼不是2xx而是4xx/5xx的話,那麼瀏覽器將停止發送真正的請求。OPTIONS請求它具有如下特徵:

  1. 沒有請求body體
  2. 可以有響應body體(比如我們熟悉的:Invalid CORS request
  3. 安全
  4. 冪等性
  5. 不能快取,不能在表單里使用

下面先看一個非簡單請求的例子,只需要把上例的Ajax注釋的contentType放開即可,它便輕鬆成為了一個非簡單請求了:

...  contentType: "application/json",  ...

點擊發送按鈕,結果截圖如下:

OPTIONS請求返回的狀態碼是403,所以真實的請求並未發送(network欄只有一個請求~)。瀏覽器自動添加的請求頭中,最重要的仍然是Origin這個頭,例如我們生產環境的請求頭如下:

另外兩個請求頭解釋如下(雖然不是十分重要,但也是必須了解的):

  • Access-Control-Request-Method:該請求頭是必須的。發給伺服器告知我接下來的真實方法是啥,本例是GET;
  • Access-Control-Request-Headers:非必須(因為可能無自定義的請求頭嘛)。若有多個是逗號分隔,告訴伺服器我真實請求即將攜帶的請求頭是哪些,本例是content-type這一個請求頭;

這些請求頭最終都發送給伺服器,伺服器收到這個預檢請求後判斷,檢查這些頭,確認允許跨域與否就可以做出相應的回應了(本例回應403:Forbidden)。

和非簡單請求相關的5個響應頭如下:

Access-Control-Allow-Origin和Access-Control-Allow-Credentials

同上

Access-Control-Allow-Methods

必須的相應頭,值是逗號分隔的字元串。表明我伺服器可以支援的所有跨域請求的方法~可以用*代替

註:為何返回的不單單是馬上要發真實請求的那個方法,而是多個呢???這是為了避免多次"預檢"請求,提高效率。後面你可以看到它的功效

Access-Control-Allow-Headers

若請求頭中包含Access-Control-Request-Headers,那響應頭中這個頭就是必須的,否則是非必須的。它的值是逗號分隔的字元串,表示我伺服器支援的所有頭欄位,不限於預檢請求中的頭欄位(但請包含它~)。可以用*代替

說明:若請求頭中有Access-Control-Request-Headers,但是沒有此響應頭/響應頭中的值不包含請求頭的值。那麼出現的奇異現象便是:OPTIONS請求正常200返回,但是真實請求就不會發送了。所以使用時請務必注意~

Access-Control-Max-Age(重要)

非必須。它表示需要快取預檢結果多長時間,單位是秒。比如Access-Control-Max-Age: 600表示將預檢結果快取10分鐘,即表示10分鐘之內同樣的URL將不再發送預檢請求。如果值是0表示不用快取~

Tips:因為它對url生效,所以對那些默認的查詢條件取當前時間戳的可千萬別這麼幹了,一般我相信你精確到日期就夠了而不用精確到毫秒吧,否則age就不生效了(每次都還得發送預檢請求)

當然,你的瀏覽器也是可以禁用掉這種快取的。

請求成功案例

它和簡單請求的處理方式是不一樣的,因為OPTIONS請求進入不了Handler方法,所以在Controller里向HttpServletResponse里設置請求頭是無效的。 因此我們應該把設置相應頭資訊放在Filter/HandlerInterceptor上才行,本例以Spring MVC的攔截器為例(生產上推薦使用Filter):

@Override  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  	// 這幾個響應頭都是可以用*來表示所有的      response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");      response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "*");      response.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*");      response.addHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "60");      return true;  }

請注意:這些添加header只能放在preHandle,放在postHandle/afterCompletion里都將不生效(network里能看到這個頭,但是無效果

配置好後,點擊按鈕發送Ajax請求,結果截圖如下:

從這張截圖可以看到:我點擊了3此發送切都成功了,再回頭看看network:

非簡單請求跨域成功。從截圖的結果上還能看到Access-Control-Max-Age它的功效,它能夠減少OPTIONS請求的發送,從而減輕對服務端的訪問壓力。

如何理解Access-Control-Max-Age對相同URL生效???

為了更好理解這個響應頭的作用,我針對性的做出如下試驗:

為了測試,我把Access-Control-Max-Age設為了24小時,以保證快取「永不過期」(控制變數法)

1、相同URL,不同的請求Method 頁面改造如下,以保證先後發送一個GET請求和一個POST請求,同時Controller也增加對POST請求的支援

<script type="text/javascript">      var url = "http://localhost:8080/demo_war_war/test/cors";      function sendAjaxReq() {          $.ajax({              type: "GET",              contentType: "application/json",              url: url,              success: function (message) {                  console.log("成功!" + message);                    // 成功里立馬再發一次請求:url一樣 但是POST請求                  $.ajax({                      type: "POST",                      contentType: "application/json",                      url: url,                      success: function (message) {                          console.log("成功!" + message);                      }                  });              },              error: function (a, b, c, d) {                  console.log("失敗!");              }          });      }  </script>
// 支援GET和POST請求的處理  @RequestMapping(value = "/test/cors", method = {GET, POST})  public Object testCors(HttpServletResponse response) {      return "hello cors";  }

答案:發送一次OPTIONS請求

2、相同URL,相同的請求Method(POST請求為例),不同的請求body體 答案:發送一次OPTIONS請求 3、相同的URL,不同Method、不同body體 答案:發送一次OPTIONS請求 4、不同的URL 答案:發送兩次OPTIONS請求

實驗證明:在快取還生效的情況下,是否再次發送OPTIONS請求只和URL有關,只要URL不變,都不會再次發送OPTIONS請求了~ 這就警示我們:那些URL中有默認動態查詢參數的(如當前時間戳)請務必注意了,如果每次都獲取當前時間戳,那就導致每次URL都是不一樣的,那就讓Access-Control-Max-Age這個響應頭形同虛設了~

改進方案:默認動態查詢參數不要精確到毫秒,絕大多數情況下精確到當前小時、天是足夠了的,最不濟分鐘級別也夠了吧~~~


CORS和JSONP對比

最終一個小知識點補充。JSONP是一個相對比較古老的用於解決跨域問題的技術了,對於新生代的程式設計師來說幾乎可以忽略掉它,因為已經完全被新時代的CORS所代替,把前浪拍死在沙灘上。

它哥倆都能解決瀏覽器Ajax請求資源的跨域問題,有些不同的點總結如下:

  1. JSONP只能實現GET請求(讓支援其餘請求將非常麻煩),CORS支援所有類型的HTTP請求
  2. 使用CORS,我們可以通過XMLHttpRequest直接完成請求發起和獲取數據,因為都是這一個對象,所以處理錯誤更加方便
  3. JSONP的唯一優勢:支援更老的瀏覽器(現在都9012年了,相信木有了)。CORS現已是官方的標準實現規範,幾乎所有瀏覽器都支援得很好~

CORS帶來的問題

  1. 帶來的安全隱患,最主要的便是著名的跨站請求偽造CSRF(Cross-site request forgery),所以要做好這塊的安全工作(建議可開啟withCredentials的cookie認證)
  2. 因為增加了OPTIONS預檢請求,無疑增加了系統的開銷(本一個請求搞定的變成了需要兩個請求),所以需要做好快取策略以及確保快取能夠生效
  3. 可能影響到你的限流,需要特殊處理。由於OPTIONS請求和實際請求的發送時間間隔非常短,此時若你限流如:同一IP每秒只能訪問1次,那真實請求就會被拒絕了,因此此時就要排除掉OPTIONS這種預檢請求的影響
  4. 同樣的,若你的Filter/攔截器里,若有需要也是要對OPTIONS方法進行特殊處理的,否則可能就會執行多次造成一些麻煩

總結

CORS(跨域資源共享)是一種瀏覽器端的機制,它在現在前後端完全分離開發主流的今天還是蠻重要的概念,即使它比較簡單。 需要注意的是:既然它是瀏覽器端的一種機制,所以它是可以被瀏覽器關閉這種機制的,至於如何do,有興趣的可自行度娘~

在實戰場景中:能控制伺服器的情況下,一般都是伺服器上正確配置CORS。可以在伺服器API層(Controller層)進行精細化控制配置,也可以在nginx層進行統一配置(這樣後端新加伺服器不用再配置),最好配置上白名單而不是簡單的粗暴的全是*

本文主要以介紹CORS概念為主,然後結合一個實例介紹了它的使用和結果分析。但至少看完本文後你應該留有如下疑問待解決:

  • 有沒有通用的跨域解決方案?
  • Spring MVCCORS的支援原理、使用方式是怎樣的?
  • 為何OPTIONS請求就不進入Handler方法進行處理呢