1. 程式人生 > >cors跨域之簡單請求與預檢請求(傳送自定義請求頭)

cors跨域之簡單請求與預檢請求(傳送自定義請求頭)

引子

前後端分離這個問題,對cors的應用不斷增多,暴露出的問題也接踵而至。
正所謂慮一千次,不如去做一次。 猶豫一萬次,不如實踐一次,本篇主要討論在傳送ajax請求,頭部帶上自定義token驗證驗證,暴露出的跨域問題。

先說說定義

CORS:跨來源資源共享(CORS)是一份瀏覽器技術的規範,提供了 Web 服務從不同網域傳來沙盒指令碼的方法,以避開瀏覽器的同源策略,是 JSONP 模式的現代版。與 JSONP 不同,CORS 除了 GET 要求方法以外也支援其他的 HTTP 要求。用 CORS 可以讓網頁設計師用一般的 XMLHttpRequest,這種方式的錯誤處理比JSONP要來的好,JSONP對於 RESTful 的 API 來說,傳送 POST/PUT/DELET 請求將成為問題,不利於介面的統一。但另一方面,JSONP 可以在不支援 CORS 的老舊瀏覽器上運作。不過現代的瀏覽器(IE10以上)基本都支援 CORS。
預檢請求(option):在 CORS 中,可以使用 OPTIONS 方法發起一個預檢請求(一般都是瀏覽檢測到請求跨域時,會自動發起),以檢測實際請求是否可以被伺服器所接受。預檢請求報文中的 Access-Control-Request-Method 首部欄位告知伺服器實際請求所使用的 HTTP 方法;Access-Control-Request-Headers 首部欄位告知伺服器實際請求所攜帶的自定義首部欄位。伺服器基於從預檢請求獲得的資訊來判斷,是否接受接下來的實際請求。

OPTIONS /resources/post-here/ HTTP/1.1 
Host: bar.other 
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-Allow-Methods 首部欄位將所有允許的請求方法告知客戶端。該首部欄位與 Allow 類似,但只能用於涉及到 CORS 的場景中。

問題描述

話不多說,先上程式碼:

前端(ajax庫:vue-resource)
        userLogin:function(){
            this.$http({
                method:'post',
                url:'http://localhost:8089/StockAnalyse/LoginServlet',
                params:{"flag":"ajaxlogin","loginName":this.userInfo.id,"loginPwd":this.userInfo.psd}, 
                headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 
                credientials:false, 
                emulateJSON: true                    
            }).then(function(response){
                sessionStorage.setItem("token",response.data);
                this.isActive =false;
                document.querySelector("#showInfo").classList.toggle("isLogin");
            })                 
        }
後端相關配置:
        response.setHeader("Access-Control-Allow-Origin", "http://localhost"); //允許來之域名為http://localhost的請求        
    response.setHeader("Access-Control-Allow-Headers", "Origin,No-Cache, X-Requested-With, If-Modified-Since, Pragma, Last-Modified, Cache-Control, Expires, Content-Type, X-E4M-With, userId, token");
    response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); //請求允許的方法
    response.setHeader("Access-Control-Max-Age", "3600");    //身份認證(預檢)後,xxS以內傳送請求不在需要預檢,既可以直接跳過預檢,進行請求(前面只是照貓畫虎,後面才理解)

關於上面一段程式碼,是我的使用者首次登入認證,生成token令牌,儲存在sessionStorage中,供後面呼叫;需要說明的是,前端伺服器地址是:localhost:80,後端伺服器地址:localhost:8089,所以前後端涉及到跨域,自己在後端做了相應的跨域設定:response.setHeader("Access-Control-Allow-Origin", "http://localhost"); 所以登入認證,安全的實現了跨域資訊認證,後端相應傳送回來了相應的token資訊。
但獲取到token後,想在需要的時候,在請求的頭部攜帶上這個令牌,來做相應的身份認證,所以自己在請求中做了這些改動(有標註),後端沒改動,原始碼:

        checkIdentity:function(){
            let token =sessionStorage.getItem('token');
            this.$http({
                method:'post',
                url:'http://localhost:8089/StockAnalyse/LoginServlet',
                params:{"flag":"checklogin","isLogin":true,"token":token}, 
                headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 
                headers:{'token':token},        //header中攜帶令牌資訊            
                credientials:false, 
                emulateJSON: true                    
            }).then(function(response){
                console.log(response.data);
            })                 
        }   

但實際上在devtools列印瞭如下錯誤資訊:Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost' is therefore not allowed access.仔細想一想,好像,似乎這個問題遇到過,還提過問,確實提過。但這次的設定和上次一樣,就在header裡多加了一個自定義token,但卻報了和上次沒有設定headers: {'Content-Type': 'application/x-www-form-urlencoded'}一樣的錯誤資訊,於是,不知所措,算了,重頭再來,好好百度,研究一下cors跨域。

理論學習

