一文弄懂 CORS 跨域(前端+後端程式碼例項講解)

Momomo發表於2020-02-01

經常被問到一些問題,比如寫 Java 服務端的同學的來問:我服務端明明正確返回了,測試環境 debug 能看到,為什麼前端就是拿不到資料? 然後寫前端的同學會問:為什麼我明明設定了 withCredentials=true,服務端同學還是拿不到 cookie?

所以決定重新捋一捋使用 CORS 解決跨域的問題,前後端要怎麼做?為什麼這麼做?

為什麼會有跨域的問題?

為了保證使用者資訊的安全,所有的瀏覽器都遵循同源策略,那什麼情況下算同源呢?同源策略又是什麼呢?

記住:協議、域名、埠號完全相同時,才是同源

可以參考 Web安全 - 瀏覽器的同源策略

在同源策略下,會有以下限制:

  • 無法獲取非同源的 Cookie、LocalStorage、SessionStorage 等
  • 無法獲取非同源的 dom
  • 無法向非同源的伺服器傳送 ajax 請求

但是我們又經常會遇到前後端分離,不在同一個域名下,需要 ajax 請求資料的情況。那我們就要規避這種限制。

可以在網上搜到很多解決跨域的方法,有些方法比較古老了,現在專案中用的比較多的是 jsonp 和 CORS(跨域資源共享),這篇主要講 CORS 的原理和具體實踐。

CORS 跨域原理

CORS 跨域的原理實際上是瀏覽器與伺服器通過一些 HTTP 協議頭來做一些約定和限制。可以檢視 HTTP-訪問控制(CORS)

與跨域相關的協議頭

請求頭 說明
Origin 表明預檢請求或實際請求的源站 URI,不管是否跨域ORIGIN 欄位總是被髮送
Access-Control-Request-Method 將實際請求所使用的 HTTP 方法告訴伺服器
Access-Control-Request-Headers 將實際請求所攜帶的首部欄位告訴伺服器
響應頭 說明
Access-Control-Allow-Origin 指定允許訪問該資源的外域 URI,對於攜帶身份憑證的請求不可使用萬用字元*
Access-Control-Expose-Headers 指定 XMLHttpRequest的getResponseHeader 可以訪問的響應頭
Access-Control-Max-Age 指定 preflight 請求的結果能夠被快取多久
Access-Control-Allow-Credentials 是否允許瀏覽器讀取 response 的內容;
當用在 preflight 預檢請求的響應中時,指定實際的請求是否可使用 credentials
Access-Control-Allow-Methods 指明實際請求所允許使用的 HTTP 方法
Access-Control-Allow-Headers 指明實際請求中允許攜帶的首部欄位

程式碼例項

這裡寫了個 demo,一步步來分析。目錄如下:

.
├── README.md
├── client
│   ├── index.html
│   └── request.js
└── server
    ├── pom.xml
    ├── server-web
    │   ├── pom.xml
    │   ├── server-web.iml
    │   └── src
    │       └── main
    │           ├── java
    │           │   └── com
    │           │       └── example
    │           │           └── cors
    │           │               ├── constant
    │           │               │   └── Constants.java
    │           │               ├── controller
    │           │               │   └── CorsController.java
    │           │               └── filter
    │           │                   └── CrossDomainFilter.java
    │           ├── resources
    │           │   └── config
    │           │       └── applicationContext-core.xml
    │           └── webapp
    │               ├── WEB-INF
    │               │   ├── dispatcher-servlet.xml
    │               │   └── web.xml
    │               └── index.jsp
    └── server.iml
複製程式碼

  • Client:前端,簡單的ajax請求

在client資料夾下,啟動靜態伺服器,前端頁面通過http://localhost:8000/index.html訪問:

anywhere -h localhost -p 8000
複製程式碼

  • Server: java專案,SpringMVC

在 IntelliJ IDEA 中本地啟動 tomcat,設定host: http://localhost:8080/,服務端資料通過http://localhost:8080/server/cors請求。

這裡前端和後端因為埠號不同,存在跨域限制,下面通過 CORS 來解決因為跨域無法通過ajax請求資料的問題。


沒有允許跨域的情況

這種情況就是前端什麼都不做,服務端也什麼都不做。

Client: 請求成功後,將資料顯示在頁面上

new Request().send('http://localhost:8080/server/cors',{
	success: function(data){
		document.write(data)
	}
});
複製程式碼

Server:

@Controller
@RequestMapping("/server")
public class CorsController {
    @RequestMapping(value="/cors", method= RequestMethod.GET)
    @ResponseBody
    public String ajaxCors(HttpServletRequest request) throws Exception{
        return "SUCCESS";
    }
}
複製程式碼

