26 _ 凭证:系统如何保证与用户之间的承诺是准确完整且不可抵赖的? 你好,我是周志明。

第24讲我给你介绍OAuth 2.0协议的时候,提到过每一种授权模式的最终目标都是拿到访问令牌,但我并没有讲拿回来的令牌应该长什么样子,反而还挖了一些坑没有填,即为什么说OAuth 2.0的一个主要缺陷是令牌难以主动失效。

所以,这节课我们要讨论的主角就是令牌了。我会带你了解令牌的结构、原理与实现,让你明确系统是如何保证它和用户之间的承诺是双方当时意图的体现、是准确完整且不可抵赖的;另外我还会跟你一起看看,如果不使用OAuth 2.0的话,通过最传统的状态管理机制的方式,系统要如何完成认证和授权。

好,那接下来,我们就先来看看HTTP协议中最传统的状态管理机制,Cookie-Session是如何运作的吧。

我们应该都知道,HTTP协议是一种无状态的传输协议,也就是协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的。但是我想,肯定很多人都没有意识到HTTP协议无状态的重要性。

为什么这么说呢?假如你做了一个简单的网页,其中包含了1个HTML、2个Script脚本、3个CSS,还有10张图片,那么这个网页要想成功地展示在用户屏幕前,就需要完成16次与服务器的交互来获取这些资源。

但是,因为网络传输等各种因素的影响,服务器发送的顺序与客户端请求的先后并没有必然的联系,所以按照可能出现的响应顺序,理论上最多会有P(16,16) = 20,922,789,888,000种可能性。

所以我们可以试想一下,如果HTTP协议不是设计成无状态的,这16次请求每一个都有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影响的话,那么服务器与客户端交互的协调工作会有多么复杂。

可是,HTTP协议的无状态特性,又有悖于我们最常见的网络应用场景,典型的就是认证授权,毕竟系统总得要获知用户身份才能提供合适的服务。因此,我们也希望HTTP能有一种手段,让服务器至少有办法区分出发送请求的用户是谁。

所以,为了实现这个目的,RFC 6265规范就定义了HTTP的状态管理机制,在HTTP协议中增加了Set-Cookie指令

这个指令的含义是以键值对的方式向客户端发送一组信息,在此后一段时间内的每次HTTP请求中,这组信息会附带着名为Cookie的Header重新发回给服务端,以便服务器区分来自不同客户端的请求。

我们直接来看一个典型的Set-Cookie指令具体是怎么做的: Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly

服务器在收到该指令以后,客户端再对同一个域的请求中,就会自动附带有键值对信息“id=icyfenix”,比如说:

GET /index.html HTTP/2.0 Host: icyfenix.cn Cookie: id=icyfenix

那么,根据每次请求传到服务端的Cookie,服务器就能分辨出请求来自于哪一个用户。由于Cookie是放在请求头上的,属于额外的传输负担,不应该携带过多的内容,而且放在Cookie中传输也并不安全,容易被中间人窃取或篡改,所以在实际情况中,通常是不会像这个例子一样,设置“id=icyfenix”这样的明文信息的。

一般来说,系统会把状态信息保存在服务端,而在Cookie里只传输一个无字面意义的、不重复的字符串,通常习惯上是以sessionid或者jsessionid为名。然后,服务器拿这个字符串为Key,在内存中开辟一块空间,以Key/Entity的结构,来存储每一个在线用户的上下文状态,再辅以一些超时自动清理之类的管理措施。

这种服务端的状态管理机制,就是今天我们非常熟悉的Session。Cookie-Session也就是最传统的,但在今天依然广泛应用于大量系统中的、由服务端与客户端联动来完成的状态管理机制。

Cookie-Session的方案在安全架构的系统当中,其实是占有一定天生优势的:因为状态信息都存储于服务器,只要依靠客户端的同源策略和HTTPS的传输层安全,保证Cookie中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉信息在传输过程中被泄露和篡改的风险。

Cookie-Session方案另一大优点是服务端有主动的状态管理能力,可以根据自己的意愿随时修改、清除任意的上下文信息,比如很轻易就能实现强制某用户下线这样的功能。

不过,Cookie-Session在单节点的单体服务环境中确实是最合适的方案,但当服务器需要具备水平扩展服务能力,要部署集群时就有点儿麻烦了。

