凭证

凭证(Credentials)

系统如何保证它与用户之间的承诺是双方当时真实意图的体现,是准确、完整且不可抵赖的?

在前面介绍OAuth2的内容中,每一种授权模式的目的都是拿到访问令牌,但从未涉及过拿回来的令牌应该长什么样子?反而还挖了一些坑(为何说OAuth2的一个主要缺陷是令牌难以主动失效)还没有填。这节我们讨论凭证,此话题中令牌必须得是主角了,此外,我们还要在这节讨论不使用OAuth2、最传统的方式是如何完成前面所讨论的认证、授权的。

我们知道,HTTP协议是一种无状态的传输协议,无状态是指协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的,但是我们中肯定有许多人并没有意识到HTTP协议无状态的重要性。假如你做了一个简单的网页,其中包含了1个HTML、2个Script脚本、3个CSS、还有10张图片,这个网页成功展示在用户屏幕前,需要完成16次与服务端的交互,由于服务器响应的顺序与发送请求的先后没有直接联系,按照可能出现的响应顺序,一共会有P(16,16) = 20922789888000种可能性。试想一下,如果HTML协议不是设计成无状态的,这16次请求各个有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影响的话,那协调工作会有多么复杂。

可是,HTTP协议的无状态特性又有悖于我们最常见的网络应用,譬如认证、授权方面,系统总得要获知用户身份才能提供服务,因此,我们也希望HTTP能有一种手段,让服务器至少有办法能够区分出发送请求的用户是谁。为了实现这个目的,RFC 6265规范中定义了HTTP的状态管理机制,在HTTP协议中设计了Set-Cookie指令,该指令的含义是以K/V值对的方式向客户端发送信息,此信息将在此后一定时间内的每次HTTP请求中,以名为Cookie的Header中附带着重新发回服务端,一个典型的Set-Cookie指令如下所示:

Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly

从此以后,当客户端对同一个域名(或者Path)的请求中都会带有值对信息“id=icyfenix”,例如以下所示:

GET /index.html HTTP/2.0
Host: icyfenix.cn
Cookie: id=icyfenix; sessionid=38afes7a8

根据每次请求传到服务端的Cookie,服务器就能分辨出请求来自于哪一个用户。由于Cookie是放在请求头上的负载(Payload,这个词后面还要频繁用到),不可能存储太大量的数据,放在Cookie中传输也不安全(被窃取,被篡改),所以通常是不会像例子中“id=icyfenix”这样的直接携带数据的。一般来说,Cookie中一般传输的是一个无意义的不重复的字符串,通常以sessionid或者jsessionid为名,服务器拿这个字符串为Key,再在内存中开辟一块空间,以Key/Entity的结构存储每一个在线用户的上下文状态,并辅以一些超时自动清理的管理措施,这种服务端的状态管理机制就是今天大家耳熟能详的Session,Cookie-Session就是在今天广泛应用于大量系统中的、服务端与客户端联动的状态管理机制。

Cookie-Session的方案在本章的主题“安全”上其实多少是占有一定优势的:信息都存储于服务器,不易遭遇传输中被泄漏、篡改的风险,只要通过域保护机制和传输层安全,保证Cookie中的键值不被窃取(如在”漏洞利用“小节中介绍的CSRF、XSS攻击)导致被冒认身份即可。Cookie-Session方案另一大优点是服务端有主动的管理能力,可根据自己的意愿随时修改、清除任意上下文状态,如实现强制某用户下线的功能就很容易。

