前端之道:浅谈cookie

语言: CN / TW / HK

依稀记得大学毕业刚入职的时候学的第一个东西:SSO(单点登录),那时候很懵懂,连最基础的Cookie都没有弄懂:flushed:。导师还叫我去给同年一起入职的其他童靴分享SSO,自然分享得一塌糊涂。

回头看,Cookie这个东西说简单也简单,说不简单也不简单。后台需要它维护会话,前端不仅需要它干很多事,还要担心它被盗用。

往前看,各大浏览器对Cookie在安全性等各方面提出了并落地了更加严格的新标准,每一个标准或多或少对业务有影响,都需要我们去关注。

LocalStorage的出现让很多人在一些数据的存储上毫不犹豫地选择了它,当然,大多数场景上是没有任何问题的。但是,从软件设计的角度出发,我们在做需求实现的时候还是需要做更多的选型对比,有完整的理论支撑才能让我们的产品更具健壮性和可维护性。

下面,跟着烧烤君彻底搞懂Cookie,让前端路上没有绊脚石。

Cookie 的由来——门票的故事

说到Cookie的由来,那就不得不说一下HTTP了。

无状态协议 HTTP

HTTP是无状态的协议,什么叫无状态?什么又叫有状态呢?

打个比方,有个旅游景区需要门票才能进去,这张门票有效期是一天,一天之内可以凭票随意出入。景区的售票处不管你是谁,只要你给钱它就会给一张门票。

假如某天你买了一张票,刚通过大门进景区发现电动车没锁,还好一天之内可以凭票随意出入,就出去锁电动车了,刚出景区大门就被锁了。这是你又不想重新买票,就想和景区门卫大爷说你已经买了票了而且刚才进去了。景区大爷肯定不认啊,我这里5A景区每天都数万人进进出出,谁知道你是不是骗子,正义不允许大爷放你进去。

如果别人捡到了或者你把门票转手给其他人了,别人可以拿着门票进出景区的。

很明显你进出景区对于门卫大爷来说是无状态的。有状态的是你手中的那张门票,大爷只认门票。

Cookie 的诞生

早期互联网的web网页是没有交互一说的,从一个页面点击跳转到另外一个页面这个行为服务器是无法得知的,这也是http无状态的结果。但是随着互联网慢慢的发展,互联网能够做给多的东西,交互式web应运而生,但是如何记录会话依旧是一个棘手的问题。

1994年,网景公司当时一名员工Lou Montulli(卢-蒙特利)在实现一个购物车功能的时候将Cookie首次应用性实现了。

基于当初网景浏览器的强大影响,这个Cookie就被各大浏览器所接受,并慢慢的形成了标准。

Cookie 就是那张门票

Cookie又称为”小饼干“,是网站为了辨别用户身份而储存在用户浏览器上的数据。当然,这里既然作为一种用户浏览器上的数据,前端工程师也经常使用它作为一种非持久化数据的存储方式。

HTTP请求中的Cookie

图示流程,服务端通过Response Headers中的Set-Cookie字段将Cookie存储在客户端。客户端再次请求同一个服务的时候会将前面设置的Cookie携带在Request Headers的Cookie字段中(这一行为是浏览器默认行为),服务端收到请求后解析Headers中的Cookie字段即可获取对应Cookie。

客户端浏览器中的Cookie是一种非持久化的数据。同时,浏览器会根据服务器设置Cookie的过期时间对Cookie有不同的处理:如果不设置过期时间,那么这个Cookie的生命周期仅仅是当前浏览器运行期间,浏览器关闭后自动清除。

Cookie 一张不简单的门票

Chrome浏览器界面F12打开控制台,打开Cookies查看页,可以看到Cookie的属性非常之多。

这些属性是什么意思,有什么作用呢?下面跟着烧烤君一起看看这张门票有多不简单!

name 和 value

Cookie的名称和值,Javascript可以通过下面的方式操作Cookie:

function setCookie(cname,cvalue){
    var d = new Date();
    d.setTime(d.getTime()+(exdays*24*60*60*1000));
    document.cookie = cname+"="+cvalue+"; "
}
function getCookie(cname){
    var name = cname + "=";
    var ca = document.cookie.split(';');
    for(var i=0; i<ca.length; i++) {
        var c = ca[i].trim();
        if (c.indexOf(name)===0) { return c.substring(name.length,c.length); }
    }
    return "";
}

看起来不是很智能的样子,高级程序员要学会找别人造的轮子:

​js-cookie:A simple, lightweight JavaScript API for handling cookies​

domain 和 path

domain 是指可以访问该Cookie的域名,path则是定义domain下的什么路径可以访问该Cookie。

当设置Cookie时没有设置domain的话,浏览器会默认使用当前域名。当前网页只能访问当前域名及当前域名的父级域名下的Cookie,如A.B.C.D 能访问A.B.C.D、B.C.D、C.D域名下的Cookie,同理,也只能修改或设置这些域名下的Cookie。

