Loading... # 前言 作为无状态认证方案的忠实拥趸,星河自第一个B/S项目(星河云V1)开始,就采用JWT(JSON Web Token)作为主要认证方案。 ## 什么是JWT? 简单地说,JWT是一种 `令牌(Token)`。通过传递JWT,客户端可以轻松向服务器端验明自己的身份。 [RFC7519](https://tools.ietf.org/html/rfc7519)文件对JWT进行了较为正式的定义: > JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. > > ——[JSON Web Token(JWT)](https://tools.ietf.org/html/rfc7519)  从本质上来讲,JWT就是一个字符串。其由 `头部(Header)`、`载荷(Payload)`、`签名(Signature)`三部分组成。其中头部定义了签名部分的算法及Token类型,载荷包含了这条JWT实际要传递的信息。而签名则是服务端通过一个不对外公开的密钥,使用头部定义的签名算法对头部和载荷进行签名后生成的。 在生成JWT时,服务端会先确定头部、载荷,再通过签名算法计算,生成签名部分。然后将这三者编码为Base64格式,再拼接起来(连接处用 `.`分割),即生成了一条JWT。 # 正文 ## 事故概况 昨天下午我接到来自同事的求助,他说在早上推送了新版用户服务端后,部分用户请求推理网关服务出现报错“Token校验失败”。鉴权系统故障一事非同小可,遂先回退了用户服务端和ConfigMap版本,然后开始着手排查此问题。 核对了线上环境的签名密钥一致后,我排查了最近几次提交,但都没有发现改动了Token生成实现的地方。此时同事反馈此问题在测试环境无法复现,**仅在生产环境容器可以获得无法通过校验的Token,且无法通过校验的Token在数分钟后又可以正常使用**。 遂将问题锁定在宿主机系统时钟问题上。我比对了集群内所有机器的时间,发现有一台机器的时间快于集群时间21秒。统一设置了NTP服务器后,问题解决。 ## 归因分析 本次事故涉及的有关方可以简化为 `用户服务端(Token签发)`、`推理网关服务端(Token校验、提供服务)`、`用户(使用Token)`。事情的本质可以简化为用户服务端的时间比推理网关快了21秒,导致**用户服务端签发了一个“未来”才在推理网关服务端起效的Token**。 但用户服务端和推理网关服务端在这之中传递了什么信息,才能知道这个Token生效的时间呢? 使用[jwt.io](https://jwt.io/)分析服务端生成的Token,得到头部和载荷:  正所谓“实践出真知”,看多少遍定义都不如上来实践一次来的有用。我在本文开头已经提及了JWT的结构,它由头部、载荷和签名组成。对上图所示的这条JWT也是一样的。 让我们分析一下头部和载荷吧,首先是头部: ```json { "alg": "HS256", "typ": "JWT" } ``` 这里的 `alg`是algorithm的缩写,`typ`则是type的缩写。前者表示这条JWT使用HMAC SHA256(HS256)算法签名,后者表示Token类型为JWT(其实这个也是废话了)。 然后再看载荷: ```json { "aud": "dangotest", "exp": 1737622537, "iat": 1737536137, "iss": "Dango", "nbf": 1737536137, "sub": "Login" } ``` 我们前面提到过,载荷包含了这条JWT实际要传递的信息。其中 `aud`表示这条JWT代表的用户,`iat`、`nbf`、`exp`则分别表示这条JWT的签发时间(issue at)、起效时间(not before)、过期时间(expire)。其他条目与本问题无关,故不再赘述。 所以,如果要形象地表示使用JWT的鉴权流程,它是这样的: <div class="tip inlineBlock share"> 客户端:你好!我的用户名是 `aud`,我的身份在 `nbf`到 `exp`期间有效。 </div> <div class="tip inlineBlock success"> 服务端:好的,`aud`。已验明你的身份,欢迎回来。 </div> 到这里似乎一切都已经跑通了,服务端确实通过解析载荷部分获得了鉴权所需的全部信息。但还有一个问题,如果只有头部和载荷,**服务端怎么知道客户端说的是不是真的呢?** 在JWT中,它的三部分都会被base64编码为字符串然后拼接。其中**JWT的头部和载荷都是明文的**。也就是如果没有其他校验手段,JWT的头部和载荷是可以被**随意修改**的。 <div class="panel panel-default collapse-panel box-shadow-wrap-lg"><div class="panel-heading panel-collapse" data-toggle="collapse" data-target="#collapse-8d22f74be449e46d34b15fdffaf9c50e67" aria-expanded="true"><div class="accordion-toggle"><span style="">为什么头部和载荷是明文的?</span> <i class="pull-right fontello icon-fw fontello-angle-right"></i> </div> </div> <div class="panel-body collapse-panel-body"> <div id="collapse-8d22f74be449e46d34b15fdffaf9c50e67" class="collapse collapse-content"><p></p>以上面截图中的JWT为例,它的头部是 `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9`。尽管看上去这就是杂乱无章的字母、数字和符号组合,但事实上这些只是base64编码的结果,它们**并没有被加密**,且可以通过解码被轻松还原。<p></p></div></div></div> 那么,就需要一个手段来防止头部和载荷被轻易更改。这也是此前一直当小透明的签名部分的意义。签名并没有什么明文可读的内容,它的生成是将头部和载荷拼接,然后通过签名算法计算得到。 JWT使用的签名算法中,需要两个输入,分别为 `待签名内容`和 `密钥`,待签名内容即头部+载荷。**无论进行多少次,只要待签名内容和密钥不变,算法生成的签名就也不会变。** 在实际应用中,服务端收到一条JWT后,会重新计算头部和载荷的签名,并于这条JWT的签名部分进行对比。**如果这条JWT是合法的,两次签名计算的结果一定相等**。 如果有人篡改了JWT的头部或载荷部分,因为他不知道密钥,在修改待签名内容后无法重新生成签名。如果不生成签名,由于待签名内容发生了变动,服务端鉴权时再次计算的签名就会对不上JWT中的签名部分。 这就是签名部分能保证头部和载荷不被篡改/非法生成的原因。 进行总结,我们可以得到更进一步的结论——在 `签名算法`、`头部`、`载荷`、`密钥`都不变的情况下,计算出来的签名**总是相等的**。在这里我们引入了新的不可变量 `签名算法`,这也是为什么头部需要传递签名部分使用的签名算法(即头部的 `alg`字段)。只有完整传递这些信息,服务端才能正常完成鉴权流程。 # 尾声 在今天,JWT作为一种无状态鉴权方案已经经过了无数场景、应用的验证,是一种成熟的方案。但也由它的复杂性导致使用者必须熟知JWT原理,否则反而会带来很多麻烦。 --- Q.E.D. 0xC4A1 2025-01-23 12:23 提笔 2025-01-23 14:33 完稿 最后修改:2025 年 01 月 25 日 © 允许规范转载 赞 如果这对你有用,我乐意之至。