原文:「链接」
我们知道,HTTP 是无状态的。也就是说,HTTP 请求方和响应方之间无法维护状态,都是一次性的,它不知道前后的请求都发生了什么。
但有的场景下,我们需要维护状态。最典型的,一个用户登陆微博,发布、关注、评论,都应该是在登录后的用户状态下的。
「前端存储」
这就涉及到一发、一存、一带,发好办,登陆接口直接返回给前端,存储就需要前端想办法了。
前端的存储方式有很多。
前端存储这里不展开了。
有地方存了,请求的时候就可以拼到参数里带给接口了。
cookie 也是前端存储的一种,但相比于 localStorage 等其他方式,借助 HTTP 头、浏览器能力,cookie 可以做到前端无感知。
一般过程是这样的:
「配置:Domain / Path」
Domain属性指定浏览器发出 HTTP 请求时,哪些域名要附带这个 Cookie。如果没有指定该属性,浏览器会默认将其设为当前 URL 的一级域名,比如 www.example.com 会设为 example.com,而且以后如果访问example.com的任何子域名,HTTP 请求也会带上这个 Cookie。如果服务器在Set-Cookie字段指定的域名,不属于当前域名,浏览器会拒绝这个 Cookie。
Path属性指定浏览器发出 HTTP 请求时,哪些路径要附带这个 Cookie。只要浏览器发现,Path属性是 HTTP 请求路径的开头一部分,就会在头信息里面带上这个 Cookie。比如,PATH属性是/,那么请求/docs路径也会包含该 Cookie。当然,前提是域名必须一致。
—— Cookie — JAVAScript 标准参考教程(alpha)
「配置:Expires / Max-Age」
Expires属性指定一个具体的到期时间,到了指定时间以后,浏览器就不再保留这个 Cookie。它的值是 UTC 格式。如果不设置该属性,或者设为null,Cookie 只在当前会话(session)有效,浏览器窗口一旦关闭,当前 Session 结束,该 Cookie 就会被删除。另外,浏览器根据本地时间,决定 Cookie 是否过期,由于本地时间是不精确的,所以没有办法保证 Cookie 一定会在服务器指定的时间过期。
Max-Age属性指定从现在开始 Cookie 存在的秒数,比如60 * 60 * 24 * 365(即一年)。过了这个时间以后,浏览器就不再保留这个 Cookie。
如果同时指定了Expires和Max-Age,那么Max-Age的值将优先生效。
如果Set-Cookie字段没有指定Expires或Max-Age属性,那么这个 Cookie 就是 Session Cookie,即它只在本次对话存在,一旦用户关闭浏览器,浏览器就不会再保留这个 Cookie。
—— Cookie — JavaScript 标准参考教程(alpha)
「配置:Secure / HttpOnly」
Secure属性指定浏览器只有在加密协议 HTTPS 下,才能将这个 Cookie 发送到服务器。另一方面,如果当前协议是 HTTP,浏览器会自动忽略服务器发来的Secure属性。该属性只是一个开关,不需要指定值。如果通信是 HTTPS 协议,该开关自动打开。
HttpOnly属性指定该 Cookie 无法通过 JavaScript 脚本拿到,主要是Document.cookie属性、XMLHttpRequest对象和 Request API 都拿不到该属性。这样就防止了该 Cookie 被脚本读到,只有浏览器发出 HTTP 请求时,才会带上该 Cookie。
—— Cookie — JavaScript 标准参考教程(alpha)
「HTTP 头对 cookie 的读写」
回过头来,HTTP 是如何写入和传递 cookie 及其配置的呢?
HTTP 返回的一个 Set-Cookie 头用于向浏览器写入「一条(且只能是一条)」cookie,格式为 cookie 键值 + 配置键值。例如:
Set-Cookie: username=jimu; domain=jimu.com; path=/blog; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
那我想一次多 set 几个 cookie 怎么办?多给几个 Set-Cookie 头(一次 HTTP 请求中允许重复)
Set-Cookie: username=jimu; domain=jimu.com
Set-Cookie: height=180; domain=me.jimu.com
Set-Cookie: weight=80; domain=me.jimu.com
HTTP 请求的 Cookie 头用于浏览器把符合当前「空间、时间、使用方式」配置的所有 cookie 一并发给服务端。因为由浏览器做了筛选判断,就不需要归还配置内容了,只要发送键值就可以。
Cookie: username=jimu; height=180; weight=80
「前端对 cookie 的读写」
前端可以自己创建 cookie,如果服务端创建的 cookie 没加HttpOnly,那恭喜你也可以修改他给的 cookie。
调用document.cookie可以创建、修改 cookie,和 HTTP 一样,一次document.cookie能且只能操作一个 cookie。
document.cookie = 'username=jimu; domain=jimu.com; path=/blog; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly';
调用document.cookie也可以读到 cookie,也和 HTTP 一样,能读到所有的非HttpOnlycookie。
console.log(document.cookie);
// username=jimu; height=180; weight=80
(就一个 cookie 属性,为什么读写行为不一样?get / set 了解下)
「cookie 是维持 HTTP 请求状态的基石」
了解了 cookie 后,我们知道 cookie 是最便捷的维持 HTTP 请求状态的方式,大多数前端鉴权问题都是靠 cookie 解决的。当然也可以选用别的存储方式(后面也会多多少少提到)。
那有了存储工具,接下来怎么做呢?
现在回想下,你刷卡的时候发生了什么?
其实你的卡上只存了一个 id(可能是你的学号),刷的时候物业系统去查你的信息、账户,再决定「这个门你能不能进」「这个鸡腿去哪个账户扣钱」。
这种操作,在前后端鉴权系统中,叫 session。
典型的 session 登陆/验证流程:
「Session 的存储方式」
显然,服务端只是给 cookie 一个 sessionId,而 session 的具体内容(可能包含用户信息、session 状态等),要自己存一下。存储的方式有几种:
「Session 的过期和销毁」
很简单,只要把存储的 session 数据销毁就可以。
「Session 的分布式问题」
通常服务端是集群,而用户请求过来会走一次负载均衡,不一定打到哪台机器上。那一旦用户后续接口请求到的机器和他登录请求的机器不一致,或者登录请求的机器宕机了,session 不就失效了吗?
这个问题现在有几种解决方式。
但通常还是采用第一种方式,因为第二种相当于阉割了负载均衡,且仍没有解决「用户请求的机器宕机」的问题。
「node.js 下的 session 处理」
前面的图很清楚了,服务端要实现对 cookie 和 session 的存取,实现起来要做的事还是很多的。在npm中,已经有封装好的中间件,比如 express-session - npm,用法就不贴了。
这是它种的 cookie:
express-session - npm 主要实现了:
session 的维护给服务端造成很大困扰,我们必须找地方存放它,又要考虑分布式的问题,甚至要单独为了它启用一套 Redis 集群。有没有更好的办法?
我又想到学校,在没有校园卡技术以前,我们都靠「学生证」。门卫小哥直接对照我和学生证上的脸,确认学生证有效期、年级等信息,就可以放行了。
回过头来想想,一个登录场景,也不必往 session 存太多东西,那为什么不直接打包到 cookie 中呢?这样服务端不用存了,每次只要核验 cookie 带的「证件」有效性就可以了,也可以携带一些轻量的信息。
这种方式通常被叫做 token。
token 的流程是这样的:
「客户端 token 的存储方式」
在前面 cookie 说过,cookie 并不是客户端存储凭证的唯一方式。token 因为它的「无状态性」,有效期、使用限制都包在 token 内容里,对 cookie 的管理能力依赖较小,客户端存起来就显得更自由。但 web 应用的主流方式仍是放在 cookie 里,毕竟少操心。
「token 的过期」
那我们如何控制 token 的有效期呢?很简单,把「过期时间」和数据一起塞进去,验证时判断就好。
编码的方式丰俭由人。
「base64」
比如 node 端的 cookie-session - npm 库
不要纠结名字,其实是个 token 库,但保持了和 express-session - npm 高度一致的用法,把要存的数据挂在 session 上
默认配置下,当我给他一个 userid,他会译成这样:
这里的 eyJ1c2VyaWQiOiJhIn0=,就是 {"userid":"abb”} 的 base64 而已。
「防篡改」
那问题来了,如果用户 cdd 拿{"userid":"abb”}转了个 base64,再手动修改了自己的 token 为 eyJ1c2VyaWQiOiJhIn0=,是不是就能直接访问到 abb 的数据了?
是的。所以看情况,如果 token 涉及到敏感权限,就要想办法避免 token 被篡改。
解决方案就是给 token 加签名,来识别 token 是否被篡改过。例如在 cookie-session - npm 库中,增加两项配置:
secret: 'iAmSecret',
signed: true,
这样会多种一个 .sig cookie,里面的值就是 {"userid":"abb”} 和 iAmSecret通过加密算法计算出来的,常见的比如HmacSHA256 类 (System.Security.Cryptography) | Microsoft Docs。
好了,现在 cdd 虽然能伪造出eyJ1c2VyaWQiOiJhIn0=,但伪造不出 sig 的内容,因为他不知道 secret。
「JWT」
但上面的做法额外增加了 cookie 数量,数据本身也没有规范的格式,所以 JSON Web Token Introduction - jwt.io 横空出世了。
JSON Web Token (JWT) 是一个开放标准,定义了一种传递 JSON 信息的方式。这些信息通过数字签名确保可信。
它是一种成熟的 token 字符串生成方案,包含了我们前面提到的数据、签名。不如直接看一下一个 JWT token 长什么样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJhIiwiaWF0IjoxNTUxOTUxOTk4fQ.2jf3kl_uKWRkwjOP6uQRJFqMlwSABcgqqcJofFH5XCo
这串东西是怎么生成的呢?看图:
类型、加密算法的选项,以及 JWT 标准数据字段,可以参考 RFC 7519 - JSON Web Token (JWT)
node 上同样有相关的库实现:express-jwt - npm koa-jwt - npm
token,作为权限守护者,最重要的就是「安全」。
业务接口用来鉴权的 token,我们称之为 access token。越是权限敏感的业务,我们越希望 access token 有效期足够短,以避免被盗用。但过短的有效期会造成 access token 经常过期,过期后怎么办呢?
一种办法是,让用户重新登录获取新 token,显然不够友好,要知道有的 access token 过期时间可能只有几分钟。
另外一种办法是,再来一个 token,一个专门生成 access token 的 token,我们称为 refresh token。
有了 refresh token 后,几种情况的请求流程变成这样:
如果 refresh token 也过期了,就只能重新登录了。
session 和 token 都是边界很模糊的概念,就像前面说的,refresh token 也可能以 session 的形式组织维护。
狭义上,我们通常认为 session 是「种在 cookie 上、数据存在服务端」的认证方案,token 是「客户端存哪都行、数据存在 token 里」的认证方案。对 session 和 token 的对比本质上是「客户端存 cookie / 存别地儿」、「服务端存数据 / 不存数据」的对比。
「客户端存 cookie / 存别地儿」
存 cookie 固然方便不操心,但问题也很明显:
存别的地方,可以解决没有 cookie 的场景;通过参数等方式手动带,可以避免 CSRF 攻击。
「服务端存数据 / 不存数据」
前面我们已经知道了,在同域下的客户端/服务端认证系统中,通过客户端携带凭证,维持一段时间内的登录状态。
但当我们业务线越来越多,就会有更多业务系统分散到不同域名下,就需要「一次登录,全线通用」的能力,叫做「单点登录」。
简单的,如果业务系统都在同一主域名下,比如wenku.baidu.com tieba.baidu.com,就好办了。可以直接把 cookie domain 设置为主域名 baidu.com,百度也就是这么干的。
比如滴滴这么潮的公司,同时拥有didichuxing.com xiaojukeji.com didiglobal.com等域名,种 cookie 是完全绕不开的。
这要能实现「一次登录,全线通用」,才是真正的单点登录。
这种场景下,我们需要独立的认证服务,通常被称为 SSO。
「一次「从 A 系统引发登录,到 B 系统不用登录」的完整流程」
「完整版本:考虑浏览器的场景」
上面的过程看起来没问题,实际上很多 App 等端上这样就够了。但在浏览器下不见得好用。
看这里:
对浏览器来说,SSO 域下返回的数据要怎么存,才能在访问 A 的时候带上?浏览器对跨域有严格限制,cookie、localStorage 等方式都是有权限制的。
这就需要也只能由 A 提供 A 域下存储凭证的能力。一般我们是这么做的:
图中我们通过颜色把浏览器当前所处的域名标记出来。注意图中灰底文字说明部分的变化。