重要的,domain、path、name构成了Cookie的唯一性:

  • domain、path、name 都相同时,代表的是同一个Cookie, 重复设置将被覆盖
  • domain、path不同,name相同,代表的是不同的Cookie,可以共存

下图印证了这个唯一性:

expires / max-age

expires、max-age这两个属性都表示同一个意思:设置Cookie的过期时间。虽然表示的是同一个意思,但是这两个属性还是有差别的。

expires是最初推出Cookie概念的时候设下的标准,需要设置一个具体的GMT时间作为Cookie的过期时间,当浏览器检测到这个时间点已过期,将清清除对应的Cookie,如果直接设置一个过期时间,那么这个Cookie将不生效。

max-age是HTTP1.1推出用来替代expires的,它的单位是秒,代表设置Cookie存活时间。这里要注意一下,max-age 三种值有不同的效果:

  • max-age为负数,代表这个Cookie只是临时,在浏览器关闭后会被清除
  • max-age为0,代表这个Cookie是无效的,应该被立即删除,用于删除Cookie
  • max-age为正数时,代表这个Cookie有效,有效期是Cookie创建时间 + max-age

如果同时存在expires 和 max-age,大多数浏览器都会采用max-age。

建议设置expires为Cookie的过期时间,因为max-age在IE低版本下兼容性问题(如果你不care,可以使用max-age)。

虽然建议使用expires为Cookie的过期时间,但是如果用户系统的时间被修改成其他时间,那可能会带来不一样的效果。

size

这个size代表的是单个Cookie的name + value的字符数,偷偷告诉你,如果value里面含有中文字符(一般来说不会有),公式应该这样算了:size = name + value[非中文字符] + value[中文字符] * 3。这个长度在每个浏览器上都是有不同的长度限制的;另一方面,每个浏览器对于同一个域下的Cookie总数也是有限制的,具体如下:

IE6.0

IE7.0及以上

Opera

firefox

Safari

Chrome

cookie个数

每个域为20个

每个域为50个

每个域为30个

每个域为50个

没有个数限制

每个域为53个

cookie大小

4095个字节

4095个字节

4096个字节

4097个字节

4097个字节

4097个字节

所以设置Cookie的时候需要多想想这个Cookie是否是必须的,有没有其他的方式代替,避免造成用户浏览器Cookie泛滥。

珍爱每一个用户的浏览器,最重要的,这样做"环保"。

HttpOnly 和 Secure

HttpOnly是一个bool值,设置为true时意味着该Cookie无法通过js直接获取到,只能通过网络请求由浏览器自动匹配携带。这个属性的设置是为了防范XSS,这个后续会介绍到。

Secure属性可防止信息在传递的过程中被监听捕获后导致信息泄露,也是一个bool值,如果设置为true,可以限制只有通过https访问时,才会将浏览器保存的cookie传递到服务端,如果通过http访问,不会传递cookie。

SameSite

SameSite 属性决定Cookie在进行跨站访问时是否携带发送,作用是防止CSRF和用户追踪(也就是网页广告),对于门票来说,就是景区需要门票上的身份信息与持有人相符,防止你当小黄牛。

它一共有三个值:

  • Strict 表示只会在请求与当前网站相同的域名的地址时才会携带该域名下的Cookie,否则将不携带
  • Lax 表示大多数情况下是不发送Cookie的,除了一些get资源请求之外
  • None 表示所有第三方网站请求都会携带Cookie,很多浏览器设置None的时候服务必须是同时设置secure:true才会生效

SameSite存在一定的兼容性问题,因为它是一个新的Cookie标准,有些古老的浏览器是没有实现这个特性的,如IE浏览器需要Windows 10 RS3 及之后版本才有这个特性。相关兼容性如下图:

有些浏览器在较早的版本就支持了该属性,但是做了循序渐进的处理。如Chrome,其下有很多广告的业务,为了给自己整改的机会,先是在80版本之前将SameSite的属性默认设置成None,防止广告机制立即失效,然后就是80版本SameSite设置的默认值改成Lax,中性处理。

Lax除了一些GET资源请求是都不会发送第三方Cookie的,这里的条件也是很苛刻。这些GET请求的要求是会产生顶级域名变化,其实就是跳到请求地址的那个界面。那什么是顶级域名呢?

顶级域名就是一级域名,如baidu.com、juejin.cn,而map.baidu.com是baidu.com的子域名。

产生顶级域名变化的操作有:

  • a 标签,点击标签后会域名跳转到链接
  • link prerender 预加载,浏览器会在隐藏的tab重预加载指定网页,等待指定网页打开时立即显示。具体看着Prebrowsing
  • form表单GET请求

Lax 的条件还是很苛刻,它和Strict都能够有效地防范CSRF,当然前提是上面的GET请求没有做一些危险的操作。

下面用一张表来描述这个SameSite对第三方cookie带来的变化对比:

当前地址

请求地址

SameSite类型

是否携带

​a.b.com​

​c.com​

Strict

任何情况都不携带

​a.b.com​

​c.com​

