Cors跨域(二):實現(xiàn)跨域Cookie共享的三要素
前言
你好,我是YourBatman。
上篇文章(Cors跨域(一):深入理解跨域請求概念及其根因)用超萬字的篇幅把Cors幾乎所有概念都掃盲了,接下來將逐步提出解決方案等實戰(zhàn)性問題以及查漏補(bǔ)缺。
本文主角是大家耳熟能詳?shù)腃ookie,聊聊它在跨域情況下如何實現(xiàn)“共享”?大家都知道Cookie是需要遵守同源策略(SameSite)的,本文將以跨域Cookie信息共享為場景,進(jìn)一步加深對Cors的了解。
本文提綱
版本約定
- JDK:8
- Servlet:4.x
- Tomcat:9.x
正文
Cookie是做web開發(fā)繞不過去的一個概念,即使隨著JWT技術(shù)的出現(xiàn)它早已褪色不少,但依舊有其發(fā)光發(fā)熱之地。譬如一些內(nèi)網(wǎng)后臺管理系統(tǒng)、Portal門戶、SSO統(tǒng)一登錄等場景...
如若你是新時代的程序員朋友,可能從未使用過Cookie,但肯定聽過它的“傳說”。作為本文的主角,那么我們就來先認(rèn)識下這位“老朋友”吧。
重識Cookie
Cookie中文名:曲奇餅干。
當(dāng)然,我們在與他人溝通時可不要使用中文名,還是使用Cookie本名吧~
什么是Cookie
一個看似簡單,實則不好回答的一個問題。
眾所周知,Http是無狀態(tài)協(xié)議(Tips:不要問我什么叫無狀態(tài)哈),每次請求都是對等的(從0開始的),服務(wù)器不知道用戶上一次做了什么,這嚴(yán)重阻礙了 交互式 Web應(yīng)用程序的實現(xiàn)。有些場景服務(wù)端需要知道用戶的訪問狀態(tài)(如登錄狀態(tài)),這個時候怎么辦?
針對這種場景其實很容想到解決辦法:你來訪問我服務(wù)端的時候,我給你一個“東西”,然后下次你再訪問我(注意是訪問我才攜帶哦)的時候把它帶過來我就知道是你啦,簡單交互圖如下:
這里交互中所指的“東西”,在Web領(lǐng)域它就是Cookie。Cookie就是用來繞開HTTP的無狀態(tài)性的手段,它是Web的標(biāo)準(zhǔn)技術(shù)(是web標(biāo)準(zhǔn)而不局限于只是Servlet),隸屬于RFC6265,現(xiàn)今的所有的瀏覽器、服務(wù)器均實現(xiàn)了此規(guī)范。
用一個20年前就用的比喻再補(bǔ)充解釋下:你去銀行卡里存錢,第一次去銀行銀行會給你辦一張銀行卡(里面存放著你的姓名、身份證、余額等信息)。下次你再去銀行的時候,只需帶著這張銀行卡銀行就可以“識別”你,從而就可以存/取錢了。這里的銀行卡就類同于Http請求里的Cookie概念。
基于此銀行(卡)的比喻舉一反三,類比解釋同域Cookie、不同域Cookie、跨域Cookie共享的含義:
- 同域Cookie:每次訪問的是同一個域下的不同頁面、API(每次去的是同一家銀行的不同網(wǎng)點(diǎn),帶上這家銀行卡即可識別身份)
- 不同域Cookie:同一個瀏覽器窗口內(nèi)可能同時訪問A網(wǎng)站和B網(wǎng)站,它們均有各自的Cookie,但訪問A時只會帶上A的Cookie(你可能有不同銀行的多張銀行卡,而去某個銀行時只有帶著他們家的銀行卡才去有用嘛)
- 跨域Cookie共享:訪問A站點(diǎn)時已經(jīng)登錄從而保存姓名、頭像等基本信息,這時訪問該公司的B站點(diǎn)時就自然而然的能顯示出這些基本信息,也就是實現(xiàn)信息共享(在銀聯(lián)體系中A銀行辦理的卡也能在B銀行能取出錢來,也就是實現(xiàn)余額“共享”)
❝說明:Cookie實現(xiàn)跨域共享要求根域必須是一樣才行,比如都是www.baidu.com和map.baidu.com的根域都是 baidu.com。這道理就相當(dāng)于只有加入了銀聯(lián)的銀行才能用銀行卡去任意一家銀聯(lián)成員行取錢一樣❞
Cookie的交互機(jī)制
下面這張圖完整的說明了Cookie的交互機(jī)制,共四個步驟:
- 瀏覽器(客戶端)發(fā)送一個請求到服務(wù)器
- 服務(wù)器響應(yīng)。并在HttpResponse里增加一個響應(yīng)頭:Set-Cookie
- 瀏覽器保存此cookie在本地,然后以后每次請求都帶著它,且請求頭為:Cookie
- 服務(wù)器收到請求便可讀取到此Cookie,做相應(yīng)邏輯后給出響應(yīng)
由此可見,Cookie用于保持請求狀態(tài),而這個狀態(tài)依賴于瀏覽器端(客戶端)的本地存儲。
代碼示例
概念聊了有一會了,寫幾句代碼放松放松。下面演示一下這個交互過程:
服務(wù)端代碼:首次請求種植Cookie,以后(請求攜帶了)就只打印輸出Cookie內(nèi)容
- /**
- * 在此處添加備注信息
- *
- * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a>
- * @site https://yourbatman.cn
- * @date 2021/6/9 10:36
- * @since 0.0.1
- */
- @Slf4j
- @WebServlet(urlPatterns = "/cookie")
- public class CookieServlet extends HttpServlet {
- @Override
- protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
- String requestURI = req.getRequestURI();
- String method = req.getMethod();
- String originHeader = req.getHeader("Origin");
- log.info("收到請求:{},方法:{}, Origin頭:{}", requestURI, method, originHeader);
- // 讀取Cookie
- List<Cookie> myCookies = new ArrayList<>();
- if (req.getCookies() != null) {
- myCookies = Arrays.stream(req.getCookies()).filter(c -> c.getName().equals("name") || c.getName().equals("age")).collect(toList());
- }
- if (myCookies.isEmpty()) { // 種植Cookie
- Cookie cookie = new Cookie("name", "YourBatman");
- // cookie.setDomain("baidu.com");
- cookie.setMaxAge(3600);
- resp.addCookie(cookie);
- cookie = new Cookie("age", "18");
- cookie.setMaxAge(3600);
- resp.addCookie(cookie);
- } else {
- myCookies.stream().forEach(c -> {
- log.info("name:{} value:{} domain:{} path:{} maxAge:{} secure:{}", c.getName(), c.getValue(), c.getDomain(), c.getPath(), c.getMaxAge(), c.getVersion(), c.getSecure());
- });
- }
- resp.getWriter().write("hello cookie...");
- }
- }
瀏覽器訪問:http://localhost:8080/cookie,可以看到響應(yīng)里帶有Cookie頭信息Set-Cookie告知瀏覽器要保存此Cookie,如下所示:
瀏覽器收到響應(yīng),并且依照Set-Cookie這個響應(yīng)頭,在本地存儲上此Cookie(至于存在內(nèi)存還是硬盤上,請參照文下的生命周期部分分解):
❝說明:除了name和age之外的cookie鍵值對不用關(guān)心,由于使用IDEA作為服務(wù)器交互的緣故才產(chǎn)生了它們❞再次發(fā)送本請求,它會將此域的Cookie全都都攜帶發(fā)給后端服務(wù)器,如下圖所示:
服務(wù)端打印輸出:可以看到服務(wù)端收到瀏覽器發(fā)送過來的Cookie了
- INFO c.y.cors.java.servlet.CookieServlet - 收到請求:/cookie,方法:GET, Origin頭:null
- INFO c.y.cors.java.servlet.CookieServlet - name:name value:YourBatman domain:null path:null maxAge:-1 secure:0
- INFO c.y.cors.java.servlet.CookieServlet - name:age value:18 domain:null path:null maxAge:-1 secure:0
這就是Cookie一次完整的交互過程。
這里有個細(xì)節(jié)需要特別注意:name和age的maxAge屬性值均為-1,表示這套cookie是會話級別的。也就是說你若換一個會話(如:重新打開瀏覽器的一個無痕窗口(不是標(biāo)簽頁)),發(fā)送一個同樣的請求http://localhost:8080/cookie,請求頭里將看不到Cookie的任何蹤影,服務(wù)端會給其生成一套新Cookie。如下圖所示:
Cookie的生命周期
缺省情況下,Cookie的生命周期是Session級別(會話級別)。若想用Cookie進(jìn)行狀態(tài)保存、資源共享,服務(wù)端一般都會給其設(shè)置一個過期時間maxAge,短則1小時、1天,長則1星期、1個月甚至永久,這就是Cookie的生命(周期)。
Cookie的存儲形式,根據(jù)其生命周期的不同而不同。這由maxAge屬性決定,共有這三種情況:
- maxAge > 0:cookie不僅內(nèi)存里有,還會持久化到硬盤,也叫持久Cookie。這樣的話即使你關(guān)機(jī)重啟(甚至過幾天再訪問),這個cookie依舊存在,請求時依舊會攜帶
- maxAge < 0:一般值為-1,也就臨時Cookie。該Cookie只在內(nèi)存中有(如session級別),一旦管理瀏覽器此Cookie將不復(fù)存在。值得注意的是:若使用無痕模式訪問也是不會攜帶此Cookie的喲
- maxAge = 0:內(nèi)存中沒有,硬盤中也沒有了,也就立即刪除Cookie。此種case存在的唯一目的:服務(wù)瀏覽器可能的已存在的cookie,讓其立馬失效(消失)
❝Tips:請注意maxAge<0(負(fù)數(shù))和maxAge=0的區(qū)別。前者會存在于內(nèi)存,只有關(guān)閉瀏覽器or重啟才失效;后者是立即刪除❞當(dāng)然啦,Cookie的生命周期除了受到后端設(shè)置的Age值來決定外,還有兩種方式可“改變”它:
JavaScript操作Cookie
- // 取cookie:
- function getCookie(name) {
- var arr = document.cookie.split(';');
- for (var i = 0; i < arr.length; i++) {
- var arr2 = arr[i].split('=');
- var arrTest = arr2[0].trim(); // 此處的trim一定要加
- if (arrTest == name) {
- return arr2[1];
- }
- }
- }
- // 刪cookie:
- function delCookie(name) {
- var exp = new Date();
- exp.setTime(exp.getTime() - 1);
- var cval = getCookie(name);
- if (cval != null) {
- document.cookie = name + "=" + cval + ";expires=" + exp.toGMTString();
- }
- }
瀏覽器的開發(fā)者工具操作Cookie
Cookie的安全性和劣勢
Cookie存儲在客戶端,正所謂客戶端的所有東西都認(rèn)為不是安全的,因此敏感的數(shù)據(jù)(比如密碼)盡量不要放在Cookie里。Cookie能提高訪問服務(wù)端的效率,但是安全性較差!
Cookie雖然有不少優(yōu)點(diǎn),但它也有如下明顯劣勢:
- 每次請求都會攜帶Cookie,這無形中增加了流量開銷,這在移動端對流量敏感的場景下是不夠友好的
- Http請求中Cookie均為明文傳輸,所以安全性成問題(除非用Https)
- Cookie有大小限制,一般最大為4kb,對于復(fù)雜的需求來講就捉襟見肘
由于Cookie有不安全性和眾多劣勢,所以現(xiàn)在JWT大行其道。當(dāng)然嘍,很多時候Cookie依舊是最好用的,比如內(nèi)網(wǎng)的管理端、Portal門戶、UUAP統(tǒng)一登錄等。
Cookie的域和路徑
Cookie是不可以跨域的,隱私安全機(jī)制禁止網(wǎng)站非法獲取其他網(wǎng)站(域)的Cookie。概念上咱不用長篇大論,舉個例子你應(yīng)該就懂了:
❝淘寶有兩個頁面:A頁面a.taotao.com/index.html和B頁面b.taotao.com/index.html,默認(rèn)情況下A頁面和B頁面的Cookie是互相獨(dú)立不能共享的。若現(xiàn)在有需要共享(如單點(diǎn)登錄共享token ),我們只需要這么做:將A/B頁面創(chuàng)建的Cookie的path設(shè)置為“/”,domain設(shè)置為“.taobtao.com”,那么位于a.taotao.com和b.taotao.com域下的所有頁面都可以訪問到這個Cookie了。❞
- domain:創(chuàng)建此cookie的服務(wù)器主機(jī)名(or域名),服務(wù)端設(shè)置。但是不能將其設(shè)置為服務(wù)器所屬域之外的域(若這都允許的話,你把Cookie的域都設(shè)置為baidu.com,那百度每次請求豈不要“累死”)
注:端口和域無關(guān),也就是說Cookie的域是不包括端口的
- path:域下的哪些目錄可以訪問此cookie,默認(rèn)為/,表示所有目錄均可訪問此cookie
跨域Cookie共享
三個關(guān)鍵詞:跨域、Cookie、共享。Cookie是數(shù)據(jù)載體,跨域是場景,共享是需求。
代碼模擬跨域Cookie共享
前端頁面:發(fā)送跨域請求,為了方便模擬這里發(fā)送跨域的簡單請求即可(還不知道什么叫簡單請求?戳這里)
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <title>Cookie交互機(jī)制(跨域)</title>
- <!--導(dǎo)入Jquery-->
- <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
- </head>
- <body>
- <button id="btn">Cookie交互機(jī)制(跨域)</button>
- <div id="content"></div>
- <script>
- $("#btn").click(function () {
- $.get("http://localhost:8080/corscookie");
- });
- </script>
- </body>
- </html>
后端代碼:后端接口托管在8080端口上:http://localhost:8080/...
❝這就是最簡單的一個跨域場景,兩個域具有相同的domain,因此才有共享Cookie的可能。❞
- /**
- * 在此處添加備注信息
- *
- * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a>
- * @site https://yourbatman.cn
- * @date 2021/6/9 10:36
- * @since 0.0.1
- */
- @Slf4j
- @WebServlet(urlPatterns = "/corscookie")
- public class CorsCookieServlet extends HttpServlet {
- @Override
- protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
- String requestURI = req.getRequestURI();
- String method = req.getMethod();
- String originHeader = req.getHeader("Origin");
- log.info("收到請求:{},方法:{}, Origin頭:{}", requestURI, method, originHeader);
- // 讀取Cookie
- List<Cookie> myCookies = new ArrayList<>();
- if (req.getCookies() != null) {
- myCookies = Arrays.stream(req.getCookies()).filter(c -> c.getName().equals("name") || c.getName().equals("age")).collect(toList());
- }
- if (myCookies.isEmpty()) { // 種植Cookie
- Cookie cookie = new Cookie("name", "YourBatman");
- // cookie.setDomain("baidu.com");
- cookie.setMaxAge(3600);
- resp.addCookie(cookie);
- cookie = new Cookie("age", "18");
- cookie.setMaxAge(3600);
- resp.addCookie(cookie);
- } else {
- myCookies.stream().forEach(c -> {
- log.info("name:{} value:{} domain:{} path:{} maxAge:{} secure:{}", c.getName(), c.getValue(), c.getDomain(), c.getPath(), c.getMaxAge(), c.getVersion(), c.getSecure());
- });
- }
- setCrosHeader(resp);
- resp.getWriter().write("hello cookie...");
- }
- private void setCrosHeader(HttpServletResponse resp) {
- resp.setHeader("Access-Control-Allow-Origin", "http://localhost:63342");
- }
- }
點(diǎn)擊按鈕,發(fā)送請求:
注意看,服務(wù)端代碼雖然resp.addCookie(cookie);添加了Cookie,但是Response響應(yīng)里并沒有Set-Cookie這個頭哦。查看瀏覽器發(fā)現(xiàn)木有Cookie:
也許你會說,當(dāng)然沒有啦,因為Response里沒有Set-Cookie頭嘛,但我們代碼里明明已經(jīng)addCookie了呀。
這半截理論當(dāng)然沒問題,現(xiàn)在我在服務(wù)端程序里補(bǔ)充一個響應(yīng)頭:
- private void setCrosHeader(HttpServletResponse resp) {
- resp.setHeader("Access-Control-Allow-Origin", "http://localhost:63342");
- resp.setHeader("Access-Control-Allow-Credentials", "true");
- }
(重啟服務(wù)端應(yīng)用),再次發(fā)送請求,響應(yīng)如下:
可以看到響應(yīng)中已經(jīng)有Set-Cookie響應(yīng)頭了,再次查看Cookie是否已被瀏覽器保存,同樣的比比臉還干凈:
瀏覽器沒有存儲Cookie。What?難道翻車了?No,下面教你如何解釋以及怎么破?
跨域Cookie共享的關(guān)鍵點(diǎn)
這里要討論的是跨域中Cookie的存儲問題:默認(rèn)情況下,瀏覽器是不會去為你保存下跨域請求響應(yīng)的Cookie的。具體現(xiàn)象是:跨域請求的Response響應(yīng)了即使有Set-Cookie響應(yīng)頭(且有值),瀏覽器收到后也是不會保存此cookie的。
要實現(xiàn)Cookie的跨域共享,有3個關(guān)鍵點(diǎn):
- 服務(wù)端負(fù)責(zé)在響應(yīng)中將Set-Cookie發(fā)出來(由Access-Control-Allow-Credentials響應(yīng)頭決定)
- 瀏覽器端只要響應(yīng)里有Set-Cookie頭,就將此Cookie存儲(由異步對象的withCredentials屬性決定)
- 瀏覽器端發(fā)現(xiàn)只要有Cookie,即使是跨域請求也將其帶著(由異步對象的withCredentials屬性決定)
為了滿足這三個關(guān)鍵點(diǎn),在實施層面就有三要素來指導(dǎo)我們開發(fā)來解決此類問題。
跨域Cookie共享的三要素
首先確保服務(wù)端能正確的在響應(yīng)中有Set-Cookie響應(yīng)頭,這由Access-Control-Allow-Credentials: true來保證。因此服務(wù)端只需要做多加這一步即可:
- resp.setHeader("Access-Control-Allow-Credentials", "true");
Access-Control-Allow-Credentials該頭是可選的,是個bool值,它若為true就有兩個作用:
在跨域請求的響應(yīng)中允許Set-Cookie響應(yīng)頭
瀏覽器收到響應(yīng)后,瀏覽器根據(jù)此頭判斷是否讓自己的withCredentials屬性生效
所以就來到了第二個要素:XMLHttpRequest對象的withCredentials屬性。該屬性是一個Boolean類型,它指示了是否該使用類似cookies,authorization headers(頭部授權(quán))或者TLS客戶端證書這一類資格證書來創(chuàng)建一個跨站點(diǎn)訪問控制(cross-site Access-Control)請求。
- var xhr = new XMLHttpRequest();
- ...
- xhr.withCredentials = true;
❝Jquery的Ajax寫法與此不同,但底層原理一樣❞官方的語言理解起來總是那么晦澀,翻譯成人話:當(dāng)異步對象設(shè)置了withCredentials=true時,瀏覽器會保留下響應(yīng)的Cookie等信息,并且下次發(fā)送請求時將其攜帶。因此要指示瀏覽器存儲Cookie并且每次跨域請求都攜帶,僅需加上此參數(shù)即可:
- $.ajax({
- url: "http://localhost:8080/corscookie",
- type: "GET",
- xhrFields: {
- withCredentials: true
- },
- crossDomain: true
- });
以上兩個要素完成后,影響“結(jié)果”的還有最后一個要素。這個要素比較隱晦,也是很多同學(xué)/文章忽略的點(diǎn)。
服務(wù)端的Access-Control-Allow-Origin這個響應(yīng)頭的值不能是通配符*,而只能是具體的值。否則出現(xiàn)報錯:
換句話講:瀏覽器端跨域請求對象一旦開啟withCredentials=true屬性,服務(wù)端跨域Origin將不能再用*通配符,否則CORS error!
三要素都滿足后(Access-Control-Allow-Credentials:true;Access-Control-Allow-Origin:http://localhost:63342;withCredentials=true),再次點(diǎn)擊發(fā)送請求,結(jié)果如下:
完美。
總結(jié)上篇文章對Cors進(jìn)行了全面介紹,本文以跨域Cookie共享為場景,很好的對跨域知識點(diǎn)進(jìn)行了補(bǔ)充,并且也補(bǔ)足了Cors里一個重要的響應(yīng)頭Access-Control-Allow-Credentials的解釋,相信通過本文同學(xué)你能加深對Web中Cookie的了解,以及跨域情況下Cookie信息如何共享。
本文轉(zhuǎn)載自微信公眾號「BAT的烏托邦」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系BAT的烏托邦公眾號。