[TOC] #### 1. JWT 介绍 --- JWT(JSON Web Token)是一种开放标准,用于在网络应用之间安全的传输信息 它通常被用来进行用户身份验证和授权,尤其适用于分布式系统、前后端分离架构以及 API 接口的安全访问控制 JWT 的官方网站:<https://www.jwt.io>,在官网可以了解和使用 JWT 的核心资源,它主要提供以下功能: + 在线调试工具:该网站最常用的功能,可以将任意 JWT 字符串粘贴进去,它会即时解码并展示其头部、载荷、签名的具体内容,也可以在这里修改参数生成新的 Token 进行测试 + 官方文档与标准:提供了关于 JWT 的详细介绍、工作原理以及相关的行业标准(RFC 7519)连接 + 类库列表:列出了不同编程语言(如:PHP、Java、Python 等)用于处理 JWT 的开源类库 #### 2. JWT 的结构 --- JWT 是一个长字符串,由三部分组成,用点(`.`)分隔:Header(头部)、Payload(载荷)、Signature(签名) 头部(Header)通常包含两个部分:令牌的类型(typ)、所使用的签名算法(alg) + typ(type): 通常是 “JWT” + alg(Algorithm): 签名算法,常见的有 HS256、RS256 + HS256:HMAC SHA-256 算法的对称加密 + RS256:RSA SHA-256 算法的非对称加密 头部示例: ```json {"alg": "HS256", "typ": "JWT"} ``` `btoa()` 和 `atob()` 是 JavaScript 中用于 Base64 编码和解码的全局函数(binary 与 ASCII 之间的转换) ```javascript // 编码 const decoder = btoa('{"alg":"HS256","typ":"JWT"}') console.log(decoder) // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // 解码 const encoder = atob(decoder) console.log(encoder) // {"alg":"HS256","typ":"JWT"} ``` 载荷(Payload)部分包含需要传递的声明,即关于用户或其它实体的信息,声明分为三类:注册声明、公共声明、私有声明 注册声明:JWT 规范预定义的推荐字段,如: + iss(Issuer):签发者。标识是谁签发了这个JWT + sub(Subject):主题。通常用于存放用户的唯一标识(如:用户ID),是身份验证的核心字段 + exp(Expiration Time):过期时间。超过这个时间,令牌将被视为无效 + iat(Issued At):签发时间。标识令牌是什么时候场景的 + jti(JWT ID):唯一标识。用于唯一标识一个 JWT,常用于实现令牌的黑名单或防止重放攻击 一个载荷的示例: ```json { "iss": "liang", "sub": 10, "exp": 1774667109, "iat": 1774580709 } ``` 将上面的示例载荷拿到 JWT 官网编码后得到的 ```plaintext eyJpc3MiOiJsaWFuZyIsInN1YiI6MTAsImV4cCI6MTc3NDY2NzEwOSwiaWF0IjoxNzc0NTgwNzA5fQ ``` 而我们手动编码的结果,比 JWT 官网给的字符串后面多出了两个等号 `=`,这是为什么 ? 简单来说,`btoa` 遵循标准的 Base64 规范,而 JWT 使用的是 Base64Url,并且且去掉了填充符号 ```javascript // eyJpc3MiOiJsaWFuZyIsInN1YiI6MTAsImV4cCI6MTc3NDY2NzEwOSwiaWF0IjoxNzc0NTgwNzA5fQ== const decoder = btoa('{"iss":"liang","sub":10,"exp":1774667109,"iat":1774580709}') ``` `btoa` 函数使用的是标准的 Base64 编码算法: + 原理:将每 3 个字节二进制数据转为 4 个字符, + 填充规则:如果输入的数据长度不是 3 的倍数,标准 Base64 算法要求使用 `=` 符号在末尾填充 因为以下字符串的长度为 58,58 % 3 = 1(求余数),需要填充 2 个等号才是 3 的倍数 ```javascript '{"iss":"liang","sub":10,"exp":1774667109,"iat":1774580709}' ``` 如果想用 JavaScript 生成符合 JWT 标准的字符串(没有 `=` 号),需要手动去掉 `btoa` 生成的填充符,并替换特殊字符 ```javascript function base64UrlEncode(str) { return btoa(str) .replace(/\+/g, '-') // 将 + 替换为 - .replace(/\//g, '_') // 将 / 替换为 _ .replace(/=/g, ''); // 去掉末尾的 = } ``` 重要提醒:载荷只是经过 Base64Url 编码,并非加密 这意味着任何人都可以解码并读取其中的内容,因此,切勿在载荷中存放密码、手机号等敏感信息 签名(Signature)是 JWT 的灵魂,用于验证令牌在传输过程中是否被篡改,并确保来源是否可信 签名的生成方式是将编码后的头部、载荷和一个密钥(对称算法)或私钥(非对称算法),通过头部指定的算法进行计算 以 `HS256` 算法为例,签名生成公式如下: ```javascript HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) ``` #### 3. JWT 工作原理 --- JWT 的认证流程是一个典型 “签发-传输-验证” 过程,实现了无状态认证 **一、签发(Issuance)** 用户登录成功后,认证服务器验证凭据,然后创建一个 JWT。 JWT 中包含了用户的身份信息和过期时间,并用服务器的密钥进行签名。最后,服务器将这个 JWT 返回给客户端 **二、传输(Transmission)** 客户端接收到 JWT 后,需要妥善保存(如:localStorage),在后续请求受保护的资源时,客户端需要在请求中携带这个 JWT。最常见的方式是放在 HTTP 请求头的 `Authorization` 字段中 **三、验证(Verification)** 服务器收到请求后,从请求中提取 JWT,然后进行验证: + 验证签名:使用相同的密钥验证签名,确保 JWT 未被篡改 + 检查声明:检查 `exp`(过期时间)等声明,确保 JWT 没有过期,依然有效 如果验证通过,服务器就可以信任 JWT 中的信息,并从 Payload 中获取用户身份进行后续业务处理 因为 JWT 本身包含了所有必要信息,服务器无需查询数据库或会话存储(session),这就是无状态的核心 #### 4. JWT 无状态的理解 --- JWT 之所以被称为 “无状态”,核心原因在于:服务器在生成并签发令牌后,就不再需要保存任何关于该令牌或用户会话的信息 | 特性 | 传统 Session 机制 | JWT 机制 | | ------------ | ------------ | ------------ | | 数据存储 | 用户登录状态、用户信息等数据存储在服务端(数据库) | 用户信息直接存储在令牌本身 | | 客户端凭证 | 客户端持有一个 `Session ID`,用于在服务器上查找对应的会话数据 | 客户端持有完整的 JWT 令牌 | | 验证方式 | 服务端收到 `Session ID`,查询内部的会话存储验证用户身份 | 服务端通过密钥验证签名 | | 服务器负担 | 服务端需要维护和管理大量的会话数据,占用内存和存储资源 | 服务端无需存储会话状态 | 无状态的设计带来了几个显著的优点,尤其适合现代应用架构: + 易于扩展和分布式部署:无需共享会话存储,不用担心会话同步的问题 + 降低服务器开销:服务器省去了读写会话存储的步骤,验证仅仅是计算签名,降低了延迟和后端负载 + 跨域和跨服务友好:不依赖特定的域名或 Cookie,方便在不同的域名之间传递或使用,非常适合前后端分离的项目 无状态是一把双刃剑,它在带来便利的同时也引入了新的挑战,最主要的问题是:令牌撤销 一旦 JWT 被签发,在它自然过期之前,它都是有效的: 因为服务器不记录令牌,所以无法简单的通过删除 “服务器上的会话” 来让一个已签发的令牌立即失效 #### 5. JWT 的唯一标识符 --- `jti` 是 JWT 标准(RFC 7519)中定义的一个可选声明,它的核心作用是为每一个 JWT 令牌提供一个全局唯一的标识符 JWT 本质上是无状态的,服务器签发后就不管了,这带来一个痛点:一旦签发,很难主动让令牌失效 `jti` 解决的问题:JWT 令牌本身是一个长字符串,使用 `jti` 可以简短其长度,精确管理令牌(黑名单机制的令牌撤销) #### 6. JWT 令牌撤销问题 --- 针对 JWT 无状态特性带来的令牌撤销问题,业界已发展出多种成熟的解决方案,可以根据项目实际场景进行选择: + 令牌黑名单(Token Blacklist) + 令牌版本控制(Token Versioning) ##### 令牌黑名单 这是最直接、最成熟的解决方案,核心思想是维护一个已撤销令牌的 “黑名单” 具体逻辑:在每次请求验证时,除了验证令牌签名和有效期,还需额外检查该令牌是否在黑名单中 实现方式:通常使用高性能的缓存数据库(如:Redis)来存储黑名单 + 存储内容:为了节省空间,通常不存储完整的令牌,而是存储令牌的唯一标识符(`jti`) + 过期时间:黑名单记录的过期时间和原 JWT 令牌的过期时间一致,这样当令牌自然过期时,黑名单记录也会自动清理 + 验证流程:用户请求到达时,服务端先解析 JWT 获取 `jti`,查询该 `jti` 是否在 Redis 黑名单中,若在则拒绝请求 思考一下,将 JWT 存入黑名单具体应该操作,比如:Redis 用什么数据类型 ?key 的命名怎么设计 ? + String 类型即可,核心需求只有两个:判断存在性、自动过期 + 设计 key 的命名时,核心原则是:可读性、隔离性、可维护性 `Key` 的命名格式推荐: ```plaintext {项目前缀}:{业务模块}:{唯一标识符} ``` 具体示例:项目叫 `mall`,专门用于认证 `auth`,且使用 `jti` 作为唯一标识 ```plaintext mall:auth:blacklist:a1b2c3d4-e5f6-... ``` | 组成部分 | 示例 | 作用 | 好处 | | ------------ | ------------ | ------------ | ------------ | | 项目前缀 | `mall:` | 区分不同的项目 | 防止同一个 Redis 实例被多个项目共用时,Key 发生冲突 | | 业务模块 | `auth:` | 区分业务功能 | 将认证相关的 Key 归类,方便管理 | | 功能标识 | `blacklist:` | 明确用途 | 一眼就能看出这是黑名单数据 | | 唯一标识 | `jti:` | 具体的令牌 ID | 保证 Key 的全局唯一性 | 采用这种结构化命名后,在运维和排查问题时会非常方便: ```bash # 快速查找所有黑名单相关的 key KEYS *blacklist* ``` 这种方案的优缺点: + 优点:可以实现令牌的即时、精确撤销 + 缺点:引入了有状态的存储层,增加了每次请求的验证开销(一次额外的缓存查询) 看完前面这种方案的用法,你可能会有疑问,我在什么时候把这个 `jti` 加入黑名单 ? + 应用场景:用户退出登录时,将当前 JWT 发给服务器,让服务器把这个 JWT 加入黑名单 + 当客户端想要注销时,把这个令牌发给服务器,告诉服务器,这个服务器我不要了,请把它加入黑名单 这时候你可能会意识到,这是用户主动退出让其令牌失效的场景,如果是后台管理员想让某个用户下线呢 ? + 后台管理员封禁一个用户或者修改用户密码后,让这个用户的所有令牌都失效,这时候只靠 `jti` 就不行了 + 因为我们并不知道用户手里有哪些 `jti`,就算知道,将该用户的所有 `jti` 全部放入黑名单中也不是明智的做法 针对 “特定用户” 的强制下线,可以存入一个封禁时间戳来处理: ```plaintext Key格式:blacklist:user:{user_id} Value:1712345678(当前时间戳,表示该时间之前的令牌都失效) TTL:根据令牌的过期时间决定,比如令牌的有效期是两个小时,该 TTL 设置同样时间即可 验证逻辑:从 JWT 载荷中获取签发时间,和存入 Redis 的封禁时间戳进行对比,若早于封禁时间,则认定当前 JWT 失效 ``` ##### 令牌版本控制 通过在服务端维护一个轻量级的版本号,而不是存储具体的令牌信息,验证令牌时额外验证下版本号 实现方式: + 签发令牌:在 payload 中加入一个版本号字段(`ver`),同时在用户数据库中也保存一个对应的 `token_version` + 验证令牌:验证 JWT 时不仅检查签名和有效期,还要比对 JWT 中的 `ver` 和用户的 `token_version` 是否一致 + 触发撤销:当需要撤销某个用户的所有令牌时(修改密码、强制下线),修改用户数据库的 `token_version`,让和 JWT 中的 `ver` 不一致即可