在瀏覽器位址列輸入http://localhost:8080/server/cors直接請求服務端,可以看到返回結果: ‘SUCCESS’

server_1.png

在瀏覽器位址列輸入http://localhost:8000/index.html,從不同域的網頁中向 Server 傳送 ajax 請求。可以看到幾個方面:

從 network 可以看到,請求返回正常。

client_1_network.png

但 Response 中沒有內容,顯示 Failed to load response data

client_network_response.png

並且控制檯報錯:

client_1_console_error.png

總結:

1、瀏覽器請求是發出去了的,服務端也會正確返回,但是我們拿不到response的內容

2、瀏覽器控制檯會報錯提示可以怎麼做,而且提示的很明白: xhr不能請求http://localhost:8080/server/cors,請求資源的響應頭中沒有設定Access-Control-Allow-Origin,Origin:http://localhost:8000是不允許跨域請求的。

那下一步,我們要在服務端響應跨域請求時,設定響應頭: Access-Control-Allow-Origin

設定 Access-Control-Allow-Origin 允許跨域

先說明為什麼要設定 Access-Control-Allow-Origin,可以把 Access-Control-Allow-Origin 當作一個指令,服務端設定 Access-Control-Allow-Origin 就是告訴瀏覽器允許向服務端請求資源的域名,瀏覽器通過 Response 中的 Access-Control-Allow-Origin 就可以知道能不能把資料吐出來。

官方解釋是這樣的: Access-Control-Allow-Origin 響應頭指定了該響應的資源是否被允許與給定的 origin 共享。

Access-Control-Allow-Origin可以設定的值有:

Access-Control-Allow-Origin: *
Access-Control-Allow-Origin:
複製程式碼

那在java服務端給響應頭設定 Access-Control-Allow-Origin 可以這麼做:

1、新增一個過濾器

public class CrossDomainFilter implements Filter{
    public void init(FilterConfig filterConfig) throws ServletException {}
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse resp = (HttpServletResponse)servletResponse;
        resp.setHeader("Access-Control-Allow-Origin", "http://localhost:8000");
        filterChain.doFilter(servletRequest,servletResponse);
    }
    public void destroy() {}
}
複製程式碼

2、然後在web.xml檔案中新增過濾器配置:

<filter>
     <filter-name>crossDomainFilter</filter-name>
     <filter-class>com.example.cors.filter.CrossDomainFilter</filter-class>