因为Session存储在服务器的内存中,那么当服务器水平拓展成多节点时,我们在设计时就必须在以下三种方案中选择其一:

  • 要么就牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法。比如根据用户IP或者Session来分配节点,每一个特定用户发出的所有请求,都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
  • 要么就牺牲集群的可用性(Availability),让各个节点之间采用复制式的Session,每一个节点中的Session变动,都会发送到组播地址的其他服务器上,这样即使某个节点崩溃了,也不会中断某个用户的服务。但Session之间组播复制的同步代价比较高昂,节点越多时,同步成本就越高。
  • 要么就牺牲集群的分区容错性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不能再提供服务。

通过第14讲内容的学习,现在我们已经知道了,只要在分布式系统中共享信息,CAP就不可兼得,所以分布式环境中的状态管理一定会受到CAP的局限,无论怎样都不可能完美。

但如果,我们只是解决分布式下的认证授权问题,并顺带解决少量状态的问题,就不一定只能依靠共享信息去实现了。

我说这句话的言外之意是想先提醒一下你,接下来我要给你介绍的JWT令牌,跟Cookie-Session并不是完全对等的解决方案,它只用来处理认证授权问题,是Cookie-Session在认证授权问题上的替代品,充其量能携带少量非敏感的信息。而我们不能说,JWT要比Cookie-Session更加先进,它也更不可能全面取代Cookie-Session机制。

JWT:解决认证授权问题的无状态方案

好,现在我们知道了,Cookie-Session机制在分布式环境下会遇到CAP不可兼得的问题,而在多方系统中,也就更不可能谈什么Session层面的数据共享了,哪怕服务端之间能共享数据,客户端的Cookie也没法跨域。

所以,我们不得不重新捡起最初被抛弃的思路:当服务器存在多个,客户端只有一个时,那就把状态信息存储在客户端,每次随着请求发回服务器中去。可是奇怪了,前面我才说过,这样做的缺点是无法携带大量信息,而且有泄露和篡改的安全风险。

其实啊,信息量受限的问题目前并没有太好的解决办法,但是要确保信息不被中间人篡改,还是可以实现的,JWT便是这个问题的标准答案。

JWT(JSON Web Token)定义于RFC 7519标准之中,是目前广泛使用的一种令牌格式,尤其经常与OAuth 2.0配合应用于分布式的、涉及多方的应用系统中。

那么,在介绍JWT的具体构成之前,我们先来直观地看一下它是什么样子的:

这个示意图来源于JWT官网,数据则是我随意编的。图上右边的JSON结构,是JWT令牌中携带的信息,左边的字符串呈现了JWT令牌的本体。它最常见的使用方式是附在名为Authorization的Header发送给服务端,其前缀在RFC 6750中被规定为Bearer。

如果你没有忘记第23讲“认证方案”与第24讲“OAuth 2.0”的知识内容,那么当你在看到Authorization这个Header与Bearer这个前缀的时候,就应该能意识到,它是HTTP认证框架中的OAuth 2.0认证方案。下面的示例代码就展示了一次采用JWT令牌的HTTP实际请求: GET /restful/products/1 HTTP/1.1 Host: icyfenix.cn Connection: keep-alive Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJpY3lmZW5peCIsInNjb3BlIjpbIkFMTCJdLCJleHAiOjE1ODQ5NDg5NDcsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwianRpIjoiOWQ3NzU4NmEtM2Y0Zi00Y2JiLTk5MjQtZmUyZjc3ZGZhMzNkIiwiY2xpZW50X2lkIjoiYm9va3N0b3JlX2Zyb250ZW5kIiwidXNlcm5hbWUiOiJpY3lmZW5peCJ9.539WMzbjv63wBtx4ytYYw_Fo1ECG_9vsgAn8bheflL8

另外我还要跟你强调的是,在前面的令牌结构示意图中,右边的状态信息是对令牌使用Base64URL转码后得到的明文,请你特别注意它是明文

毕竟JWT只解决防篡改的问题,并不解决防泄露的问题,所以令牌默认是不加密的。尽管你自己要加密的话也并不难做到,接收时自行解密即可,但这样做其实没有太大的意义,具体原因我这里先卖个关子,下一节课我讲“保密”的时候再给你详细解释。

JWT令牌的三部分结构

那么,从前面给出的明文中你已经知道,JWT令牌是以JSON结构(毕竟名字就叫JSON Web Token)存储的,其结构总体上可以划分为三个部分,每个部分用点号“ . ”分隔开。

令牌的第一部分是令牌头(Header),其内容如下所示: { “alg”: “HS256”, “typ”: “JWT” }