Lax

除了三种GET请求,都不携带

​a.b.com​

​c.com​

None

携带

​a.b.com​

​c.com​

None

不携带

​a.b.com​

​c.b.com​

Lax

携带

​a.b.com​

​c.b.com​

Stric

携带

​a.b.com​

​c.b.com​

Lax

除了三种GET请求,都不携带

​a.b.com​

​c.b.com​

Strict

任何情况都不携带

以上这个表格可以用以下知识点总结:

  • 以上的第三方是指跨站地址,跨站是指不是同站的地址,同站又是指顶级域名+二级域名相同的地址就是同站,如a.taobao.com中的taobao.com,但a.github.io和b.github.io就不属于同站,因为github.io属于顶级域名。
  • chrome 86版本之后的同站延申到需要两个站点协议相同,所以,就算是顶级域名+二级域名都相同,也不属于同站,属于第三方站点
  • SameSite设置为None需要同时设置secure为true,而且设置cookie的站点的只能是https协议,访问时是也只能是访问https的站点时才会携带这个cookie
  • https站点中不能向http协议的站点发送请求,会出发浏览器混合内容限制,所以上面列表没有这种案例

Cookie的应用

竟然Cookie的诞生为了解决持久会话的问题,会话与登录有关,单纯靠一个Cookie是完成不了登录会话这个简单又复杂的过程的。所以,出现了很多与Cookie协作的工具或者机制可以实现登录会话的维持。

如服务端使用的Session,如扩展性非常好的Token令牌机制,看过很多Cookie、Session、Token的区别的文章,都写得很好,这里就不展开了,大家可以取搜搜。

Cookie让登录持久化变得轻轻松松。进而衍生了很多更高层次的需求如单点登录。

单点登录SSO

单点登录简单说就是在一个地方实现了多个系统的登录。比较常见的是公司的内部系统。

如果进到一些比较有规模的公司,你会发现,公司里面的办公系统是真的五花八门,像OA、gitlab、kb等等。如果没有单点登录,从A系统切换到B系统又得登录一次,这对于公司的办公效率是大大的降低的。单点登录就是在一个系统登录成功之后可以随意切换到其他相关的系统并且不需要登录。

如下是一个简单的SSO系统的实现:

其中的关键是token如何在不同域名之间交换。图示中是通过统一登录页面重定向token,也可以通过jsonp或者业务系统直接请求用户登录系统,使用后面的这两种方式如果要使用cookie存储,需要设置samesite为None(如果不同域),或者可以不使用cookie存储。

其实,SSO系统就是一种登录的解耦,能够实现不同系统共享一套用户系统。通过统一的登录页面和统一用户服务,将用户登录和用户信息集成起来,token的生成和交换、用户信息的交换都在一个地方,实现用户系统与业务系统的解耦。

Cookie 门票的安全性

Cookie这张门票刚被创造出来的时候没有考虑到太多的安全问题,所以Cookie大规模使用之后出现了很多安全性的问题,如XSS、CSRF。这些问题的出现同时警醒我们对于信息安全方面需要万分的关注和预防式的设计。

XSS

通过XSS攻击可以通过js代码获取用户的的登录token,进而伪造请求。

document.cookie

这个很容一防御,设置cookie的一定记得设置http-only。当然,作为一个严谨的开发,我们应该在服务器对涉及敏感操作的请求进行来源鉴定,通过鉴定通过http请求头中的以下两个字段:

origin: https://juejin.cn
    referer: https://juejin.cn/

CSRF

Cookie安全问题出现比较多的是CSRF(跨站请求伪造)。

CSRF的简单流程是通过浏览器的cookie携带机制,诱骗用户打开伪造的网址,向目标的网站发送请求,以达到获取用户信息或者修改用户信息的目的。

在SameSite未出现之前,发送第三方网站请求的cookie是默认携带的。SameSite出来之后大多数情况下是不携带cookie的,除了设置为None或者Lax。

因此,设置cookie的SameSite值也并不能完全防止CSRF攻击。除非设置为Strict...

防御措施有多种多样:

  • 校验请求的referer、origin,这个方式是最直接的,能偶防范大多数CSRF攻击。通常情况下,referer、origin是不能够被篡改的。
  • 设置cookie的SameSite数学为Lax或者Strict,有效禁止第三方发送
  • CSRF token机制(如下图),这种机制的解决方案是在页面打开时就注入csrf token,后续请求中通过请求头header携带。规避了CSRF攻击中自动携带的弊病,服务端不校验cookie中的token,而是校验header中的。

总结

Cookie因为其浏览器自动携带的特性为交互式页面的快速发展做出了不可磨灭的贡献,同时,也产生了很多问题。在浏览器不断地发展的今天,Cookie也在不断地被赋予更多的特性而没有被淘汰,说明其作用的不可替代性。

localStorage的出现也是为了弥补Cookie本身的缺陷,但不是替代。更多时候,还是应当在不同场景使用最恰当的技术方案。Cookie,仍然值得深究!