Cors跨域(二):实现跨域Cookie共享的三要素


高考不努力,工地里当兄弟

前言

你好,我是YourBatman

上篇文章(Cors跨域(一):深入理解跨域请求概念及其根因)用超万字的篇幅把Cors几乎所有概念都扫盲了,接下来将逐步提出解决方案等实战性问题以及查漏补缺。

本文主角是大家耳熟能详的Cookie,聊聊它在跨域情况下如何实现“共享”?大家都知道Cookie是需要遵守同源策略(SameSite)的,本文将以跨域Cookie信息共享为场景,进一步加深对Cors的了解。

本文提纲

版本约定

  • JDK:8
  • Servlet:4.x
  • Tomcat:9.x

正文

Cookie是做web开发绕不过去的一个概念,即使随着JWT技术的出现它早已褪色不少,但依旧有其发光发热之地。譬如一些内网后台管理系统、Portal门户、SSO统一登录等场景…

如若你是新时代的程序员朋友,可能从未使用过Cookie,但肯定听过它的“传说”。作为本文的主角,那么我们就来先认识下这位“老朋友”吧。

重识Cookie

Cookie中文名:曲奇饼干。

当然,我们在与他人沟通时可不要使用中文名,还是使用Cookie本名吧~

什么是Cookie

一个看似简单,实则不好回答的一个问题。

众所周知,Http是无状态协议(Tips:不要问我什么叫无状态哈),每次请求都是对等的(从0开始的),服务器不知道用户上一次做了什么,这严重阻碍了 交互式 Web应用程序的实现。有些场景服务端需要知道用户的访问状态(如登录状态),这个时候怎么办?

针对这种场景其实很容想到解决办法:你来访问我服务端的时候,我给你一个“东西”,然后下次你再访问(注意是访问我才携带哦)的时候把它带过来我就知道是你啦,简单交互图如下:

这里交互中所指的“东西”,在Web领域它就是Cookie。Cookie就是用来绕开HTTP的无状态性的手段,它是Web的标准技术(是web标准而不局限于只是Servlet),隶属于RFC6265,现今的所有的浏览器、服务器均实现了此规范。

用一个20年前就用的比喻再补充解释下:你去银行卡里存钱,第一次去银行银行会给你办一张银行卡(里面存放着你的姓名、身份证、余额等信息)。下次你再去银行的时候,只需带着这张银行卡银行就可以“识别”你,从而就可以存/取钱了。这里的银行卡就类同于Http请求里的Cookie概念。

基于此银行(卡)的比喻举一反三,类比解释同域Cookie、不同域Cookie、跨域Cookie共享的含义:

  • 同域Cookie:每次访问的是同一个域下的不同页面、API(每次去的是同一家银行的不同网点,带上这家银行卡即可识别身份)
  • 不同域Cookie:同一个浏览器窗口内可能同时访问A网站和B网站,它们均有各自的Cookie,但访问A时只会带上A的Cookie(你可能有不同银行的多张银行卡,而去某个银行时只有带着他们家的银行卡才去有用嘛)
  • 跨域Cookie共享:访问A站点时已经登录从而保存姓名、头像等基本信息,这时访问该公司的B站点时就自然而然的能显示出这些基本信息,也就是实现信息共享(在银联体系中A银行办理的卡也能在B银行能取出钱来,也就是实现余额“共享”)

说明:Cookie实现跨域共享要求根域必须是一样才行,比如都是www.baidu.com和map.baidu.com的根域都是 baidu.com。这道理就相当于只有加入了银联的银行才能用银行卡去任意一家银联成员行取钱一样

Cookie的交互机制

下面这张图完整的说明了Cookie的交互机制,共四个步骤:

  1. 浏览器(客户端)发送一个请求到服务器
  2. 服务器响应。并在HttpResponse里增加一个响应头:Set-Cookie
  3. 浏览器保存此cookie在本地,然后以后每次请求都带着它,且请求头为:Cookie
  4. 服务器收到请求便可读取到此Cookie,做相应逻辑后给出响应

由此可见,Cookie用于保持请求状态,而这个状态依赖于浏览器端(客户端)的本地存储。

代码示例

概念聊了有一会了,写几句代码放松放松。下面演示一下这个交互过程:

服务端代码:首次请求种植Cookie,以后(请求携带了)就只打印输出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 = "/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,可以看到响应里带有Cookie头信息Set-Cookie告知浏览器要保存此Cookie,如下所示:

浏览器收到响应,并且依照Set-Cookie这个响应头,在本地存储上此Cookie(至于存在内存还是硬盘上,请参照文下的生命周期部分分解):

说明:除了name和age之外的cookie键值对不用关心,由于使用IDEA作为服务器交互的缘故才产生了它们

再次发送本请求,它会将此域的Cookie全都都携带发给后端服务器,如下图所示:

服务端打印输出:可以看到服务端收到浏览器发送过来的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一次完整的交互过程。

