前言
作为无状态认证方案的忠实拥趸,星河自第一个B/S项目(星河云V1)开始,就采用JWT(JSON Web Token)作为主要认证方案。
什么是JWT?
简单地说,JWT是一种 令牌(Token)。通过传递JWT,客户端可以轻松向服务器端验明自己的身份。
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.

从本质上来讲,JWT就是一个字符串。其由 头部(Header)、载荷(Payload)、签名(Signature)三部分组成。其中头部定义了签名部分的算法及Token类型,载荷包含了这条JWT实际要传递的信息。而签名则是服务端通过一个不对外公开的密钥,使用头部定义的签名算法对头部和载荷进行签名后生成的。
在生成JWT时,服务端会先确定头部、载荷,再通过签名算法计算,生成签名部分。然后将这三者编码为Base64格式,再拼接起来(连接处用 .分割),即生成了一条JWT。
正文
事故概况
昨天下午我接到来自同事的求助,他说在早上推送了新版用户服务端后,部分用户请求推理网关服务出现报错“Token校验失败”。鉴权系统故障一事非同小可,遂先回退了用户服务端和ConfigMap版本,然后开始着手排查此问题。
核对了线上环境的签名密钥一致后,我排查了最近几次提交,但都没有发现改动了Token生成实现的地方。此时同事反馈此问题在测试环境无法复现,仅在生产环境容器可以获得无法通过校验的Token,且无法通过校验的Token在数分钟后又可以正常使用。
遂将问题锁定在宿主机系统时钟问题上。我比对了集群内所有机器的时间,发现有一台机器的时间快于集群时间21秒。统一设置了NTP服务器后,问题解决。
归因分析
本次事故涉及的有关方可以简化为 用户服务端(Token签发)、推理网关服务端(Token校验、提供服务)、用户(使用Token)。事情的本质可以简化为用户服务端的时间比推理网关快了21秒,导致用户服务端签发了一个“未来”才在推理网关服务端起效的Token。
但用户服务端和推理网关服务端在这之中传递了什么信息,才能知道这个Token生效的时间呢?
使用jwt.io分析服务端生成的Token,得到头部和载荷:

正所谓“实践出真知”,看多少遍定义都不如上来实践一次来的有用。我在本文开头已经提及了JWT的结构,它由头部、载荷和签名组成。对上图所示的这条JWT也是一样的。
让我们分析一下头部和载荷吧,首先是头部:
{
"alg": "HS256",
"typ": "JWT"
}这里的 alg是algorithm的缩写,typ则是type的缩写。前者表示这条JWT使用HMAC SHA256(HS256)算法签名,后者表示Token类型为JWT(其实这个也是废话了)。
然后再看载荷:
{
"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的鉴权流程,它是这样的:
aud,我的身份在 nbf到 exp期间有效。
aud。已验明你的身份,欢迎回来。
到这里似乎一切都已经跑通了,服务端确实通过解析载荷部分获得了鉴权所需的全部信息。但还有一个问题,如果只有头部和载荷,服务端怎么知道客户端说的是不是真的呢?
在JWT中,它的三部分都会被base64编码为字符串然后拼接。其中JWT的头部和载荷都是明文的。也就是如果没有其他校验手段,JWT的头部和载荷是可以被随意修改的。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9。尽管看上去这就是杂乱无章的字母、数字和符号组合,但事实上这些只是base64编码的结果,它们并没有被加密,且可以通过解码被轻松还原。那么,就需要一个手段来防止头部和载荷被轻易更改。这也是此前一直当小透明的签名部分的意义。签名并没有什么明文可读的内容,它的生成是将头部和载荷拼接,然后通过签名算法计算得到。
JWT使用的签名算法中,需要两个输入,分别为 待签名内容和 密钥,待签名内容即头部+载荷。无论进行多少次,只要待签名内容和密钥不变,算法生成的签名就也不会变。
在实际应用中,服务端收到一条JWT后,会重新计算头部和载荷的签名,并于这条JWT的签名部分进行对比。如果这条JWT是合法的,两次签名计算的结果一定相等。
如果有人篡改了JWT的头部或载荷部分,因为他不知道密钥,在修改待签名内容后无法重新生成签名。如果不生成签名,由于待签名内容发生了变动,服务端鉴权时再次计算的签名就会对不上JWT中的签名部分。
这就是签名部分能保证头部和载荷不被篡改/非法生成的原因。
进行总结,我们可以得到更进一步的结论——在 签名算法、头部、载荷、密钥都不变的情况下,计算出来的签名总是相等的。在这里我们引入了新的不可变量 签名算法,这也是为什么头部需要传递签名部分使用的签名算法(即头部的 alg字段)。只有完整传递这些信息,服务端才能正常完成鉴权流程。
尾声
在今天,JWT作为一种无状态鉴权方案已经经过了无数场景、应用的验证,是一种成熟的方案。但也由它的复杂性导致使用者必须熟知JWT原理,否则反而会带来很多麻烦。
Q.E.D.
0xC4A1
2025-01-23 12:23 提笔
2025-01-23 14:33 完稿