1. 程式人生 > >Spring boot解決CORS(跨域)請求(基於spring MVC 4.0以上版本)。

Spring boot解決CORS(跨域)請求(基於spring MVC 4.0以上版本)。

一、前言

   昨天晚上臨近下班之前,公司前端同事突然跟我說,他從前端傳送的請求,方法變成了OPTIONS,我看到他的程式碼裡明明也寫著的是POST請求,可是為什麼會變成了OPTIONS請求,而且返回的http狀態碼也是200,但是沒有任何資料的返回,這就表明請求根本沒有進入到方法中就被攔截返回了。這究竟是哪裡出現了錯誤呢?

二、原因探究

    經過早上不懈的查詢資料,在以下這篇博文中找到了原因:https://www.cnblogs.com/cc299/p/7339583.html。

我簡要的說明下原因:

跨域(CORS)問題出現的原因主要是:前端發出Ajax請求的頁面與後端處理Ajax請求的伺服器的協議,域名,埠之間存在任意一組不同,便會出現跨域問題。具體參見下表

來源


        再說回我遇到這個問題的情況,由於我們的專案是前後端分離的專案,前端頁面與後端服務在開發階段及正式上線時都極有可能不在一臺主機上,即會存在跨域的問題。

        同時瀏覽器將CORS請求分成兩類:簡單請求(simple request)和非簡單請求(not-so-simple request)。

        只要同時滿足以下兩大條件,就屬於簡單請求。

(1) 請求方法是以下三種方法之一:
HEAD
GET
POST
(2)HTTP的頭資訊除瀏覽器自己加的頭資訊外不超出以下幾種欄位:
Accept
Accept-Language
Content-Language
Last-Event-ID
Content-Type:只限於三個值 application/x-www-form-urlencoded、multipart/form-data、text/plain

凡是不滿足以上兩種情況的就是非簡單請求。

    關於簡單請求和非簡單請求瀏覽器的處理手段這裡我只提一點:非簡單請求的CORS請求,會在正式通訊之前,增加一次HTTP查詢請求,稱為"預檢"請求(preflight)。瀏覽器先詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些HTTP動詞和頭資訊欄位。只有得到肯定答覆,瀏覽器才會發出正式的XMLHttpRequest請求,否則就報錯。

以下是我模擬前端傳送請求的ajax程式碼。

   $("#test").click(function(){
  htmlobj=$.ajax({
   method:"POST",
   headers: {
        Accept: "application/json; charset=utf-8",
        authorization:"123456"
    },
  url:"http://localhost:8769/appBuyerSaleScontract/addSaleScontractWithCodeByShopping",
  data:{"aaa":"bbb"}
  	});
  });
得到的結果如下圖所示:

在ajax程式碼中我定義發出的請求方法為POST,但是在這裡卻變成了OPTIONS,這是因為我們傳送的是一個非簡單請求,所以瀏覽器在傳送真正的ajax請求之前傳送了一次“預檢”請求,而預檢請求的請求方法就是OPTIONS。這次預檢請求得到的結果是403,也就是後端不接受這個請求。而返回的結果也顯示了這是一次非法的跨域請求。

三、解決方案

    既然知道了原因,那麼我們就可以開始著手解決問題了。既然返回值是403就表示確實進入了我們的程式,只是被spring攔截並拒絕了,所以沒有進入業務程式碼。

    我們知道springMVC根據請求方式的不同,用不同的handler來處理請求,而我們傳送的OPTIONS請求則會自動被corsProcessor物件(型別為:DefaultCorsProcessor)的processRequest方法來處理。

 注:下圖程式碼塊中的返回值,若是true,則表示該請求被允許訪問,若為“預檢”請求,則直接返回200結果,交由瀏覽器判斷是否能夠被執行(具體是判斷返回的響應頭中的Access-Control-Allow-XXX是否包含請求頭中對應的Access-Control-Request-XXX的值)若是false,則表示該請求被拒絕。


    接著,我們進入到processRequest方法中看看。


以上標出的四點是這個方法的關鍵也是我們的“預檢”請求能不能成功執行的關鍵。

1.

if (!CorsUtils.isCorsRequest(request)) {
    return true;
}

這一步的目的是判斷這個請求是不是一個跨域請求,其中的isCorsRequest(request)方法的邏輯也非常簡單。

public static boolean isCorsRequest(HttpServletRequest request) {
    return request.getHeader("Origin") != null;
}

只是判斷是否存在請求頭Origin即可。

正常的跨域請求是能夠返回true的。再經過!一轉換變成false。

如果進入了 執行了return true;則表明這不是一個跨域請求,spring會直接返回200的http狀態碼。以下三步中執行return true.時也是同樣的結果。

接下來執行的邏輯便是else中的邏輯了。

2.

if (this.responseHasCors(serverResponse)) {
    logger.debug("Skip CORS processing: response already contains \"Access-Control-Allow-Origin\" header");
    return true;
}

這一步的目的是判斷response中是否已經包含了"Access-Control-Allow-Origin"這個響應頭資訊。如果已經包含了該頭資訊則直接返回true;並執行return true;