这里有个细节需要特别注意:name和age的maxAge属性值均为-1,表示这套cookie是会话级别的。也就是说你若换一个会话(如:重新打开浏览器的一个无痕窗口(不是标签页)),发送一个同样的请求http://localhost:8080/cookie,请求头里将看不到Cookie的任何踪影,服务端会给其生成一套新Cookie。如下图所示:
新会话的第一次请求
新会话第一次请求的响应

Cookie的生命周期

缺省情况下,Cookie的生命周期是Session级别(会话级别)。若想用Cookie进行状态保存、资源共享,服务端一般都会给其设置一个过期时间maxAge,短则1小时、1天,长则1星期、1个月甚至永久,这就是Cookie的生命(周期)。

Cookie的存储形式,根据其生命周期的不同而不同。这由maxAge属性决定,共有这三种情况:

  1. maxAge > 0:cookie不仅内存里有,还会持久化到硬盘,也叫持久Cookie。这样的话即使你关机重启(甚至过几天再访问),这个cookie依旧存在,请求时依旧会携带
  2. maxAge < 0:一般值为-1,也就临时Cookie。该Cookie只在内存中有(如session级别),一旦管理浏览器此Cookie将不复存在。值得注意的是:若使用无痕模式访问也是不会携带此Cookie的哟
  3. maxAge = 0:内存中没有,硬盘中也没有了,也就立即删除Cookie。此种case存在的唯一目的:服务浏览器可能的已存在的cookie,让其立马失效(消失)

Tips:请注意maxAge<0(负数)和maxAge=0的区别。前者会存在于内存,只有关闭浏览器or重启才失效;后者是立即删除

当然啦,Cookie的生命周期除了受到后端设置的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();
        }
    }
  • 浏览器的开发者工具操作Cookie

Cookie的安全性和劣势

Cookie存储在客户端,正所谓客户端的所有东西都认为不是安全的,因此敏感的数据(比如密码)尽量不要放在Cookie里。Cookie能提高访问服务端的效率,但是安全性较差!

Cookie虽然有不少优点,但它也有如下明显劣势:

  • 每次请求都会携带Cookie,这无形中增加了流量开销,这在移动端对流量敏感的场景下是不够友好的
  • Http请求中Cookie均为明文传输,所以安全性成问题(除非用Https)
  • Cookie有大小限制,一般最大为4kb,对于复杂的需求来讲就捉襟见肘

由于Cookie有不安全性和众多劣势,所以现在JWT大行其道。当然喽,很多时候Cookie依旧是最好用的,比如内网的管理端、Portal门户、UUAP统一登录等。

Cookie的域和路径

Cookie是不可以跨域的,隐私安全机制禁止网站非法获取其他网站(域)的Cookie。概念上咱不用长篇大论,举个例子你应该就懂了:

淘宝有两个页面:A页面a.taotao.com/index.html和B页面b.taotao.com/index.html,默认情况下A页面和B页面的Cookie是互相独立不能共享的。若现在有需要共享(如单点登录共享token ),我们只需要这么做:将A/B页面创建的Cookie的path设置为“/”,domain设置为“.taobtao.com”,那么位于a.taotao.com和b.taotao.com域下的所有页面都可以访问到这个Cookie了。

  • domain:创建此cookie的服务器主机名(or域名),服务端设置。但是不能将其设置为服务器所属域之外的域(若这都允许的话,你把Cookie的域都设置为baidu.com,那百度每次请求岂不要“累死”)
    • 注:端口和域无关,也就是说Cookie的域是不包括端口的
  • path:域下的哪些目录可以访问此cookie,默认为/,表示所有目录均可访问此cookie

跨域Cookie共享

三个关键词:跨域、Cookie、共享。Cookie是数据载体,跨域是场景,共享是需求。

代码模拟跨域Cookie共享

前端页面:发送跨域请求,为了方便模拟这里发送跨域的简单请求即可(还不知道什么叫简单请求?戳这里

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Cookie交互机制(跨域)</title>
    <!--导入Jquery-->
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>
<button id="btn">Cookie交互机制(跨域)</button>
<div id="content"></div>

<script>
    $("#btn").click(function () {
        $.get("http://localhost:8080/corscookie");
    });
</script>
</body>
</html>

前端页面托管在本地的63342端口上:http://localhost:63342/...

后端代码:后端接口托管在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");
    }
}

点击按钮,发送请求:
跨域请求详情
跨域请求响应详情
注意看,服务端代码虽然resp.addCookie(cookie);添加了Cookie,但是Response响应里并没有Set-Cookie这个头哦。查看浏览器发现木有Cookie:

也许你会说,当然没有啦,因为Response里没有Set-Cookie头嘛,但我们代码里明明已经addCookie了呀。

这半截理论当然没问题,现在我在服务端程序里补充一个响应头:

private void setCrosHeader(HttpServletResponse resp) {
    resp.setHeader("Access-Control-Allow-Origin", "http://localhost:63342");
    resp.setHeader("Access-Control-Allow-Credentials", "true");
}