</filter>
<filter-mapping>
      <filter-name>crossDomainFilter</filter-name>
      <url-pattern>/*</url-pattern>
</filter-mapping>
複製程式碼

3、然後重新啟動tomcat,client重新傳送請求http://localhost:8000/index.html

client_2_access_success.png

client_2_access_response.png

可以看到我們能夠拿到返回結果了,響應頭中有我們在服務端設定的Access-Control-Allow-Origin: http://localhost:8000,這個應該跟請求頭中的origin一致,或者設定Access-Control-Allow-Origin:*也是可以的,這就允許任何網站來訪問資源了(前提是不帶憑證資訊,這個後面講)

以上就是允許一個簡單的跨域請求的做法,只需要服務端設定響應頭Access-Control-Allow-Origin。

簡單請求與預檢請求

上面講述了一個簡單請求通過在服務端設定響應頭 Access-Control-Allow-Origin 就可以完成跨域請求。

那怎樣的請求算是一個簡單請求?與簡單請求相對應的是什麼樣的請求呢?解決跨域的方式又有什麼不一樣呢?

符合以下條件的可視為簡單請求:

1、使用下列 HTTP 方法之一

- GET
- HEAD
- POST,並且Content-Type的值在下列之一:
  - text/plain
  - multipart/form-data
  - application/x-www-form-urlencoded
複製程式碼

2、並且請求頭中只有下面這些

- Accept
- Accept-Language
- Content-Language
- Content-Type (需要注意額外的限制)
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
複製程式碼

不滿足上述要求的在傳送正式請求前都要先傳送一個預檢請求,預檢請求以 OPTIONS 方法傳送,瀏覽器通過請求方法和請求頭能夠判斷是否傳送預檢請求。

比如 Client 傳送如下請求:

new Request().send('http://localhost:8080/server/options',{
	method: 'POST',
	header: {
		'Content-Type': 'application/json'  //告訴伺服器實際傳送的資料型別
	},
	success: function(data){
		document.write(data)
	}
});
複製程式碼

Server 端處理請求的 controller:

@Controller
@RequestMapping("/server")
public class CorsController {
    @RequestMapping(value="/options", method= RequestMethod.POST)
    @ResponseBody
    public String options(HttpServletRequest request) throws Exception{
        return "SUCCESS";
    }
}
複製程式碼

因為請求時,請求頭中塞入了 header,'Content-Type': 'application/json'。根據前面講述的可以知道,瀏覽器會以 OPTIONS 方法發出一個預檢請求,瀏覽器會在請求頭中加入:

Access-Control-Request-Headers:content-type
Access-Control-Request-Method:POST
複製程式碼

這個預檢請求的作用在這裡就是告訴伺服器:我會在後面請求中以 POST 方法傳送 Request Header 帶有 Content-Type 的請求,詢問伺服器是否允許。 。

client_3_options_error.png

在這裡伺服器還沒有做任何允許這種請求的設定,所以瀏覽器控制檯報錯:

client_3_options_console_error.png

也清楚的說明了出錯的原因: 服務端在預檢請求的響應中沒有告訴瀏覽器允許協議頭 Content-Type,即服務端需要設定響應頭 Access-Control-Allow-Headers,允許瀏覽器傳送帶 Content-Type 的請求。

Server端過濾器中新增Access-Control-Allow-Headers:

public class CrossDomainFilter implements Filter{
    public void init(FilterConfig filterConfig) throws ServletException {}
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse resp = (HttpServletResponse)servletResponse;
        resp.setHeader("Access-Control-Allow-Origin", "http://localhost:8000");
        resp.setHeader("Access-Control-Allow-Headers", "Content-Type");
        filterChain.doFilter(servletRequest,servletResponse);
    }
    public void destroy() {}
}
複製程式碼

可以看到請求成功

client_3_options_console_success.png

再來看請求的具體資訊,第一次以 OPTIONS 方法傳送預檢請求,瀏覽器設定請求頭:

Access-Control-Request-Headers:content-type //請求中加入的請求頭
Access-Control-Request-Method:POST  //跨域請求的方法
複製程式碼

服務端設定響應頭:

Access-Control-Allow-Headers:Content-Type   //允許的header
Access-Control-Allow-Origin:http://localhost:8000 //允許跨域的源
複製程式碼

client_3_options_success.png

也可以設定 Access-Control-Allow-Methods 來限制客戶端的的請求方法。

這樣預檢請求成功了,瀏覽器會發出第二個請求,這是真正請求資料的請求:

client_3_options_2_post.png

可以看到 POST 請求成功了,第二次請求頭中沒有設定 Access-Control-Request-Headers 和 Access-Control-Request-Method。

但是這裡有個問題,需要預檢請求時,瀏覽器會發出兩次請求,一次 OPTIONS,一次 POST。兩次都返回了資料。這樣服務端如果邏輯複雜一些,比如去資料庫查詢資料,從 web 層、 service 到資料庫這段邏輯就會走兩遍,瀏覽器會兩次拿到相同的資料,所以服務端的 filter 可以改一下,如果是 OPTIONS 請求,在設定完跨域請求響應頭後就不走後面的邏輯直接返回。

public class CrossDomainFilter implements Filter{
    public void init(FilterConfig filterConfig) throws ServletException {}
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse resp = (HttpServletResponse)servletResponse;
        resp.setHeader("Access-Control-Allow-Origin", "http://localhost:8000");
        resp.setHeader("Access-Control-Allow-Headers", "Content-Type");   
        //OPTION請求就直接返回
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        if (req.getMethod().equals("OPTIONS")) {
            resp.setStatus(200);
            resp.flushBuffer();
        }else {
            filterChain.doFilter(servletRequest,servletResponse);
        }
    }
    public void destroy() {}
}
複製程式碼

總結:

1、 對於 POST 請求設定響應頭Content-Type為某些值、自定義請求頭等情況,瀏覽器會先以OPTIONS方法傳送一個預檢請求,並設定相應的請求頭。

2、 服務端還是正常返回,但如果預檢請求響應頭中不設定相應的響應頭,預檢請求不通過,不會再發出第二次請求來獲取資料。

3、 服務端設定相應的響應頭,瀏覽器會發出第二個請求,並將服務端返回的資料吐出,我們可以獲得response的內容

帶憑證資訊的請求

還有一種情況我們經常遇到。瀏覽器在傳送請求時需要給服務端傳送 cookie,服務端根據 cookie 中的資訊做一些身份驗證等。

預設情況下,瀏覽器向不同域的傳送 ajax 請求,不會攜帶傳送 cookie 資訊。

Client:

var containerElem = document.getElementById('container')
new Request().send('http://localhost:8080/server/testCookie',{
	success: function(data){
		containerElem.innerHTML = data
	}
});
複製程式碼

Server:

@RequestMapping(value="/testCookie", method= RequestMethod.GET)
@ResponseBody
public String testCookie(HttpServletRequest request,HttpServletResponse response) throws Exception{
    String str = "SUCCESS";
    Cookie[] cookies = request.getCookies();
    String school = getSchool(cookies);
    if(school == null || school.length() == 0){
        addCookie(response);
    }
    return str + buildText(cookies);
}
複製程式碼

服務端收到請求,判斷 cookie 中有沒有 school,沒有就新增 cookie.

client_4_cookie_none.png

可以看到響應頭中有 Set-Cookie,再次請求時,如果是同源請求,瀏覽器會將 Set-Cookie 中的值放在請求頭中,但是對於跨域請求,預設是不傳送這個 Cookie 的。

如果要讓瀏覽器傳送 cookie,需要在 Client 設定 XMLHttpRequest 的 withCredentials 屬性為 true。

Client:

var containerElem = document.getElementById('container')
new Request().send('http://localhost:8080/server/testCookie',{
	withCredentials: true,
	success: function(data){
		containerElem.innerHTML = data
	}
});
複製程式碼

現在瀏覽器在請求頭中加入了 cookie 資訊

client_4_cookie_error.png

但是服務端返回的資料沒有在頁面中展示,並且報錯:

client_4_credentials_error.png

報錯資訊很明白: 當請求中包含憑證資訊時,需要設定響應頭 Access-Control-Allow-Credentials,是否帶憑證資訊是由 XMLHttpRequest的withCredentials 屬性控制的。
**
所以我們在 Server 端 filter 中加入這個響應頭:

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse resp = (HttpServletResponse)servletResponse;
        resp.setHeader("Access-Control-Allow-Origin", "http://localhost:8000");
        resp.setHeader("Access-Control-Allow-Credentials","true");
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        if (req.getMethod().equals("OPTIONS")) {
            resp.setStatus(200);
            resp.flushBuffer();
        }else {
            filterChain.doFilter(servletRequest,servletResponse);
        }
    }
複製程式碼

現在瀏覽器知道響應頭中 Access-Control-Allow-Credentials 為 true,就會把資料給吐出來了,我們能夠從response 中拿到內容了。

client_4_cookie_success_console.png

client_4_cookie_success.png

那如果附帶憑證資訊並且有預檢請求呢?如果有預檢請求,並附帶憑證資訊( XMLHttpRequest 的withCredentials 設定為 true), 服務端需要設定 Access-Control-Allow-Credentials: true,否則瀏覽器不會發出第二次請求,並報錯。

client_4_cookie_preflight_error.png

總結:

1、跨域請求時,瀏覽器預設不會傳送cookie,需要設定XMLHttpRequest的withCredentials屬性為true

2、 瀏覽器設定XMLHttpRequest的withCredentials屬性為true,表明要向服務端傳送憑證資訊(這裡是cookie)。那麼服務端就需要在響應頭中新增Access-Control-Allow-Credentials為true。否則瀏覽器上有兩種情況:

  • 如果是簡單請求,服務端結果吐出了,瀏覽器拿到了但就是不給吐出來,並報錯。
  • 如果是預檢請求,同樣我們拿不到返回結果,並報錯提示預檢請求不通過,不會再發第二次請求。

其他

cookie 的同源策略

另外就是設定了 XMLHttpRequest 的 withCredentials 屬性為 true,瀏覽器發出去了,服務端還是拿不到 cookie的問題。

cookie 也遵循同源策略的,在設定 cookie 的時候可以發現除了鍵值對,還可以設定 cookie 的這些值:

cookie屬性值 說明
path 可訪問 cookie 的路徑,預設為當前文件位置的路徑
domain 可訪問 cookie 的域名,預設為當前文件位置的路徑的域名部分
max-age 多久後失效,秒為單位時間。
負數:session 內有效;0:刪除 cookie;正數:有效期為建立時刻 + max-age
expires cookie 失效日期.如果沒有定義,cookie 會在對話結束時過期,即會話 cookie
secure cookie 只通過 https 協議傳輸

如果獲取不到 cookie,可以檢查下 cookie 的 domain 和 path.

IE 上跨域訪問沒有許可權

在跨域傳送 ajax 請求時提示沒有許可權。 因為IE瀏覽器預設對跨域訪問有限制。需要在瀏覽器設定中去除限制。
方法: 設定 > Internet 選項 > 安全 > 自定義級別 > 在設定中找到其他 - 在【其他】中將【通過域訪問資料來源】啟用。

Demo 原始碼

CORS Demo 原始碼

參考


有收穫嗎? 看看這篇? 你可能不知道的 JavaScript 模組化野史

相關文章