經常被問到一些問題,比如寫 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’
在瀏覽器位址列輸入http://localhost:8000/index.html
,從不同域的網頁中向 Server 傳送 ajax 請求。可以看到幾個方面:
從 network 可以看到,請求返回正常。
但 Response 中沒有內容,顯示 Failed to load response data
。
並且控制檯報錯:
總結:
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
可以看到我們能夠拿到返回結果了,響應頭中有我們在服務端設定的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 的請求,詢問伺服器是否允許。 。
在這裡伺服器還沒有做任何允許這種請求的設定,所以瀏覽器控制檯報錯:
也清楚的說明了出錯的原因: 服務端在預檢請求的響應中沒有告訴瀏覽器允許協議頭 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() {}
}
複製程式碼
可以看到請求成功
再來看請求的具體資訊,第一次以 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 //允許跨域的源
複製程式碼
也可以設定 Access-Control-Allow-Methods 來限制客戶端的的請求方法。
這樣預檢請求成功了,瀏覽器會發出第二個請求,這是真正請求資料的請求:
可以看到 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.
可以看到響應頭中有 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 資訊
但是服務端返回的資料沒有在頁面中展示,並且報錯:
報錯資訊很明白: 當請求中包含憑證資訊時,需要設定響應頭 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 中拿到內容了。
那如果附帶憑證資訊並且有預檢請求呢?如果有預檢請求,並附帶憑證資訊( XMLHttpRequest 的withCredentials 設定為 true), 服務端需要設定 Access-Control-Allow-Credentials: true,否則瀏覽器不會發出第二次請求,並報錯。
總結:
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 原始碼
參考
有收穫嗎? 看看這篇? 你可能不知道的 JavaScript 模組化野史