運氣不錯,找到了一篇文章,文章講的很細,也找到自己問題的所在:觸發 CORS 預檢請求。引用原文的話加以自己總結:跨域資源共享標準新增了一組 HTTP 首部欄位,允許伺服器宣告哪些源站有許可權訪問哪些資源。另外,規範要求,對那些可能對伺服器資料產生副作用的 HTTP 請求方法(特別是 GET 以外的 HTTP 請求,或者搭配某些 MIME 型別的 POST 請求),瀏覽器必須首先使用 OPTIONS 方法發起一個預檢請求(preflight request:似曾相識有沒有?誒,對,上面那個錯誤資訊中,就有一個這樣陌生的詞彙),從而獲知服務端是否允許該跨域請求。伺服器確認允許之後,才發起實際的 HTTP 請求。在預檢請求的返回中,伺服器端也可以通知客戶端,是否需要攜帶身份憑證(包括 Cookies 和 HTTP 認證相關資料)。所以跨域請求分兩種:簡單請求和預檢請求。一次完整的請求不需要服務端預檢,直接響應的,歸為簡單請求;而響應前需要預檢的,稱為預檢請求,只有預檢請求通過,才有接下來的簡單請求。對於那些是簡單請求,那些會觸發預檢請求,文章做了詳細的總結,這裡列出觸發預檢請求的條件(不知道腦子為啥會想到那些會觸發BFC的條件),不要跑題,原文是這樣總結的:

當請求滿足下述任一條件時,即應首先發送預檢請求:
使用了下面任一 HTTP 方法:
PUT
DELETE
CONNECT
OPTIONS
TRACE
PATCH
人為設定了對 CORS 安全的首部欄位集合之外的其他首部欄位。該集合為:
Accept
Accept-Language
Content-Language
Content-Type (but note the additional requirements below)
DPR
Downlink
Save-Data
Viewport-Width
Width
 Content-Type 的值不屬於下列之一:
application/x-www-form-urlencoded
multipart/form-data
text/plain

問題分析

所以,再來看自己兩次犯錯(第一次是沒有設定:headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 第二次是設定自定義header,headers:{'token':token}。很巧,有沒有,一次少,一次多,都點燃了導火索),其實都是觸發了預檢請求。對於第一次的錯誤,很好解決,增加headers: {'Content-Type': 'application/x-www-form-urlencoded'},就解決了,關於Conten-Type的幾種取值,你需要知道的。但對於第二個錯誤,好像沒法向第一種那樣,將預檢請求轉變為簡單請求,所以,只有尋找方法怎麼在後端實現相應的預檢請求,來返回一個狀態碼2xx,告訴瀏覽器此次跨域請求可以繼續。所以注意力轉向後端。
關於JAVA實現預檢請求,基本都是採用過濾器,不要問我為什麼不是監聽器或者攔截器(我就是個偽全棧,就不要相互為難了,自己百度之),自定義(copy)了一個filter,並在web.xml中進行了設定。原始碼:

Filter介面實現部分:
package stock.model;
import java.io.IOException;   
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;    
import org.apache.commons.httpclient.HttpStatus;   //這裡需要新增commons-httpclient-3.1.jar
public class CorsFilter implements Filter {     //filter 介面的自定義實現
    public void init(FilterConfig filterConfig) throws ServletException {
    }
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        response.setHeader("Access-Control-Allow-Origin", "*");           
        String token = request.getHeader("token");
        System.out.println("filter origin:"+token);//通過列印,可以看到一次非簡單請求,會被過濾兩次,即請求兩次,第一次請求確認是否符合跨域要求(預檢),這一次是不帶headers的自定義資訊,第二次請求會攜帶自定義資訊。
        if ("OPTIONS".equals(request.getMethod())){//這裡通過判斷請求的方法,判斷此次是否是預檢請求,如果是,立即返回一個204狀態嗎,標示,允許跨域;預檢後,正式請求,這個方法引數就是我們設定的post了
          response.setStatus(HttpStatus.SC_NO_CONTENT); //HttpStatus.SC_NO_CONTENT = 204
          response.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS, DELETE");//當判定為預檢請求後,設定允許請求的方法
          response.setHeader("Access-Control-Allow-Headers", "Content-Type, x-requested-with, Token"); //當判定為預檢請求後,設定允許請求的頭部型別
          response.addHeader("Access-Control-Max-Age", "1");  // 預檢有效保持時間                       
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }
    @Override
    public void destroy() {     
    }
}
web.xml配置部分
<filter>
<filter-name>cors</filter-name>
<filter-class>stock.model.CorsFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>cors</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>   

關於Access-Control-Max-Age

最近又開始寫java,再回來看這個,發現當時Access-Control-Max-Age設定了1.其實這樣寫有很大問題,因為每個複雜請求都會發兩次。顯然這樣是當代所不能接受的,所以Max-Age的值適合設的大一些,具體多大很業務需求相關。另外Access-Control-Max-Age不是針對請求域名有效的,是請求的完成路徑有效的,比如第一次發出www.exanple.com/api/corsGet,會產生一次options請求和一次post請求,然後我再請求一次,這時沒有預檢請求了,只有post請求。但再發送一次www.exanple.com/api/corsSave請求,會發現又產生了一次options請求和一次post請求,所以Access-Control-Max-Age不是針對相同的origin有效,而是針對相同的requestUrl有效。很重要哦。

結論

當在後端實現新增上面的原始碼後,皆大歡喜,問題得以解決,補上失敗和成功,自己截下的兩張請求響應圖

仔細看請求響應失敗發起響應那張圖,在General的資料集中,可以看到方法是options,而非程式碼指定的post請求,所以這是一次瀏覽器發出的一次預檢請求,讓伺服器確認此IP是否有訪問的許可權,如果有,伺服器需要返回一個2xx的狀態碼給瀏覽器。緊接著再發起一次簡單請求。如下面在devtools中的擷取圖片(為了對比清除,我把兩次分別擷取,做了拼接,因為不會做動態圖)。可以看到同一個post請求,實際上產生了兩次網路連線。


但關於cors,要去探索的,還有很多很多,所以遵循革命語錄:實踐(有時也可以是時間)是檢驗真理的唯一標準,是沒有錯的。後續有新的