其執行程式碼如下:

private boolean responseHasCors(ServerHttpResponse response) {
    try {
        return response.getHeaders().getAccessControlAllowOrigin() != null;
} catch (NullPointerException var3) {
        return false;
}
}

3.

if (WebUtils.isSameOrigin(serverRequest)) {
    logger.debug("Skip CORS processing: request is from same origin");
    return true;
}

這一步的主要目的是判斷髮送請求的套接字(IP+埠)地址與接收請求的套接字(IP+埠)地址是否相同(此處不對比協議)。

如果相同依舊返回true。執行 return true;

否則返回false。執行第4步。

主要邏輯如下:

public static boolean isSameOrigin(HttpRequest request) {
    String origin = request.getHeaders().getOrigin();
    if (origin == null) {
        return true;
} else {
        UriComponentsBuilder urlBuilder;
        if (request instanceof ServletServerHttpRequest) {
            HttpServletRequest servletRequest = ((ServletServerHttpRequest)request).getServletRequest();
urlBuilder = (new UriComponentsBuilder()).scheme(servletRequest.getScheme()).host(servletRequest.getServerName()).port(servletRequest.getServerPort()).adaptFromForwardedHeaders(request.getHeaders());
} else {
            urlBuilder = UriComponentsBuilder.fromHttpRequest(request);
}

        UriComponents actualUrl = urlBuilder.build();
UriComponents originUrl = UriComponentsBuilder.fromOriginHeader(origin).build();
        return actualUrl.getHost().equals(originUrl.getHost()) && getPort(actualUrl) == getPort(originUrl);
}
}

4.

第四步,也是最關鍵的一步來了。以上三步分別判斷是否存在請求頭Origin,響應中是否存在"Access-Control-Allow-Origin"這個響應頭資訊,請求的套接字地址與請求源的套接字地址是否相同。只要為做過攔截和加工,基本都能夠進入到第4步中。

第4步中的主要邏輯就是判斷是否存在config(型別為:CorsConfiguration)

if (config == null)

我們就來看看CorsConfiguration這個類到底有什麼東西。

public class CorsConfiguration {
    public static final String ALL = "*";
    private static final List<HttpMethod> DEFAULT_METHODS;
    private List<String> allowedOrigins;
    private List<String> allowedMethods;
    private List<HttpMethod> resolvedMethods;
    private List<String> allowedHeaders;
    private List<String> exposedHeaders;
    private Boolean allowCredentials;
    private Long maxAge;
 

以上是這個類中的所有欄位,我們主要關心的欄位有如下幾個:

   // CorsConfiguration
    public static final String ALL = "*";
    // 允許的請求源
    private List<String> allowedOrigins;
    // 允許的http方法
    private List<String> allowedMethods;    ——-->我們可以加入的http方法,我們註冊時加入的允許方法。
    // 允許的http方法
    private List<HttpMethod> resolvedMethods;   ----->我們不能加入的http方法,後面比對的時候只能比對這個。
    // 允許的請求頭
    private List<String> allowedHeaders;
    // 返回的響應頭
    private List<String> exposedHeaders;
    // 是否允許攜帶cookies
    private Boolean allowCredentials;
    // 預請求的存活有效期
    private Long maxAge;

說了這麼久終於說道重點上了。


在Spring MVC4 中為我們提供了這麼一個配置類來完成跨域請求。

在springboot中只需要加上下面這個類就可以完成允許跨域請求。其中allowedHeaders(String ... headers)這個方法中設定的允許帶上的請求頭可以各位看官老爺們自己定義。

@Configuration
public class CORSConfiguration extends WebMvcConfigurerAdapter {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                .addMapping("/**")
                .allowedMethods("*")
                .allowedOrigins("*")
                .allowedHeaders("authorization","Accept");
    }
}

這裡有一點要提出。allowedMethods和resolvedMethods其實是一樣的。

當我們呼叫allowedMethods(String ... method)時其實呼叫的是CorsRegistration.setAllowedMethods(List<String> allowedMethods)方法

下面是這個方法的原始碼:

public void setAllowedMethods(List<String> allowedMethods) {
        this.allowedMethods = allowedMethods != null ? new ArrayList(allowedMethods) : null;
        if (!CollectionUtils.isEmpty(allowedMethods)) {
            this.resolvedMethods = new ArrayList(allowedMethods.size());
            Iterator var2 = allowedMethods.iterator();

            while(var2.hasNext()) {
                String method = (String)var2.next();
                if ("*".equals(method)) {
                    this.resolvedMethods = null;
                    break;
                }

                this.resolvedMethods.add(HttpMethod.resolve(method));//將我們輸入的"GET","POST"等轉化為HttpMethod物件並新增到resolvedMethods中
            }
        } else {
            this.resolvedMethods = DEFAULT_METHODS;
        }

    }

這其中allowedMethods和resolvedMethods的轉化邏輯我已經註釋出來了。

接下來我們繼續看執行邏輯,即是else程式碼塊中的

