跨域問題的一次深刻研究

前言

最近在業務代碼中深受跨域問題困擾,所以特別寫一篇博客來記錄一下本身對跨域的理解以及使用到的參考資料。本文的項目背景基於vue+vuex+axios+springboot。涉及如下內容:php

  • 何爲跨域
  • HTTP跨域的請求究竟長啥樣,裏面的參數分別表明什麼意思
  • SpringBoot配置跨域請求

若是對跨域有所瞭解的盆友能夠直接跳到SpringBoot配置部分查看具體配置,或者是參考文章末尾Spring官網對CORS配置的博客連接。html

什麼是跨域

跨域是指當一個資源從與該資源自己所在的服務器不一樣的域或端口請求一個資源時,資源會發起一個跨域 HTTP 請求。這裏盜用MDN上的一張圖:前端

clipboard.png
當一個域名向另外一個不一樣的域名發起請求時,這時就產生了跨域問題。
那麼爲何會出現跨域這樣的概念呢?這就要提到以前規定的same origin policy。如下是引自維基百科的對於同源政策的描述:vue

An origin is defined by the scheme, host, and port of a URL. Generally speaking, documents retrieved from distinct origins are isolated from each other. For example, if a document retrieved from http://example.com/doc.html tries to access the DOM of a document retrieved from https://example.com/target.html, the user agent will disallow access because the origin of the first document, (http, example.com, 80), does not match the origin of the second document (https, example.com, 443).

總而言之,同源就是指擁有一樣的schema,主機和端口號的URL,不知足以上三點的任何一點都表明着這兩個URL非同源,它們之間的相互訪問就會產生跨域問題。ios

這裏再借用MDN上的URL是否同源的例子:web

clipboard.png

而在HTTP訪問中,又有了些許的變化。好比咱們一般會從CDN上獲取CSS,JS等靜態資源,而這些靜態資源的域名和當前的域並不一樣源,可是HTTP容許這樣的跨域訪問。所以,咱們能夠將HTTP上的跨域分爲三類:面試

  • 一般容許跨源寫入。好比連接,重定向和表單提交。某些特殊的HTTP請求可能須要預檢(preflight),後面將會詳細介紹這個詞。
  • 內嵌式跨域一般也是容許的。好比<link rel="stylesheet" href="...">得到CSS文件,<img>標籤引入另外一個源的圖片
  • 一般不容許跨源讀取,但讀訪問一般經過嵌入泄露。例如,您能夠讀取嵌入式圖像的寬度和高度,以及嵌入式腳本的操做。前端能夠經過嵌入式跨域變相實現跨域讀取。前端跨域的方法很是多,不過不是本文的重點,因此不詳細描述。

爲何會出現同源政策

這裏簡單介紹一下有名的CSFR攻擊來講明同源政策的目的。
這裏引用維基百科對跨站請求攻擊的解釋:spring

跨站請求攻擊,簡單地說,是攻擊者經過一些技術手段欺騙用戶的瀏覽器去訪問一個本身曾經認證過的網站並執行一些操做(如發郵件,發消息,甚至財產操做如轉帳和購買商品)。因爲瀏覽器曾經認證過,因此被訪問的網站會認爲是真正的用戶操做而去執行。這利用了web中用戶身份驗證的一個漏洞:簡單的身份驗證只能保證請求發自某個用戶的瀏覽器,卻不能保證請求自己是用戶自願發出的。

引用網上的一張圖片:
clipboard.pngvuex

簡單的解釋一下跨站請求的實現,維基百科上也有很是詳細的例子。
假設如今用戶在網頁上進行轉帳,只有經過身份驗證的用戶才能夠進行該操做。假設服務器是經過這樣的一個URL http://bank.example/withdraw?account=a&amount=1000000&for=b實現a向b轉帳100000塊的業務。由於該請求會攜帶用戶的身份認證信息,所以它可以經過服務器的認證並實現操做。可是這時惡意用戶c但願用這樣一個形式的URLhttp://bank.example/withdraw?account=a&amount=1000000&for=c
由於c本身並不具備a的session,所以他會經過別的方式誘惑a用戶執行這個操做。好比它會經過發送惡意郵件的方式騙a點擊上面的超連接。a若是此時並無退出bank.example的登陸即其session信息未被清空,那麼a將成功經過服務器的認證進行轉帳,實現了c的「心願」。其它的還有諸如在用戶進入惡意網站後利用js腳本自動提交表單向bank.example發出帶有a的session的post請求等等。json

同源政策將會確保網站a拒絕來自網站b的請求。

那爲何又須要跨域

當前端框架興起以後,先後端完全分離的開發方式漸漸流行。前端和後端每每部署在不一樣的域名之上。前端經過訪問後端的API獲取數據,渲染前端界面,甚至進行路由跳轉。這一般意味着先後端會出現不一樣源的問題。由於即便部署在同一臺主機上,兩者也屬於不一樣的端口。那麼咱們就須要某種策略使得跨域請求可以經過。支持跨域的方式有不少,下文主要介紹後端Spring Boot配置支持跨域訪問。

跨域訪問的HTTP報文