这里你可以看到,它描述了令牌的类型(统一为typ:JWT)以及令牌签名的算法,示例中HS256为HMAC SHA256算法的缩写,其他各种系统支持的签名算法你可以参考JWT官网

额外知识:散列消息认证码-

在这一讲及后面其他关于安全的课程内容中,你会经常看到在某种哈希算法前出现“HMAC”的前缀,这是指散列消息认证码(Hash-based Message Authentication Code,HMAC)。你可以简单将它理解为一种带有密钥的哈希摘要算法,实现形式上通常是把密钥以加盐方式混入,与内容一起做哈希摘要。-

HMAC哈希与普通哈希算法的差别是,普通的哈希算法通过Hash函数结果易变性,保证了原有内容未被篡改,而HMAC不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。

令牌的第二部分是负载(Payload),这是令牌真正需要向服务端传递的信息。针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息;针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。

JWT的负载部分是可以完全自定义的,我们可以根据具体要解决的问题,设计自己所需要的信息,只是总容量不能太大,毕竟它受HTTP Header大小的限制。下面我们来看一个JWT负载的例子: { “username”: “icyfenix”, “authorities”: [ “ROLE_USER”, “ROLE_ADMIN” ], “scope”: [ “ALL” ], “exp”: 1584948947, “jti”: “9d77586a-3f4f-4cbb-9924-fe2f77dfa33d”, “client_id”: “bookstore_frontend” }

另外,JWT在RFC 7519标准中,推荐(非强制约束)了七项声明名称(Claim Name),如果你在设计令牌时需要用到这些内容,我建议其字段名要与官方的保持一致:

  • iss(Issuer):签发人。
  • exp(Expiration Time):令牌过期时间。
  • sub(Subject):主题。
  • aud (Audience):令牌受众。
  • nbf (Not Before):令牌生效时间。
  • iat (Issued At):令牌签发时间。
  • jti (JWT ID):令牌编号。 补充:除此之外,在RFC 8225、RFC 8417、RFC 8485等规范文档,以及OpenID等协议当中,都定义有约定好公有含义的名称,内容比较多,我就不贴出来了,你可以参考IANA JSON Web Token Registry

令牌的第三部分是签名(Signature)。签名的意思是:使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算,产生签名值。

这里我们继续以前面例子里使用的JWT默认的HMAC SHA256算法为例,它会通过以下公式产生签名值: HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload) , secret)

签名的意义在于,它可以确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。因为被签名的内容哪怕是发生了一个字节的变动,也会导致整个签名发生显著变化。

此外,由于签名这件事情,只能由认证授权服务器完成(只有它知道Secret),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的JWT中的负载信息。

JWT默认的签名算法HMAC SHA256是一种带密钥的哈希摘要算法,加密与验证过程都只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用

另外,在多方系统,或者是授权服务与资源服务分离的分布式应用当中,通常会采用非对称加密算法来进行签名。这时候,除了授权服务端持有的可以用于签名的私钥以外,还会对其他服务器公开一个公钥,公开方式一般遵循JSON Web Key规范

不过,这个公钥不能用来签名,但它能被其他服务用于验证签名是否由私钥所签发的。这样,其他服务器就也能不依赖授权服务器、无需远程通讯,即可独立判断JWT令牌中的信息的真伪了。 在后面课程会展示的Fenix’s Bookstore的单体服务版本中,我们采用了默认的HMAC SHA256算法来加密签名,而在Istio服务网格版本里,终端用户认证会由服务网格的基础设施来完成,此时就改用了非对称加密的RSA SHA256算法来进行签名。如果你还想更深入地了解凭证安全,到时不妨对比一下这两部分的代码。更多关于哈希摘要、对称和非对称加密的讨论,我将会在“传输”这个小章节中继续展开介绍。

JWT令牌的缺陷

现在我们知道,JWT令牌是多方系统中的一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现,是准确、完整、不可篡改且不可抵赖的。

同时,由于JWT本身可以携带少量信息,这十分有利于RESTful API的设计,比较容易地做成无状态服务,我们在做水平扩展时就不需要像前面Cookie-Session方案那样,考虑如何部署的问题了。在现实应用中,也确实有一些项目直接采用JWT来承载上下文信息,以此实现完全无状态的服务端,这样就可以获得任意加入或移除服务节点的巨大便利,天然具有完美的水平扩缩能力。