Session-Cookie在单节点单体服务环境中是非常合适的方案,但当服务能力需要水平扩展,要部署集群时就开始面临一些麻烦了,由于Session建立在服务器的内存中,当服务器水平拓展成多节点时,我们必须在以下三种方案中选择其一:

  • 要么就牺牲集群的一致性(Consistency)能力,让均衡器采用亲和式的负载均衡算法(譬如根据用户IP或者sessionid来分配节点),每一个特定用户发出的所有请求都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。

  • 要么就牺牲集群的可用性(Availability)能力,让各个节点之间采用复制式的Session,每一个节点中的Session变动都会发送到组播地址的其他服务器上,这样某个节点崩溃了,不会中断都某个用户的服务,但Session之间组播复制的同步代价高昂,节点越多时越是如此。

  • 要么就牺牲集群的分区容错(Partition Tolerance)能力,让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏,整个集群都不再能提供服务。(多说一句,现在数据节点常见以Redis来搭建,本身Redis通常也会做集群,但将大集群的CAP问题放到小集群里,并不会让问题的消失,简而言之就是:禁止套娃

以后,我们在微服务架构中还会遇到更多分布式的问题,还会经常受到CAP理论(C、A、P必须牺牲一个)的打击,这是一个很值得深入探讨的技术权衡,但毕竟与本章的“安全”关系不大,这里就不再展开了。现在我只想知道一个问题的答案:前面三种方案都有缺陷,那在分布式应用中,就没有能绕过这些问题的解决方案吗?

我的答案是:有,也没有。如果说要解决分布式环境下的共享数据的CAP矛盾,这是被数学严格证明了不可能的,所以分布式环境中的状态管理一定会受到CAP的限制。但如果是解决分布式下的认证授权问题,那确实还有一些别的法子可想。前面这句话的言外之意是提醒读者,接下来的JWT令牌与Cookie-Session并不是对等的技术方案,它只解决认证授权问题,充其量能携带少量非敏感的信息,只是Cookie-Session在认证授权问题上的替代品,而不会成为Cookie-Session本身的革命者与继承人。

JWT

前面介绍的Cookie-Session机制在分布式环境下遇到一些问题,在多方系统中,就更不可能谈什么Session层面的数据共享了,而且Cookie也没法跨域。看来,服务器多了,确实不好解决,那就换个思路吧,客户端是唯一的,把数据存储在客户端,每次随着请求发回服务器——JWT就是这种思路的典型代表。

JSON Web Token(JWT),定义于RFC 7519的令牌格式,是目前广泛使用的一种令牌,尤其是与OAuth2配合应用于分布式的、涉及多方的应用系统之中。介绍JWT的具体构成之前,我们先来看一样它是什么样子的,一个JWT的例子如下图所示:

JWT令牌结构

以上截图来自于网站https://jwt.io/,当然,数据是我自己编的。左边的是JWT的本体,它通过名为Authorization的Header发送给服务端,前缀是在RFC 6750中定义的bearer,这点在之前关于“认证”的小节中提到过,一个完整的HTTP请求实例如下所示:

GET /restful/products/1 HTTP/1.1
Host: icyfenix.cn
Connection: keep-alive
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJpY3lmZW5peCIsInNjb3BlIjpbIkFMTCJdLCJleHAiOjE1ODQ5NDg5NDcsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwianRpIjoiOWQ3NzU4NmEtM2Y0Zi00Y2JiLTk5MjQtZmUyZjc3ZGZhMzNkIiwiY2xpZW50X2lkIjoiYm9va3N0b3JlX2Zyb250ZW5kIiwidXNlcm5hbWUiOiJpY3lmZW5peCJ9.539WMzbjv63wBtx4ytYYw_Fo1ECG_9vsgAn8bheflL8

图中右边的内容是经过Base64URL转码之后的令牌明文,是的,明文,JWT令牌默认是不加密的(你自己要加密也行就,接收时自己解密即可)。从明文中可以看到JWT令牌是以JSON结构(毕竟叫JSON Web Token)存储的,结构上可划分为三个部分,每个部分间用点号“.”分隔开。

第一部是令牌头(Header),内容如下所示:

{
  "alg": "HS256",
  "typ": "JWT"
}

它描述了令牌的类型(统一为typ:JWT)和令牌签名的算法,示例中HS256为HMAC SHA256算法的缩写,其他各种系统所支持的签名算法可以参考https://jwt.io/网站所列。

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

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

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

hmac

第二部分是负载(Payload),是令牌真正需要向服务端传递的信息,在认证问题中,至少应该包括告诉服务端“我是谁”的信息,在授权问题中,至少应该包括告诉服务端“我属于什么角色/权限,有哪些许可”。负载部分是可以完全自定义的,根据具体要解决的问题不同,设计自己所需要的信息(但不能太多,毕竟受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在RF 7519中推荐(无强制约束)了7个声明名称(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,是一种哈希摘要算法,属于不可逆的“加密”,过程住实质上是不用依赖密钥的,这时候的密钥实际上承担了加盐的作用。由于加密与验证均需要中心化的授权服务器来提供,所以这种方式较适合于单体应用。在多方系统或者授权服务与资源服务分离的分布式应用中,通常会采用非对称加密算法(主要有基于大数分解困难性的RSA SHA256算法和基于椭圆曲线的ECDSA SHA256算法)来进行签名,这时候除了授权服务端持有的可以用于签名的私钥外,还会对其他服务器公开一个公钥(公钥公开格式一般遵循JSON Web Key规范),公钥不会用来签名,但是能被其他服务用于验证签名是否由私钥所签发的。这样其他服务器也能不依赖授权服务器独立判断JWT令牌中的信息的真伪。

在Fenix's Bookstore的单体服务版本中,将会采用了JWT默认的HMAC SHA256算法来加密签名。在Fenix's Bookstore的Spring Cloud和Istio服务网格版本里,终端用户认证过程将会由服务网格的基础设施参与,此时将采用RSA SHA256算法进行非对称加密来进行签名,希望更深入了解凭证安全的同学,可以通过代码对比,想一下为什么微服务版本要改HMAC为RSA(非对称版本在微服务中交互次数明显小于默认的中心化加密版本)来进一步理解两者的差异。更多关于哈希、对称、非对称加密的讨论,会在“传输”一节中继续进行。

JWT令牌是多方系统中一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现,是准确、完整、不可篡改、且不可抵赖的。同时,由于JWT本身可以携带少量信息,这十分有利于RESTFul API的设计,能够较容易地做成无状态服务,在做水平扩展时就不需要像前面Cookie-Session方案那样考虑如何部署的问题。现实中也确实有一些项目(譬如Fenix's Bookstore)直接采用JWT来承载上下文来实现完全无状态的服务端,这能获得很大的好处,譬如,在你调试Fenix's Bookstore的程序时,随时都可以停止、重启服务端程序,服务重启后客户端仍然是可以毫无感知地继续操作流程;而对于有状态的系统,一般就必须通过再次登录、进行前置业务操作来给服务端重建状态(以上这句话所指的“好处”不是开发时方便重启,而是指不必顾虑状态地增加或者减少服务来进行伸缩)。

目前,在大型系统中完全使用JWT来保存上下文状态,服务端完全不持有状态仍是不太现实的,不过将最热点的服务接口单独抽离出来,做成无状态的、幂等的服务,是一种很有效的提升系统吞吐能力的架构设计。这部分内容将在微服务架构的部分如何划分微服务的章节中进一步探讨。

JWT并不是没有缺点的完美方案,它存在着以下几个明显或者不明显的缺点:

  • 令牌难以主动失效:JWT令牌一旦签发,理论上就和认证服务器再没有什么瓜葛了,在到期之前就会始终有效,除非服务器部署额外的逻辑,这对某些管理功能的实现是很不利的。譬如,有一种颇为常见的需求是:要求一个用户只能在一台设备上登录,在B设备登陆后,之前已经登录过的A设备就应该自动退出。如果采用JWT,就必须设计一个“黑名单”的额外的逻辑,用来把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在Session、Redis或者数据库中,都会让服务退化回有状态,降低了JWT本身的价值(但黑名单还是很常见的做法,需要维护的黑名单一般是很小的状态量,不少场景中是有存在意义的)。
  • 更容易遭受重放攻击:首先说明Cookie-Session也是有重放攻击问题的,只是因为Session中的数据控制在服务端手上,应对重放攻击会相对主动一些。要在JWT层面解决重放攻击需要付出比较大的代价,无论是加入全局序列号(HTTPS协议的思路)、Nonce字符串(HTTP Digest验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时其实都是很麻烦的,真要处理重放攻击,启用HTTPS是正道。
  • 只能携带相当有限的数据:HTTP协议并没有强制约束Header的最大长度,但是,各种服务器(甚至是浏览器)都会有约束,譬如Tomcat就要求Header最大不超过8KB,而在Nginx中则默认为4KB,因此在令牌中存储过多的数据不仅浪费带宽,还有额外的出错风险。
  • 令牌在客户端如何存储:严谨地说,这个并不是JWT的问题而是你的问题。如果授权之后,操作完了关掉浏览器这是结束了,那把令牌放到内存里面,压根不考虑持久化那是最理想的。但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登陆的(大概也就银行的网站可以忍)。那这样的话,客户端该把令牌存放到哪里?Cookie?localStorage?Indexed DB?它们都有泄漏的可能,而令牌一旦泄漏,别人就可以冒充你的身份做任何事情。
  • 无状态也不总是好处:这个其实不也是JWT的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态JWT的方案,做一个在线用户统计功能。兄弟,难搞哦。

我在写这篇文章的时候,在网上搜索资料,发现JWT的争议和吹捧都不少。技术只是工具而已,无论是迷信它还是抵制它,都并无必要。

Kudos to Star
总字数: 5,370 字  最后更新: 9/1/2020, 12:06:59 AM