return this.handleInternal(serverRequest, serverResponse, config, preFlightRequest);
handleInternal方法中執行著主要的,判斷跨域請求是否被允許的邏輯。

以下是方法的主要邏輯,我會重點講解。

 protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response, CorsConfiguration config, boolean preFlightRequest) throws IOException {
        String requestOrigin = request.getHeaders().getOrigin(); //獲取請求源地址
        String allowOrigin = this.checkOrigin(config, requestOrigin); //檢查是否有與requestOrigin一致的,我們設定的請求源,如果沒有則返回null;
//此處還有這樣一段邏輯:如果你設定的allowOrigin為*,並且你設定的allowCredentials(是否允許帶cookies)為false,則返回的allowOrigin會是*
//                   如果你設定的allowOrigin為*,並且你設定的allowCredentials(是否允許帶cookies)為true,則返回的allowOrigin會是requestOrigin
//                   如果你設定的allowOrigin為真正的url,則會遍歷整個List判斷每個元素忽略大小寫時是否相等。如相等則返回requestOrigin,否則返回null
//                   如果你沒有設定allowOrigin或者requestOrigin為空則直接返回null;
        HttpMethod requestMethod = this.getMethodToUse(request, preFlightRequest);//獲取真正ajax傳送時的請求方法
        List<HttpMethod> allowMethods = this.checkMethods(config, requestMethod); //檢查是否有允許的請求方法,如果resolveMethod為null,則返回requestMethod,否則進行對比,如果沒有,則返回null
        List<String> requestHeaders = this.getHeadersToUse(request, preFlightRequest);//獲取真正ajax傳送時的需要攜帶的請求頭
        List<String> allowHeaders = this.checkHeaders(config, requestHeaders);  //檢查是否有允許的請求頭資訊
                                                                                //此處還有這樣一段邏輯:如果你設定的allowHeaders為*,則直接返回requestHeaders
                                                                                //                      否則逐個比對,知道全部匹配為止
        if (allowOrigin == null || allowMethods == null || preFlightRequest && allowHeaders == null) { //preFlightRequest:只要符合請求頭包含Origin,請求方法為OPTIONS和請求頭包含"Access-Control-Request-Method"即為true,我們傳送的"預檢"請求都包含這三樣
            this.rejectRequest(response);
            return false;
        } else {
            HttpHeaders responseHeaders = response.getHeaders();
            responseHeaders.setAccessControlAllowOrigin(allowOrigin);
            responseHeaders.add("Vary", "Origin");
            if (preFlightRequest) {
                responseHeaders.setAccessControlAllowMethods(allowMethods); //設定響應頭的Access-Control-Allow-Methods
            }

            if (preFlightRequest && !allowHeaders.isEmpty()) {  
                responseHeaders.setAccessControlAllowHeaders(allowHeaders);//設定響應頭的Access-Control-Allow-Headers
            }

            if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
                responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());//設定響應頭的Access-Control-Expose-Headers
            }

            if (Boolean.TRUE.equals(config.getAllowCredentials())) {
                responseHeaders.setAccessControlAllowCredentials(true);//設定響應頭的Access-Control-Allow-Credentials
            }

            if (preFlightRequest && config.getMaxAge() != null) {
                responseHeaders.setAccessControlMaxAge(config.getMaxAge());//設定響應頭的maxAge
            }

            response.flush();
            return true;
        }
    }
protected void rejectRequest(ServerHttpResponse response) throws IOException {
    response.setStatusCode(HttpStatus.FORBIDDEN);
response.getBody().write("Invalid CORS request".getBytes(UTF8_CHARSET));
}

其中rejectRequest方法執行的邏輯就是為什麼我們會得到403 forbidden!的結果。

三、疑問與測試

1.filter實現跨域問題的解決

我看了好多網上說的方法,其中有一種方法也非常的熱門,就是用filter攔截

於是我寫了下面這個類

@Component
public class CORSFilter implements Filter{
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("Init");
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse)resp;
        HttpServletRequest request = (HttpServletRequest) req;
        System.out.println(request.getHeader("Access-Control-Request-Method"));
        if( request.getHeader("Access-Control-Request-Method") != null && request.getHeader("Access-Control-Request-Headers") != null ){
            response.setHeader("Access-Control-Allow-Origin","http://localhost:8080");
            response.setHeader("Access-Control-Allow-Methods",request.getHeader("Access-Control-Request-Method"));
            response.setHeader("Access-Control-Allow-Headers",request.getHeader("Access-Control-Request-Headers"));
            response.setHeader("Access-Control-Max-Age","300");
            response.setHeader("Access-Control-Allow-Credentials","true");
        }
        filterChain.doFilter(request,response);
    }
    @Override
    public void destroy() {
        System.out.println("destroy");
    }
}

下面是訪問的結果。


同樣的,使用filter也能夠成功。同時也能夠自定義設定值。但是這種方式不便於後期維護,同時看起來也比較亂,相比之下,我推薦上面的使用CorsRegistry 註冊的方式。