以前配置Spring Boot跨域的時候,我都是直接從網上抄一段這樣的代碼:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
    ......

    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/***")
                .allowedHeaders("**")
                .allowedMethods("GET", "POST")
                .allowedOrigins("*");

    }

}

一開始在開發過程當中,這段代碼並無問題。可是在試圖接入登陸業務以後出現了問題。由於採用Dubbo進行微服務開始,咱們決定將登陸做爲一個單獨的業務獨立部署在另外一個容器中。登陸業務的基本流程是訪問登陸容器,登陸成功後返回一個token存儲在服務器的localStorage中。以後每次訪問別的服務時都會在header中攜帶這個token,服務利用攔截器對token進行解析,判斷其是否合法以及是否生效,若是合法則將解析結果放入request中傳遞給後面的Controller。

在上面這個配置的基礎上出現了幾個問題:

  1. 在發送請求前,會發送preflight的OPTION請求來判斷服務器是否支持該域的跨域請求以及支持的跨域方法,可是該配置並不支持跨域的OPTION請求,從而致使OPTION方法沒法經過,進而沒法發送真正的GET或是POST請求
  2. 針對1中的問題開放OPTION請求以後,若是不進行認證就去訪問須要認證的業務,雖然得到了401的狀態碼,可是會出現跨域請求失敗的問題。若是你去查看該請求的響應頭,會發現響應header中確實沒有access-control-allow-origin字段!也就是說響應被攔截器攔截,甚至沒有進入跨域訪問的響應邏輯。而我使用axios時由於這個響應報文最後被認爲是跨域問題,沒法從error中得到401的狀態碼。

clipboard.png

總之,由於不知道一個真正的跨域請求的報文應該是什麼樣子的,因此盲目的折騰了半天,甚至沒能將問題定位到後端的跨域配置。因此,如今來看一下真正的跨域請求報文到底是什麼樣子的,來了解一下跨域的原理。

跨域報文

preflight

在次以前,先了解一下preflight。
咱們去查看瀏覽器發出的跨域請求時,常常會看到一個OPTION報文,它的url和真正的GET或是POST請求的URL相同。這個OPTION請求就是傳說中的preflight請求。preflight請求是爲了詢問服務器該跨域請求是否能夠被識別或是被容許。

preflight報文一般長成這樣:

OPTIONS /resource/foo 
Access-Control-Request-Method: DELETE 
Access-Control-Request-Headers: origin, x-requested-with
Origin: https://foo.bar.org

若是容許來自該IP的跨域訪問,服務器會用Access-Control-Allow-Origin頭字段說明允,並在Access-Control-Allow-Methods指明容許的方法。preflight響應報文一般長成這樣:

HTTP/1.1 200 OK
Content-Length: 0
Connection: keep-alive
Access-Control-Allow-Origin: https://foo.bar.org
Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE
Access-Control-Max-Age: 86400

這裏Access-Control—Max-Age指定了在86400s內無需爲該URL發送preflight請求。
至於爲什麼須要preflight請參考reference中的文章。

CORS報文

並非全部的請求都須要發送preflight請求,服務器面對簡單請求會直接返回Access-Control-Allow-Origin響應頭來講明它的跨域訪問是否經過,若是經過,則會在響應體中直接攜帶數據。請求和響應報文以下:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
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
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.example


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61 
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[xml]

服務器會檢查origin字段的URL是否容許跨域請求。能夠看到該服務器容許來自一切IP的跨域訪問,由於它返回的響應頭爲Access-Control-Allow-Origin: *
你會發現,這裏的請求和通常的HTTP請求並無太大的差異。

那麼,什麼是簡單請求呢?

clipboard.png
知足以上要求的則爲簡單請求。而一般先後端分離的服務之間會經過json形式的數據進行溝通,即content-type爲application/json。而這種形式不符合簡單請求的定義,所以須要使用option請求進行預檢。

複雜請求的預檢請求報文以下:

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
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
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

請求頭中有兩個字段比較特殊。Access-Control-Request-Method說明真正的跨域請求的方法,這裏是POST方法,而Access-Control-Request-Headers則說明請求頭中包含哪些非簡單字段。服務器端會根據自身的配置查看是否支持包含這些非簡單字段的請求,若是不包含,則該跨域請求會被拒絕。

預檢響應報文以下:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://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
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

這裏Access-Control-Allow-Headers說明了服務器支持的跨域請求的這些字段。

以後服務器會發送真實的請求,服務器會對之響應,其響應頭中會包含Access-Control-Allow-Origin字段。

身份認證

Spring-Boot 配置

如今咱們再來看一下以前的配置:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
    ......

    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/***")//對/api/**進行跨域配置
                .allowedHeaders("**")//容許全部的非簡單請求頭
                .allowedMethods("GET", "OPTIONS", "POST") //容許三種方法
                .allowedOrigins("*");//容許來自全部域的請求

    }
}

固然這種所有符合的通配符並非一個很好的選擇,咱們應當限制跨域請求的形式,從而拒毫不符合要求的請求。

第二種配置是採用Filter的形式進行配置。位於最前面的Filter會在請求進入任何其它位置以前對其進行處理。

@Configuration
public class MyConfiguration {

    @Bean
    public FilterRegistrationBean corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("http://domain1.com");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source));
        bean.setOrder(0);
        return bean;
    }
}

這裏跟上面的配置意思基本相同,區別在於這裏引入了setAllowCredential配置。它表明服務器支持跨域時攜帶認證信息。須要注意的是,若是開啓這個配置,則allowedOrigins不能夠爲*。

Reference

springboot設置cors跨域請求的兩種方式
spring官網-設置容許跨域請求
MDN Http 控制訪問
MDN Same Origin Policy
What's the motivation of preflight

clipboard.png

若想了解更多技術資訊、面試教程以及互聯網公司的內推信息,歡迎關注個人公衆號!

相關文章
相關標籤/搜索