learn-tech/专栏/周志明的架构课/26_凭证:系统如何保证与用户之间的承诺是准确完整且不可抵赖的?.md
2024-10-16 06:37:41 +08:00

241 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
26 _ 凭证:系统如何保证与用户之间的承诺是准确完整且不可抵赖的?
你好,我是周志明。
在第24讲我给你介绍OAuth 2.0协议的时候提到过每一种授权模式的最终目标都是拿到访问令牌但我并没有讲拿回来的令牌应该长什么样子反而还挖了一些坑没有填即为什么说OAuth 2.0的一个主要缺陷是令牌难以主动失效。
所以这节课我们要讨论的主角就是令牌了。我会带你了解令牌的结构、原理与实现让你明确系统是如何保证它和用户之间的承诺是双方当时意图的体现、是准确完整且不可抵赖的另外我还会跟你一起看看如果不使用OAuth 2.0的话,通过最传统的状态管理机制的方式,系统要如何完成认证和授权。
那接下来我们就先来看看HTTP协议中最传统的状态管理机制Cookie-Session是如何运作的吧。
Cookie-SessionHTTP的状态管理机制
我们应该都知道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便是这个问题的标准答案。
JWTJSON 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 CodeHMAC。你可以简单将它理解为一种带有密钥的哈希摘要算法实现形式上通常是把密钥以加盐方式混入与内容一起做哈希摘要。-
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如果你在设计令牌时需要用到这些内容我建议其字段名要与官方的保持一致
issIssuer签发人。
expExpiration Time令牌过期时间。
subSubject主题。
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令牌中的信息的真伪了。
在后面课程会展示的Fenixs Bookstore的单体服务版本中我们采用了默认的HMAC SHA256算法来加密签名而在Istio服务网格版本里终端用户认证会由服务网格的基础设施来完成此时就改用了非对称加密的RSA SHA256算法来进行签名。如果你还想更深入地了解凭证安全到时不妨对比一下这两部分的代码。更多关于哈希摘要、对称和非对称加密的讨论我将会在“传输”这个小章节中继续展开介绍。
JWT令牌的缺陷
现在我们知道JWT令牌是多方系统中的一种优秀的凭证载体它不需要任何一个服务节点保留任何一点状态信息就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现是准确、完整、不可篡改且不可抵赖的。
同时由于JWT本身可以携带少量信息这十分有利于RESTful API的设计比较容易地做成无状态服务我们在做水平扩展时就不需要像前面Cookie-Session方案那样考虑如何部署的问题了。在现实应用中也确实有一些项目直接采用JWT来承载上下文信息以此实现完全无状态的服务端这样就可以获得任意加入或移除服务节点的巨大便利天然具有完美的水平扩缩能力。
比如在调试Fenixs 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的问题而是系统设计的问题。如果在授权之后操作完关掉浏览器就结束了那把令牌放到内存里面压根不考虑持久化其实才是最理想的方案。
但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登录的。这样的话你想想客户端该把令牌存放到哪里呢CookielocalStorage还是Indexed DB它们都有泄露的可能而令牌一旦泄露别人就可以冒充用户的身份做任何事情。
无状态也不总是好的
这个其实不也是JWT的问题。如果不能想像无状态会有什么不好的话我给你提个需求请基于无状态JWT的方案做一个在线用户实时统计功能。兄弟难搞哦。
小结
Cookie-Session机制是为HTTP量身定做的经典凭证实现方案它曾经为信息系统解决过无数问题。不过随着微服务的流行分布式系统变得越来越主流因此由于分布式下共享数据的CAP矛盾就导致了Cookie-Session在一些场景中遇到了C与A难以取舍的情况。
而无状态的JWT方案在合适的场景下确实可以带来实实在在的好处它可以让服务端水平扩容变得异常容易不用担心Session复制的效率问题也不用担心Session挂掉后整个集群全部无法正常工作的问题。
然而场景二字仍然是关键词脱离了具体场景我们就很难说哪种凭证方案更好或者更坏在这节课中我也特别强调了JWT的几个缺点。你要记住权衡才是架构设计中最关键的地方。
一课一思
这节课我给你介绍了Cookie-Session和JWT两种最常见的凭证实现除此之外你还知道其他凭证的实现方案吗它们都有什么应用场景和优缺点
欢迎给我留言,分享你的答案。如果你觉得有收获,也欢迎把今天的内容分享给更多的朋友。感谢你的阅读,我们下一讲再见。