(重启服务端应用),再次发送请求,响应如下:

可以看到响应中已经有Set-Cookie响应头了,再次查看Cookie是否已被浏览器保存,同样的比比脸还干净:

浏览器没有存储Cookie。What?难道翻车了?No,下面教你如何解释以及怎么破?

跨域Cookie共享的关键点

这里要讨论的是跨域中Cookie的存储问题:默认情况下,浏览器是不会去为你保存下跨域请求响应的Cookie的。具体现象是:跨域请求的Response响应了即使有Set-Cookie响应头(且有值),浏览器收到后也是不会保存此cookie的。

要实现Cookie的跨域共享,有3个关键点:

  1. 服务端负责在响应中将Set-Cookie发出来(由Access-Control-Allow-Credentials响应头决定)
  2. 浏览器端只要响应里有Set-Cookie头,就将此Cookie存储(由异步对象的withCredentials属性决定)
  3. 浏览器端发现只要有Cookie,即使是跨域请求也将其带着(由异步对象的withCredentials属性决定)

为了满足这三个关键点,在实施层面就有三要素来指导我们开发来解决此类问题。

跨域Cookie共享的三要素

首先确保服务端能正确的在响应中有Set-Cookie响应头,这由Access-Control-Allow-Credentials: true来保证。因此服务端只需要做多加这一步即可:

resp.setHeader("Access-Control-Allow-Credentials", "true");

Access-Control-Allow-Credentials该头是可选的,是个bool值,它若为true就有两个作用:

  • 在跨域请求的响应中允许Set-Cookie响应头
  • 浏览器收到响应后,浏览器根据此头判断是否让自己的withCredentials属性生效

所以就来到了第二个要素:XMLHttpRequest对象的withCredentials属性。该属性是一个Boolean类型,它指示了是否该使用类似cookies,authorization headers(头部授权)或者TLS客户端证书这一类资格证书来创建一个跨站点访问控制(cross-site Access-Control)请求。

var xhr = new XMLHttpRequest();
...
xhr.withCredentials = true;

Jquery的Ajax写法与此不同,但底层原理一样

官方的语言理解起来总是那么晦涩,翻译成人话:当异步对象设置了withCredentials=true时,浏览器会保留下响应的Cookie等信息,并且下次发送请求时将其携带。因此要指示浏览器存储Cookie并且每次跨域请求都携带,仅需加上此参数即可:

$.ajax({
    url: "http://localhost:8080/corscookie",
    type: "GET",
    xhrFields: {
        withCredentials: true
    },
    crossDomain: true
});

以上两个要素完成后,影响“结果”的还有最后一个要素。这个要素比较隐晦,也是很多同学/文章忽略的点。

服务端的Access-Control-Allow-Origin这个响应头的值不能是通配符*,而只能是具体的值。否则出现报错:

换句话讲:浏览器端跨域请求对象一旦开启withCredentials=true属性,服务端跨域Origin将不能再用*通配符,否则CORS error!

三要素都满足后(Access-Control-Allow-Credentials:true;Access-Control-Allow-Origin:http://localhost:63342;withCredentials=true),再次点击发送请求,结果如下:
首次跨域请求的响应
首次跨域请求后浏览器存储了Cookie
第二次跨域请求携带了Cookie
完美。

总结

上篇文章对Cors进行了全面介绍,本文以跨域Cookie共享为场景,很好的对跨域知识点进行了补充,并且也补足了Cors里一个重要的响应头Access-Control-Allow-Credentials的解释,相信通过本文同学你能加深对Web中Cookie的了解,以及跨域情况下Cookie信息如何共享。

本系列下篇将着眼于跨域请求解决方案的阐述,欢迎关注。

本文思考题

本文已被https://yourbatman.cn收录。所属专栏:BATutopia-Cors跨域,后台回复“专栏列表”即可查看详情。

看完了不一定懂,看懂了不一定会。来,3个思考题帮你复盘:

  1. Access-Control-Allow-Origin值设置为通配符*是万金油吗?
  2. 如何通过Cookie技术实现SSO单点登录?
  3. 实现跨域Cookie共享的三要素是什么?

推荐阅读

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('私聊YourBatman:fsx1056342982');
console.log("点个赞吧!");
NSLog(@"关注【BAT的乌托邦】!");
print("私聊YourBatman:fsx1056342982");
echo("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
printf("私聊YourBatman:fsx1056342982");
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("私聊YourBatman:fsx1056342982");
alert("点个赞吧!");

YourBatman:Java架构师,领域专家,Spring Framework开源贡献者。致力于写纯粹技术专栏,不哗众取宠。成系列的技术文修行起来会较痛苦,但做难事必有所得嘛,共勉。注重基本功修养,底层基础决定上层建筑。现有IDEA系列、Spring技术栈系列、Bean Validation系列、Java日期时间系列……关注免费获取

BAT的乌托邦


作者: YourBatman
声明: 本站文章均采用 CC BY 4.0 许可协议。你懂的!
  目录