比如,在调试Fenix’s Bookstore的代码时,你随时都可以重启服务,在重启后,客户端仍然能毫无感知地继续操作流程;而对于有状态的系统,就必须通过重新登录、进行前置业务操作,来为服务端重建状态。尽管在大型系统中,只使用JWT来维护上下文状态,服务端完全不持有状态是不太现实的,不过将热点的服务单独抽离出来做成无状态,仍然是一种有效提升系统吞吐能力的架构技巧。

但是,JWT也并不是一种完美的解决方案,它存在着以下几个经常被提及的缺点:

  • 令牌难以主动失效

JWT令牌一旦签发,理论上就和认证服务器没有什么瓜葛了,在到期之前就会始终有效,除非我们在服务器部署额外的逻辑去处理失效问题,而这对某些管理功能的实现是很不利的。比如说,一种十分常见的需求是:要求一个用户只能在一台设备上登录,在B设备登录后,之前已经登录过的A设备就应该自动退出。

如果我们采用JWT,就必须设计一个“黑名单”的额外逻辑,把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在Session、Redis还是数据库当中,都会让服务退化成有状态服务,这就降低了JWT本身的价值。但在使用JWT时,设置黑名单依然是很常见的做法,需要维护的黑名单一般是很小的状态量,因此在许多场景中还是有存在价值的。

  • 相对更容易遭受重放攻击

这里首先我要说明,Cookie-Session也是有重放攻击问题的,只是因为Session中的数据控制在服务端手上,应对重放攻击会相对主动一些。

但是,要在JWT层面解决重放攻击,就需要付出比较大的代价了,因为无论是加入全局序列号(HTTPS协议的思路)、Nonce字符串(HTTP Digest验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。

而真要处理重放攻击的话,我建议的解决方案是在信道层次(比如启用HTTPS)上解决,而不提倡在服务层次(比如在令牌或接口其他参数上增加额外逻辑)上解决。

  • 只能携带相当有限的数据

HTTP协议并没有强制约束Header的最大长度,但是,各种服务器、浏览器都会有自己的约束,比如Tomcat就要求Header最大不超过8KB,而在Nginx中则默认为4KB。所以在令牌中存储过多的数据,不仅耗费传输带宽,还有额外的出错风险。

  • 必须考虑令牌在客户端如何存储

严谨地说,这个并不是JWT的问题,而是系统设计的问题。如果在授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化,其实才是最理想的方案。

但并不是谁都能忍受一个网站关闭之后,下次就一定强制要重新登录的。这样的话,你想想客户端该把令牌存放到哪里呢?Cookie?localStorage?还是Indexed DB?它们都有泄露的可能,而令牌一旦泄露,别人就可以冒充用户的身份做任何事情。

  • 无状态也不总是好的

这个其实不也是JWT的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态JWT的方案,做一个在线用户实时统计功能。兄弟,难搞哦。

小结

Cookie-Session机制是为HTTP量身定做的经典凭证实现方案,它曾经为信息系统解决过无数问题。不过,随着微服务的流行,分布式系统变得越来越主流,因此由于分布式下共享数据的CAP矛盾,就导致了Cookie-Session在一些场景中遇到了C与A难以取舍的情况。

而无状态的JWT方案在合适的场景下,确实可以带来实实在在的好处,它可以让服务端水平扩容变得异常容易,不用担心Session复制的效率问题,也不用担心Session挂掉后,整个集群全部无法正常工作的问题。

然而,场景二字仍然是关键词,脱离了具体场景,我们就很难说哪种凭证方案更好或者更坏,在这节课中,我也特别强调了JWT的几个缺点。你要记住,权衡才是架构设计中最关键的地方。

一课一思

这节课我给你介绍了Cookie-Session和JWT两种最常见的凭证实现,除此之外,你还知道其他凭证的实现方案吗?它们都有什么应用场景和优缺点?

欢迎给我留言,分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/26%20_%20%e5%87%ad%e8%af%81%ef%bc%9a%e7%b3%bb%e7%bb%9f%e5%a6%82%e4%bd%95%e4%bf%9d%e8%af%81%e4%b8%8e%e7%94%a8%e6%88%b7%e4%b9%8b%e9%97%b4%e7%9a%84%e6%89%bf%e8%af%ba%e6%98%af%e5%87%86%e7%a1%ae%e5%ae%8c%e6%95%b4%e4%b8%94%e4%b8%8d%e5%8f%af%e6%8a%b5%e8%b5%96%e7%9a%84%ef%bc%9f.md