Note 19(第一章). SpringBoot3-登录校验内核构建:JWT 2.0 体系与双Token验证详细落地指南
Note 19(第一章). SpringBoot3-登录校验内核构建:JWT 2.0 体系与双Token验证详细落地指南
ProriseNote 19.1. 内核构建:JWT 2.0 体系与双令牌状态机
环境版本锁定
在开始编写内核代码之前,必须统一加密算法库与中间件版本,以确保密码学操作的一致性。
| 技术组件 | 版本号 | 说明 |
|---|---|---|
| JDK | 17 LTS | 必须支持 Sealed Classes 等新特性 |
| JJWT (Java JWT) | 0.12.5 | 2025 年最新标准,废弃了旧版 Parser 写法 |
| Spring Boot | 3.2.x | 配合 Spring Data Redis 3.2.0 |
| Spring Data Redis | 3.2.0 | 配合 Lettuce 客户端 |
| Hutool | 5.8.25 | 用于辅助的 UUID 生成 |
| Lombok | 1.18.30 | 简化 POJO 编写 |
本章摘要
本章我们将抛弃所有现成的 Auth 框架(如 Spring Security OAuth2),从零构建一套工业级的身份认证内核。你将掌握如何生成非对称加密的 RS256 密钥对,如何基于 Redis 实现毫秒级的黑名单操作,以及如何通过 “双令牌(Access + Refresh)” 机制解决 JWT 无法注销与续签的世纪难题。这是所有上层登录业务(账号、短信、微信)的基石。
本章学习路径
本章采用 “架构基建 → 密码学基石 → 单令牌实战 → 双令牌升级 → 安全加固” 的递进式结构:
阶段一:架构基建(19.1.1)
- 搭建多模块工程(auth-parent、auth-common、auth-core、auth-web)
- 封装统一响应(Result
) - 引入核心依赖
阶段二:密码学基石(19.1.2 - 19.1.3)
- 理解 RS256 非对称加密的架构优势
- 使用 OpenSSL 生成 RSA 密钥对
- 掌握 JWT 三段式结构与 JJWT 0.12 Builder 模式
阶段三:单令牌实战(19.1.4)
- 实现最简单的单令牌登录系统
- 暴露单令牌的两大致命问题
阶段四:Redis 与双令牌(19.1.5 - 19.1.7)
- 掌握 StringRedisTemplate 完整语法
- 升级为双令牌机制
- 实现主动注销的黑名单机制
阶段五:安全加固(19.1.8 - 19.1.9)
- 建立完善的异常处理体系
- 明确前端对接规范
19.1.1. 架构基建:多模块工程与统一响应
由于我们是一个长系列笔记,在以后的进阶教程中都会不断服用此内容,所以我们尽可能的构建一个较为优雅的基建内容
在开始编写任何业务代码之前,我们需要先回答一个问题:为什么不能把所有代码都塞在一个模块里?
假设我们现在有一个单模块项目,所有代码都在 src/main/java/com/example/auth 下:
1 | src/main/java/com/example/auth/ |
这种结构在项目初期看起来很简洁,但随着业务增长,会遇到三个致命问题:
问题一:职责不清
JwtUtil 是通用的 JWT 工具类,理论上可以被其他项目复用。但现在它和业务代码混在一起,如果其他项目想用,只能复制粘贴代码。
问题二:依赖混乱
AuthController 依赖了 TokenService,TokenService 依赖了 JwtUtil,JwtUtil 依赖了 RedisUtil。这些依赖关系全部隐藏在代码里,新人接手项目时很难理解架构。
问题三:测试困难
如果我想单独测试 JwtUtil 的功能,必须启动整个 Spring Boot 应用,因为它和 Web 层耦合在一起。
解决方案:多模块工程
我们将项目拆分成多个模块,每个模块有明确的职责:
1 | auth/ # 根目录(聚合器) |
架构设计说明:
- 根目录 pom.xml:聚合器,只负责声明模块,不管理依赖
- auth-parent/pom.xml:父 POM(Parent POM),统一管理依赖版本和构建配置
- auth-common:存放通用工具,可以被任何项目复用
- auth-core:存放 JWT 核心逻辑,不依赖 Web 层,可以单独测试
- auth-web:只负责 HTTP 接口,依赖 auth-core
依赖关系非常清晰:auth-web → auth-core → auth-common
为什么需要分离聚合器和父 POM?
- 职责分离:聚合器只负责模块声明,父 POM 只负责依赖管理,各司其职
- 易于维护:版本统一在
auth-parent管理,根目录保持简洁 - 可扩展性:未来可以复用
auth-parent给其他项目使用
1. 创建根聚合器和父工程
步骤 1:创建根目录聚合器
在 IDEA 中,选择 File → New → Project,选择 Maven,填写以下信息:
- Name:
auth - GroupId:
com.example - ArtifactId:
auth - Version:
1.0.0
点击 Create,IDEA 会生成根目录。
步骤 2:创建根目录的 pom.xml(聚合器)
📄 文件路径:pom.xml(根目录)
1 |
|
关键点解释:
<packaging>pom</packaging>:表示这是一个聚合器,不包含任何代码<modules>:声明了三个业务模块(不包含auth-parent,因为它只是父 POM,不是业务模块)
步骤 3:创建父工程 auth-parent
在根目录下创建 auth-parent 目录,并在其中创建 pom.xml。
📄 文件路径:auth-parent/pom.xml
1 |
|
关键点解释:
<packaging>pom</packaging>:表示这是一个父 POM,不包含任何代码- 不包含
<modules>:auth-parent是父 POM,不是聚合器,模块声明在根目录的pom.xml中 <dependencyManagement>:统一管理依赖版本,子模块继承这些版本,不需要重复写版本号<build>:统一管理构建配置(如编译器版本)
为什么需要 <relativePath>?
由于项目结构是:
1 | auth/ # 根目录(聚合器) |
Maven 默认会在父目录(根目录)查找 parent POM,但根目录的 pom.xml 的 artifactId 是 auth,而不是 auth-parent。因此需要在子模块中明确指定 <relativePath>../auth-parent/pom.xml</relativePath>,告诉 Maven 从 auth-parent 目录查找父 POM。
2. 创建子模块 auth-common
步骤 1:创建子模块
在 IDEA 中,右键点击根目录 auth,选择 New → Module,选择 Maven,填写以下信息:
- Name:
auth-common - Parent:
com.example:auth-parent:1.0.0
点击 Create,IDEA 会在根目录下创建一个子模块。
步骤 2:修改 auth-common 的 pom.xml
📄 文件路径:auth-common/pom.xml
1 |
|
步骤 3:创建统一响应类 Result
现在我们来解决一个实际问题:Controller 返回的数据格式不统一。
假设我们有两个接口:
1 | // 接口 1:登录 |
前端收到的响应格式完全不同:
1 | // 接口 1 的响应 |
前端开发者会抱怨:“为什么每个接口的格式都不一样?我怎么知道请求成功还是失败?”
解决方案:统一响应格式
我们定义一个 Result<T> 类,所有接口都返回这个格式:
1 | { |
📄 文件路径:auth-common/src/main/java/com/example/auth/common/model/Result.java
1 | package com.example.auth.common.model; |
现在,我们的接口可以统一返回格式:
1 | // 接口 1:登录 |
前端收到的响应格式完全一致:
1 | // 接口 1 的响应 |
步骤 4:创建雪花算法工具类
在后续章节中,我们需要生成全局唯一的用户 ID。我们使用 Hutool 提供的雪花算法。
📄 文件路径:auth-common/src/main/java/com/example/auth/common/util/SnowflakeIdGenerator.java
1 | package com.example.auth.common.util; |
3. 创建子模块 auth-core
步骤 1:创建子模块
在 IDEA 中,右键点击根目录 auth,选择 New → Module,选择 Maven,填写以下信息:
- Name:
auth-core - Parent:
com.example:auth-parent:1.0.0
步骤 2:修改 auth-core 的 pom.xml
📄 文件路径:auth-core/pom.xml
1 |
|
4. 创建子模块 auth-web
步骤 1:创建子模块
在 IDEA 中,右键点击根目录 auth,选择 New → Module,选择 Maven,填写以下信息:
- Name:
auth-web - Parent:
com.example:auth-parent:1.0.0
步骤 2:修改 auth-web 的 pom.xml
📄 文件路径:auth-web/pom.xml
1 |
|
步骤 3:创建启动类
📄 文件路径:auth-web/src/main/java/com/example/auth/web/AuthApplication.java
1 | package com.example.auth.web; |
步骤 4:创建配置文件
📄 文件路径:auth-web/src/main/resources/application.yml
1 | server: |
5. 验证多模块工程
现在,我们的多模块工程已经搭建完成。让我们验证一下是否能够正常启动。
步骤 1:刷新 Maven
在 IDEA 右侧的 Maven 面板中,点击刷新按钮,确保所有依赖都下载完成。
步骤 2:启动应用
运行 AuthApplication 的 main 方法,如果看到以下日志,说明启动成功:
1 | 2025-12-27 20:00:00 [main] INFO com.example.auth.web.AuthApplication - Started AuthApplication in 3.5 seconds |
6. 本节小结
我们完成了多模块工程的搭建,并封装了统一响应格式。
核心成果:
| 模块 | 职责 | 依赖关系 |
|---|---|---|
| 根目录 pom.xml | 聚合器,只负责模块声明 | 无 |
| auth-parent | 父 POM,统一管理依赖版本和构建配置 | 无 |
| auth-common | 公共基础模块(统一响应、工具类) | 继承 auth-parent |
| auth-core | 认证核心模块(JWT 逻辑) | 继承 auth-parent,依赖 auth-common |
| auth-web | Web 应用模块(Controller + 启动类) | 继承 auth-parent,依赖 auth-core |
架构优势:
- 职责分离:聚合器和父 POM 各司其职,根目录保持简洁
- 易于维护:版本统一在
auth-parent管理,修改一处即可 - 可扩展性:未来可以复用
auth-parent给其他项目使用 - 结构清晰:依赖关系明确,新人容易理解
方法速查表:
| 类名 | 方法名 | 作用 |
|---|---|---|
| Result | ok(T data) | 成功响应(带数据) |
| Result | ok() | 成功响应(不带数据) |
| Result | fail(Integer code, String message) | 失败响应 |
| SnowflakeIdGenerator | nextId() | 生成全局唯一 ID |
现在,我们已经有了一个结构清晰的多模块工程,可以开始编写 JWT 核心逻辑了。
19.1.2. 密码学基石:RSA 密钥对生成与加载
在上一节中,我们搭建了多模块工程的骨架。现在我们需要解决一个核心问题:如何生成和管理 JWT 的签名密钥?
假设我们现在要实现一个登录系统,用户登录成功后,服务器会生成一个 Token 返回给前端。前端在后续的请求中携带这个 Token,服务器验证 Token 的有效性。
这里有一个关键问题:服务器如何确保 Token 没有被篡改
答案是:使用密码学签名。服务器在生成 Token 时,会用一个密钥对 Token 进行签名。当前端发送 Token 回来时,服务器用同一个密钥验证签名。如果 Token 被篡改,签名验证就会失败。
但这里又引出了一个新问题:应该使用什么样的签名算法?
1. HS256 vs RS256:架构抉择
在 JWT 的世界里,有两种主流的签名算法:
HS256(对称加密)
使用同一个密钥进行签名和验证:
1 | 签名:Token + 密钥 → 签名 |
这种方案的问题在于:签名和验证使用同一个密钥。
假设我们的系统有 20 个微服务,每个服务都需要验证 Token。那么这 20 个服务都必须持有同一个密钥。这带来两个风险:
风险一:攻击面扩大
密钥存在于 20 个不同的配置文件、环境变量、容器镜像中。任何一个环节泄漏,整个系统就沦陷了。
风险二:密钥轮换困难
当我们需要更换密钥时(如定期安全审计、员工离职),必须同时更新 20 个服务的配置并重启。在生产环境中,这几乎不可能做到原子性切换。
RS256(非对称加密)
使用一对密钥:私钥用于签名,公钥用于验证:
1 | 签名:Token + 私钥 → 签名 |
在这种架构下:
- 认证服务 持有私钥,负责签发 Token。私钥是整个系统中唯一的敏感数据,只需要保护一个点。
- 网关和业务服务 持有公钥,负责验证 Token。公钥可以公开分发,即使泄漏也不会影响安全性(攻击者无法用公钥伪造签名)。
这种设计的核心价值在于 职责分离:只有认证服务才有 “印钞权”,其他服务只能 “验钞” 而不能 “造钞”。
架构对比图:
| 对比维度 | HS256(对称加密) | RS256(非对称加密) |
|---|---|---|
| 密钥数量 | 1 个(签名和验证共用) | 2 个(私钥签名,公钥验证) |
| 密钥分发 | 所有服务都需要持有密钥 | 只有认证服务持有私钥 |
| 安全风险 | 任何一个服务泄漏,全局沦陷 | 只有认证服务泄漏才会沦陷 |
| 密钥轮换 | 需要同时更新所有服务 | 只需要更新认证服务 |
| 性能 | 快(对称加密) | 稍慢(非对称加密) |
在微服务架构下,我们选择 RS256,用稍微的性能损失换取更高的安全性和更灵活的架构。
2. 使用 OpenSSL 生成 RSA 密钥对
现在我们需要生成一对 RSA 密钥。这是整个认证系统的基石。
关键认知:Java 的 KeyFactory 对密钥格式有严格要求:
- 私钥必须是 PKCS#8 格式
- 公钥必须是 X.509 格式
如果格式不对,会抛出 InvalidKeySpecException 异常。
步骤 1:打开终端
- Windows 用户:按
Win + R,输入cmd,回车打开命令提示符。如果你的电脑没有 OpenSSL,可以安装 Git for Windows(https://git-scm.com/download/win),Git Bash 会自带 OpenSSL。 - Mac/Linux 用户:按
Cmd + 空格(Mac)或Ctrl + Alt + T(Linux)打开终端。
步骤 2:生成 PKCS#8 格式的私钥
在终端中输入以下命令:
1 | openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 |
命令解释:
openssl genpkey:使用 OpenSSL 生成密钥(新版命令,自动生成 PKCS#8 格式)-algorithm RSA:指定算法为 RSA-out private_key.pem:指定输出文件名为private_key.pem-pkeyopt rsa_keygen_bits:2048:密钥长度为 2048 位
成功现象:
执行后,你会看到类似这样的输出:
1 | .....+++ |
并且在当前目录下会生成一个 private_key.pem 文件。
验证私钥格式:
用文本编辑器打开 private_key.pem,你应该看到:
1 | -----BEGIN PRIVATE KEY----- |
关键标识:-----BEGIN PRIVATE KEY-----(没有 “RSA” 字样),这就是 PKCS#8 格式。
❌ 错误格式示例(PKCS#1 格式,Java 无法识别):
1 | -----BEGIN RSA PRIVATE KEY----- |
如果你看到 RSA PRIVATE KEY,说明格式不对,需要转换。
步骤 3:从私钥中提取公钥
继续在终端中输入:
1 | openssl rsa -pubout -in private_key.pem -out public_key.pem |
命令解释:
openssl rsa:使用 OpenSSL 处理 RSA 密钥-pubout:输出公钥-in private_key.pem:指定输入文件(刚才生成的私钥)-out public_key.pem:指定输出文件名为public_key.pem
成功现象:
执行后,你会看到:
1 | writing RSA key |
并且在当前目录下会生成一个 public_key.pem 文件。
验证公钥格式:
用文本编辑器打开 public_key.pem,你应该看到:
1 | -----BEGIN PUBLIC KEY----- |
关键标识:-----BEGIN PUBLIC KEY-----,这就是 X.509 格式(Java 可以识别)。
步骤 4:查看生成的密钥
现在你的目录下应该有两个文件:
1 | private_key.pem # 私钥(PKCS#8 格式,敏感文件,不能泄漏) |
3. 将密钥放入项目中
步骤 1:创建密钥存放目录
在 auth-web 模块中,找到 src/main/resources 目录,在其下创建一个名为 certs 的文件夹:
1 | auth-web/src/main/resources/ |
步骤 2:复制密钥文件
将刚才生成的 private_key.pem 和 public_key.pem 复制到 auth-web/src/main/resources/certs/ 目录下。
步骤 3:配置 .gitignore(重要!)
为了防止私钥被提交到 Git 仓库,必须在项目根目录的 .gitignore 文件中添加以下内容:
1 | # 忽略私钥文件 |
为什么只忽略私钥?
- 私钥:绝对不能泄漏,必须忽略。
- 公钥:可以公开,可以提交到 Git,方便团队其他成员使用。
4. 封装 RsaKeyManager 工具类
现在我们有了密钥文件,但它们是 PEM 格式(文本格式)。我们需要将它们加载到 Java 程序中,转换为 PrivateKey 和 PublicKey 对象。
为什么需要转换?
Java 的 PrivateKey 和 PublicKey 对象需要的是二进制格式。我们需要一个工具类来完成以下工作:
- 读取
resources目录下的密钥文件 - 去除 PEM 格式的头尾标记(
-----BEGIN...-----) - 将 Base64 编码的字符串解码为字节数组
- 使用 Java 的
KeyFactory将字节数组转换为密钥对象
📄 文件路径:auth-core/src/main/java/com/example/auth/core/util/crypto/RsaKeyManager.java
1 | package com.example.auth.core.util.crypto; |
关键知识点解读:
A. 为什么要去除 PEM 格式的头尾标记?
PEM 格式是一种文本格式,用于存储密钥和证书。它的结构是:
1 | -----BEGIN [类型]----- |
Java 的 KeyFactory 只能识别纯粹的 Base64 编码数据,不能识别这些标记,所以必须先去除。
B. PKCS8 和 X509 是什么?
- PKCS8:私钥的存储格式标准(Public Key Cryptography Standards #8)
- X509:公钥证书的标准格式
这两个标准定义了密钥在二进制层面的存储结构。Java 的 KeyFactory 需要知道密钥是按照哪种标准编码的,才能正确解析。
C. 为什么用 ClassPathResource 而不是 File?
当项目打包成 jar 包后,resources 目录下的文件会被打包到 jar 包内部。此时:
- ❌
new File("certs/public_key.pem")会失败,因为 jar 包内的文件不是真正的文件系统路径 - ✅
new ClassPathResource("certs/public_key.pem")可以正确读取 jar 包内的资源
5. 本节小结
我们完成了 RSA 密钥对的生成与加载。
核心成果:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 1 | 使用 OpenSSL 生成私钥 | private_key.pem |
| 2 | 从私钥中提取公钥 | public_key.pem |
| 3 | 将密钥放入项目 | auth-web/src/main/resources/certs/ |
| 4 | 封装工具类 | RsaKeyManager |
方法速查表:
| 类名 | 方法名 | 作用 | 参数 | 返回值 |
|---|---|---|---|---|
| RsaKeyManager | readResourceFile | 读取 resources 目录下的文件 | resourcePath | String |
| RsaKeyManager | loadPrivateKey | 加载私钥 | privateKeyPem | PrivateKey |
| RsaKeyManager | loadPublicKey | 加载公钥 | publicKeyPem | PublicKey |
架构对比表:
| 对比维度 | HS256(对称加密) | RS256(非对称加密) |
|---|---|---|
| 密钥数量 | 1 个 | 2 个(私钥 + 公钥) |
| 密钥分发 | 所有服务都需要 | 只有认证服务持有私钥 |
| 安全风险 | 任何服务泄漏,全局沦陷 | 只有认证服务泄漏才沦陷 |
| 密钥轮换 | 需要同时更新所有服务 | 只需要更新认证服务 |
现在,我们已经拥有了一对可靠的 RSA 密钥,可以开始构建 JWT 工具类了。
19.1.3. JWT 速成:三段式结构与 JJWT 0.12
在上一节中,我们生成了 RSA 密钥对。现在我们需要理解:JWT 到底是什么?它是如何工作的?
假设你现在去电影院看电影。你在售票处买票后,工作人员会给你一张电影票。这张票上印着:
- 电影名称(如《流浪地球 3》)
- 场次时间(如 2025-12-27 20:00)
- 座位号(如 5 排 8 座)
- 一个防伪水印(防止别人伪造票)
当你进入影厅时,检票员只需要看一眼票上的防伪水印,就知道这张票是真的,不需要回到售票处再次确认。
JWT 的工作原理和这张电影票非常相似:
- 电影票 = JWT Token
- 票上的信息(电影名称、场次、座位)= JWT 的 Payload(载荷)
- 防伪水印 = JWT 的 Signature(签名)
- 检票员 = 业务服务(验证 Token)
1. JWT 三段式结构
一个完整的 JWT Token 由三部分组成,用 . 分隔:
1 | eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. |
这三部分分别是:
第一部分:Header(头部)
1 | { |
alg:签名算法(Algorithm),这里是 RS256typ:类型(Type),固定为 JWT
这部分经过 Base64Url 编码 后,就是 Token 的第一段:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
第二部分:Payload(载荷)
1 | { |
userId:用户 ID(自定义字段)username:用户名(自定义字段)exp:过期时间(Expiration,标准字段)
这部分经过 Base64Url 编码 后,就是 Token 的第二段:eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzUyODAwMDB9
第三部分:Signature(签名)
1 | Signature = RSA_Sign( |
签名的作用是 防止 Token 被篡改。如果有人修改了 Payload 中的 userId,签名验证就会失败。
这部分就是 Token 的第三段:SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
完整的 Token:
1 | Header.Payload.Signature |
2. 手动解析 JWT
为了让你更直观地理解 JWT 的结构,我们可以使用在线工具手动解析一个 Token。
步骤 1:访问 jwt.io
打开浏览器,访问 https://jwt.io/
步骤 2:粘贴 Token
在页面左侧的 “Encoded” 区域,粘贴以下 Token:
1 | eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzUyODAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |
步骤 3:查看解析结果
页面右侧会自动显示解析后的内容:
Header:
1 | { |
Payload:
1 | { |
Signature:
1 | SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |
理解 Base64Url 编码
你可能会好奇:为什么 {"alg":"RS256","typ":"JWT"} 会变成 eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9?
这是因为使用了 Base64Url 编码。它和普通的 Base64 编码类似,但做了两个调整:
- 将
+替换为- - 将
/替换为_ - 去除末尾的
=
为什么要这样做?
因为 JWT Token 经常会出现在 URL 中(如 https://example.com/api?token=xxx)。普通 Base64 编码中的 + 和 / 在 URL 中有特殊含义,会导致解析错误。Base64Url 编码解决了这个问题。
3. JJWT 0.12 Builder 模式
现在我们理解了 JWT 的结构,接下来需要用 Java 代码来生成和解析 JWT。
在 Java 世界里,最流行的 JWT 库是 JJWT(Java JWT)。我们使用的是 0.12.5 版本,这是 2025 年的最新标准。
为什么要强调版本?
因为 JJWT 0.12 引入了 类型安全 的设计理念,API 发生了重大变化。如果你直接照搬网上的旧教程,代码很可能会报错。
旧版本(0.9.x)的写法:
1 | // ❌ 旧版本写法(存在隐患) |
这种写法的致命伤在于:算法与密钥类型分离。你指定了对称加密算法 HS256,却传入了非对称加密的 RSA 私钥。编译器无法发现这个逻辑错误,只有在代码运行到这一行时才会抛出异常。
新版本(0.12.x)的写法:
1 | // ✅ 新版本写法(类型安全) |
新版本强制要求你只传入密钥,算法由库自动推断或通过强类型常量指定。这样编译器就能在编译期发现错误。
4. 封装 JwtUtil 工具类
现在我们来封装一个 JWT 工具类,提供 Token 的生成、解析、验证功能。
步骤 1:创建 JWT 配置类
首先,我们需要定义一个配置类来承载 JWT 的相关配置(如有效期、密钥路径等)。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java
1 | package com.example.auth.core.config.properties; |
步骤 2:配置文件映射
在 auth-web/src/main/resources/application.yml 中添加对应的配置:
1 | jwt: |
步骤 3:封装 JwtUtil 工具类
📄 文件路径:auth-core/src/main/java/com/example/auth/core/util/JwtUtil.java
1 | package com.example.auth.core.util; |
关键设计细节解读:
A. 时钟偏差 (Clock Skew) 的必要性
在代码中我们设置了 .clockSkewSeconds(30)。这是一个非常重要的生产环境细节。
在微服务架构中,认证服务和网关服务可能部署在不同的物理机上,时间可能存在微小的偏差。如果认证服务时间快了 1 秒,它签发的 Token 对于网关来说就是 “未来 1 秒才生效” 的,会导致 PrematureJwtException。设置容忍度可以完美解决这个问题。
B. JTI (JWT ID) 的战略意义
我们在创建 Token 时使用 Hutool 的 IdUtil.fastSimpleUUID() 生成了 JTI 并赋值给 .id(jti)。这不仅仅是一个随机数,它是后续实现 Token 黑名单(Token Revocation)的基础。当用户注销时,我们只需将这个 JTI 放入 Redis,即可让未过期的 Token 提前失效。
为什么用 Hutool 而不是 JDK 自带的 UUID?
UUID.randomUUID().toString()生成的是带连字符的格式:550e8400-e29b-41d4-a716-446655440000IdUtil.fastSimpleUUID()生成的是无连字符的格式:550e8400e29b41d4a716446655440000- 无连字符的格式更短,存储和传输效率更高
C. @PostConstruct 的性能优化
我们在 init() 方法上使用了 @PostConstruct 注解。这意味着密钥的加载和解析只会在应用启动时执行一次,然后缓存在 privateKey 和 publicKey 字段中。
如果不这样做,每次生成或验证 Token 都需要重新读取文件、解析 PEM 格式,性能会非常差。
5. 本节小结
我们完成了 JWT 工具类的构建。
核心成果:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 1 | 理解 JWT 三段式结构 | Header + Payload + Signature |
| 2 | 使用 jwt.io 手动解析 Token | 理解 Base64Url 编码 |
| 3 | 掌握 JJWT 0.12 Builder 模式 | 类型安全的 API |
| 4 | 封装 JwtUtil 工具类 | 提供生成、解析、验证功能 |
方法速查表:
| 类名 | 方法名 | 作用 | 参数 | 返回值 |
|---|---|---|---|---|
| JwtUtil | createToken | 生成 Token | userId, username, extraClaims | String |
| JwtUtil | parseToken | 解析 Token | token | Claims |
| JwtUtil | validateToken | 验证 Token | token | boolean |
| JwtUtil | getUserIdFromToken | 提取用户 ID | token | Long |
| JwtUtil | getUsernameFromToken | 提取用户名 | token | String |
| JwtUtil | getJtiFromToken | 提取 JTI | token | String |
JWT 三段式结构:
| 部分 | 内容 | 编码方式 |
|---|---|---|
| Header | {"alg":"RS256","typ":"JWT"} | Base64Url |
| Payload | {"userId":1001,"username":"admin","exp":1735280000} | Base64Url |
| Signature | RSA 签名 | 二进制 |
JJWT 0.12 vs 旧版本:
| 对比维度 | 旧版本(0.9.x) | 新版本(0.12.x) |
|---|---|---|
| 签名方式 | signWith(Algorithm, Key) | signWith(Key, Algorithm) |
| 类型安全 | ❌ 运行时才报错 | ✅ 编译期检查 |
| 解析器构建 | 直接使用 | 必须调用 build() |
现在,我们已经拥有了一个强大的 JWT 工具类。在下一节中,我们将实现一个最简单的单令牌登录系统,让你看到 JWT 的实际运作。
19.1.4. 单 Token 验证实战
在上一节中,我们封装了 JWT 工具类。现在我们来实现一个最简单的登录系统,让你看到 JWT 是如何工作的。
场景引入:
假设我们现在要开发一个在线文档系统。用户需要先登录,然后才能查看和编辑文档。我们的目标是:
- 用户输入用户名和密码,点击登录
- 服务器验证通过后,生成一个 Token 返回给前端
- 前端在后续的请求中携带这个 Token
- 服务器验证 Token 的有效性,允许用户访问受保护的资源
这是最基础的认证流程。我们先用单令牌实现,然后在后续章节中暴露它的问题,再升级为双令牌。
1. 封装 TokenService 业务层
首先,我们需要一个业务层来封装 Token 的创建和验证逻辑。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenService.java
1 | package com.example.auth.core.service; |
2. 创建 AuthController 控制层
现在我们创建一个 Controller,提供登录和验证接口。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
1 | package com.example.auth.web.controller; |
3. Postman 测试
现在启动应用,我们通过 Postman 来测试单令牌的完整流程。
步骤 1:启动应用
运行 AuthApplication 的 main 方法,确保应用启动成功。
步骤 2:测试登录接口
- 方法:
POST - URL:
http://localhost:8080/auth/login?userId=1001&username=admin - 响应示例:
1 | { |
复制 token 的值,我们将在后续测试中使用。
步骤 3:测试验证接口
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Token)
- 响应示例:
1 | { |
步骤 4:测试 Token 过期
为了快速测试 Token 过期的情况,我们可以修改配置文件,将有效期改为 1 分钟:
修改配置:auth-web/src/main/resources/application.yml
1 | jwt: |
重启应用,重新登录获取 Token,然后等待 1 分钟后再次验证。
响应示例:
1 | { |
4. 暴露单令牌的两大致命问题
现在我们的单令牌系统已经能够正常工作了。但在实际使用中,我们会遇到两个致命问题:
问题一:无法主动注销
假设用户在公司电脑上登录了系统,下班后忘记退出。第二天他发现后,想要远程注销这个登录会话。
但在单令牌机制下,这是做不到的。因为 Token 是自包含的,服务器没有存储任何状态。只要 Token 没有过期,它就一直有效。
即使用户点击了 “退出登录” 按钮,前端删除了 Token,但如果有人之前复制了这个 Token,他依然可以在 Token 过期前继续使用。
问题二:有效期困境
我们现在面临一个两难的选择:
- 有效期设置太短(如 15 分钟):用户体验差。用户正在编辑文档,突然 Token 过期了,所有操作都失败了,必须重新登录。
- 有效期设置太长(如 7 天):安全风险高。如果 Token 被盗,攻击者可以在 7 天内随意访问用户的数据。
这两个问题看起来是矛盾的:要么牺牲用户体验,要么牺牲安全性。
解决方案预告:
在下一节中,我们将引入 Redis 和 双令牌机制,完美解决这两个问题:
- Redis 黑名单:实现主动注销功能
- 双令牌机制:用短效的 Access Token 保证安全,用长效的 Refresh Token 保证体验
5. 本节小结
我们实现了一个最简单的单令牌登录系统,并暴露了它的两大致命问题。
核心成果:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 1 | 封装 TokenService | 提供 createToken 和 validateToken 方法 |
| 2 | 创建 AuthController | 提供 /login 和 /validate 接口 |
| 3 | Postman 测试 | 验证单令牌的完整流程 |
| 4 | 暴露问题 | 无法主动注销 + 有效期困境 |
方法速查表:
| 类名 | 方法名 | 作用 | 参数 | 返回值 |
|---|---|---|---|---|
| TokenService | createToken | 创建令牌 | userId, username | String |
| TokenService | validateToken | 验证令牌 | token | boolean |
| AuthController | /auth/login | 登录接口 | userId, username | Result |
| AuthController | /auth/validate | 验证接口 | Authorization (Header) | Result |
单令牌的两大问题:
| 问题 | 描述 | 影响 |
|---|---|---|
| 无法主动注销 | Token 是自包含的,服务器无法主动作废 | 安全风险高 |
| 有效期困境 | 短了用户烦,长了不安全 | 用户体验差 |
现在,我们已经理解了单令牌的局限性。在下一节中,我们将引入 Redis,为双令牌机制打下基础。
19.1.5. Redis 速成:StringRedisTemplate 完整语法
在上一节中,我们暴露了单令牌的两大致命问题。现在我们需要引入 Redis 来解决这些问题。
场景引入:
你可能会问:为什么需要 Redis?我们不是说好了 JWT 是无状态的吗?
确实,JWT 的设计理念是无状态。但在实际业务中,我们需要一些 “状态管理” 的能力:
- 主动注销:当用户点击 “退出登录” 时,我们需要立即让 Token 失效
- 互斥登录:当用户在新设备登录时,我们需要踢掉旧设备
- Token 刷新:当 Access Token 过期时,我们需要用 Refresh Token 换取新的 Token
这些功能都需要服务器 “记住” 一些状态。而 Redis 就是最适合存储这些状态的中间件。
为什么选择 Redis 而不是 MySQL?
| 对比维度 | MySQL | Redis |
|---|---|---|
| 读写速度 | 毫秒级(磁盘 IO) | 微秒级(内存操作) |
| 过期机制 | 需要定时任务清理 | 原生支持 TTL 自动清理 |
| 数据结构 | 只有表(Table) | 支持 String、Set、ZSet 等多种结构 |
| 适用场景 | 持久化存储 | 临时状态存储 |
对于 Token 这种 “临时状态”,Redis 是最佳选择。
1. Redis 五大数据结构
Redis 支持五种数据结构,每种结构适用于不同的场景:
| 数据结构 | 特点 | 适用场景 |
|---|---|---|
| String | 最简单的键值对 | 存储 Token、用户 ID 等简单数据 |
| List | 有序列表 | 消息队列、时间线 |
| Set | 无序不重复集合 | 在线设备列表、标签系统 |
| ZSet | 有序集合(带分数) | 排行榜、多设备限制(按登录时间排序) |
| Hash | 字段-值对 | 存储对象(如用户信息) |
在我们的认证系统中,主要会用到:
- String:存储 Refresh Token、黑名单标记
- Set:存储用户的在线设备列表
- ZSet:存储用户的在线设备列表(按登录时间排序,用于多设备限制)
2. StringRedisTemplate 完整语法
Spring 提供了 StringRedisTemplate 来操作 Redis。它的 API 设计非常直观:先选择数据结构,再执行操作。
核心方法:
opsForValue():操作 StringopsForList():操作 ListopsForSet():操作 SetopsForZSet():操作 ZSetopsForHash():操作 Hash
2.1. String 操作(opsForValue())
String 是最常用的数据结构,用于存储简单的键值对。
常用方法速查表:
| 方法 | 作用 | 示例 | 说明 |
|---|---|---|---|
set(key, value) | 存储字符串 | set("token:123", "userId:1001") | 永久存储 |
set(key, value, timeout, unit) | 存储并设置过期时间 | set("token:123", "userId:1001", 15, TimeUnit.MINUTES) | 15 分钟后自动删除 |
setEx(key, value, seconds) | 存储并设置过期时间(秒) | setEx("token:123", "userId:1001", 900) | 等价于上面的方法 |
get(key) | 获取字符串 | get("token:123") | 返回 “userId: 1001” |
increment(key) | 自增 | increment("login:fail:admin") | 用于计数(如登录失败次数) |
increment(key, delta) | 自增指定值 | increment("score", 10) | 增加 10 分 |
decrement(key) | 自减 | decrement("stock") | 减少库存 |
delete(key) | 删除键 | delete("token:123") | 删除指定的键 |
hasKey(key) | 检查键是否存在 | hasKey("token:123") | 返回 true/false |
expire(key, timeout, unit) | 设置过期时间 | expire("token:123", 10, TimeUnit.MINUTES) | 10 分钟后过期 |
getExpire(key) | 获取剩余过期时间 | getExpire("token:123") | 返回剩余秒数 |
代码示例:
1 | // 存储 Refresh Token(7 天过期) |
2.2. Set 操作(opsForSet())
Set 是无序不重复集合,用于存储不需要排序的元素列表。
常用方法速查表:
| 方法 | 作用 | 示例 | 说明 |
|---|---|---|---|
add(key, values) | 添加元素 | add("user:1001:devices", "jti123", "jti456") | 可以一次添加多个 |
members(key) | 获取所有元素 | members("user:1001:devices") | 返回 Set |
isMember(key, value) | 检查元素是否存在 | isMember("user:1001:devices", "jti123") | 返回 true/false |
remove(key, values) | 移除元素 | remove("user:1001:devices", "jti123") | 可以一次移除多个 |
size(key) | 获取元素数量 | size("user:1001:devices") | 返回 Long |
pop(key) | 随机弹出一个元素 | pop("user:1001:devices") | 弹出后元素被删除 |
代码示例:
1 | // 记录用户的在线设备 |
2.3. ZSet 操作(opsForZSet())
ZSet(Sorted Set)是有序集合,每个元素都有一个分数(Score),按分数排序。
常用方法速查表:
| 方法 | 作用 | 示例 | 说明 |
|---|---|---|---|
add(key, value, score) | 添加元素(带分数) | add("user:1001:devices:sorted", "jti123", 1735280000) | Score 通常是时间戳 |
range(key, start, end) | 按分数升序获取 | range("user:1001:devices:sorted", 0, 0) | 获取分数最小的元素 |
reverseRange(key, start, end) | 按分数降序获取 | reverseRange("user:1001:devices:sorted", 0, -1) | 获取所有元素(最新的在前) |
size(key) | 获取元素数量 | size("user:1001:devices:sorted") | 返回 Long |
remove(key, values) | 移除元素 | remove("user:1001:devices:sorted", "jti123") | 可以一次移除多个 |
score(key, value) | 获取元素的分数 | score("user:1001:devices:sorted", "jti123") | 返回 Double |
removeRange(key, start, end) | 按排名移除 | removeRange("user:1001:devices:sorted", 0, 0) | 移除排名最低的元素 |
代码示例:
1 | // 记录用户的在线设备(按登录时间排序) |
3. Pipeline 批量操作
在实际业务中,我们经常需要执行多个 Redis 操作。如果每个操作都是一次网络请求,性能会很差。
问题场景:
假设用户登录时,我们需要做三件事:
- 存储 Refresh Token
- 将 Token ID 记录到用户的在线设备列表
- 给在线列表设置过期时间
普通写法(3 次网络请求):
1 | // 第 1 次网络请求 |
每次网络请求的耗时大约是 1-2 毫秒(局域网环境)。3 次请求就是 3-6 毫秒。
Pipeline 写法(1 次网络请求):
1 | redisTemplate.executePipelined((RedisCallback <Object>) connection -> { |
性能对比:
| 方式 | 网络请求次数 | 耗时 | 性能提升 |
|---|---|---|---|
| 普通写法 | 3 次 | 3-6 毫秒 | - |
| Pipeline | 1 次 | 1-2 毫秒 | 60% 提升 |
什么是 Pipeline?
Pipeline(管道)是 Redis 提供的一种批量操作机制。它的原理是:
- 客户端将多个命令打包成一个请求
- 一次性发送给 Redis 服务器
- Redis 服务器依次执行这些命令
- 将所有结果打包返回给客户端
这样就把 N 次网络往返变成了 1 次网络往返。
注意事项:
- Pipeline 中的命令是 顺序执行 的,不是原子性的
- 如果需要原子性,应该使用 Lua 脚本 或 Redis 事务
- Pipeline 适用于 “批量操作,但不需要原子性” 的场景
4. 常用操作对照表
为了让你更快地上手,这里提供一个 Java 操作与 Redis 操作的对照表:
| Java 操作 | Redis 操作 | 说明 |
|---|---|---|
map.put("k", "v") | opsForValue().set("k", "v") | 存储字符串 |
map.get("k") | opsForValue().get("k") | 获取字符串 |
map.remove("k") | delete("k") | 删除键 |
map.containsKey("k") | hasKey("k") | 检查键是否存在 |
set.add("v") | opsForSet().add("k", "v") | 添加到集合 |
set.contains("v") | opsForSet().isMember("k", "v") | 检查元素是否存在 |
set.remove("v") | opsForSet().remove("k", "v") | 从集合中移除 |
set.size() | opsForSet().size("k") | 获取集合大小 |
5. 本节小结
我们掌握了 StringRedisTemplate 的完整语法,为双令牌机制打下了基础。
核心成果:
| 数据结构 | 操作方法 | 适用场景 |
|---|---|---|
| String | opsForValue() | 存储 Token、黑名单标记、计数器 |
| Set | opsForSet() | 存储在线设备列表(无序) |
| ZSet | opsForZSet() | 存储在线设备列表(按登录时间排序) |
方法速查表(String):
| 方法 | 作用 | 示例 |
|---|---|---|
set(key, value, timeout, unit) | 存储并设置过期时间 | set("token:123", "1001", 15, TimeUnit.MINUTES) |
get(key) | 获取字符串 | get("token:123") |
increment(key) | 自增 | increment("login:fail:admin") |
delete(key) | 删除键 | delete("token:123") |
hasKey(key) | 检查键是否存在 | hasKey("token:123") |
方法速查表(Set):
| 方法 | 作用 | 示例 |
|---|---|---|
add(key, values) | 添加元素 | add("user:1001:devices", "jti123") |
members(key) | 获取所有元素 | members("user:1001:devices") |
isMember(key, value) | 检查元素是否存在 | isMember("user:1001:devices", "jti123") |
remove(key, values) | 移除元素 | remove("user:1001:devices", "jti123") |
size(key) | 获取元素数量 | size("user:1001:devices") |
方法速查表(ZSet):
| 方法 | 作用 | 示例 |
|---|---|---|
add(key, value, score) | 添加元素(带分数) | add("user:1001:devices:sorted", "jti123", 1735280000) |
range(key, start, end) | 按分数升序获取 | range("user:1001:devices:sorted", 0, 0) |
reverseRange(key, start, end) | 按分数降序获取 | reverseRange("user:1001:devices:sorted", 0, -1) |
size(key) | 获取元素数量 | size("user:1001:devices:sorted") |
remove(key, values) | 移除元素 | remove("user:1001:devices:sorted", "jti123") |
Pipeline 性能对比:
| 方式 | 网络请求次数 | 耗时 | 性能提升 |
|---|---|---|---|
| 普通写法 | N 次 | N × (1-2 毫秒) | - |
| Pipeline | 1 次 | 1-2 毫秒 | 60% 提升 |
现在,我们已经掌握了 Redis 的基本操作。在下一节中,我们将升级为双令牌机制,彻底解决单令牌的两大问题。
19.1.6. 双令牌升级:解决问题
在上一节中,我们掌握了 Redis 的基本操作。现在我们来升级为双令牌机制,彻底解决单令牌的两大问题。
场景引入:
回顾一下单令牌的两大问题:
- 无法主动注销:Token 是自包含的,服务器无法主动作废
- 有效期困境:短了用户烦,长了不安全
现在我们用一个巧妙的设计来解决这两个问题:双令牌机制。
设计理念:
我们不再只发一个 Token,而是发两个:
- Access Token(访问令牌):短效(15 分钟),真正用来请求接口的凭证
- Refresh Token(刷新令牌):长效(7 天),专门用来换取新 Access Token 的凭证
这就像你去银行办业务:
- Access Token = 排队号码牌(有效期很短,过期就作废)
- Refresh Token = 身份证(有效期很长,可以用来重新取号)
当你的号码牌过期了,你不需要重新排队,只需要拿着身份证去柜台换一个新号码牌。
架构图:
1 | 用户登录 |
1. 扩展 JWT 配置类
在实现双令牌之前,我们需要先将 Refresh Token 的有效期配置化,而不是硬编码在代码里。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java
在原有的 JwtProperties 类中,我们 追加 以下字段:
1 | package com.example.auth.core.config.properties; |
步骤 2:修改配置文件
📄 文件路径:auth-web/src/main/resources/application.yml
在原有的配置基础上,追加 Refresh Token 的配置:
1 | jwt: |
2. 定义 AuthToken 模型
首先,我们需要定义一个模型来承载双令牌。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/model/AuthToken.java
1 | package com.example.auth.core.model; |
3. 定义 Redis Key 常量
为了防止 Key 冲突,我们需要定义统一的 Key 命名规范。
为什么需要命名规范?
在实际项目中,Redis 可能被多个系统共享。如果没有统一的命名规范,很容易出现 Key 冲突:
- 系统 A 使用
token:123存储用户 Token - 系统 B 也使用
token:123存储订单 Token - 结果:两个系统的数据互相覆盖,导致严重的 Bug
命名规范:项目:模块:业务:主键
我们采用四段式命名规范,确保 Key 的唯一性和可读性:
| 层级 | 说明 | 示例 |
|---|---|---|
| 项目 | 项目名称,区分不同系统 | auth(认证系统)、order(订单系统) |
| 模块 | 模块名称,区分不同功能 | token(令牌模块)、user(用户模块) |
| 业务 | 业务类型,区分不同用途 | refresh(刷新令牌)、black(黑名单) |
| 主键 | 业务主键,唯一标识数据 | a1b2c3d4(refreshToken)、1001(userId) |
完整示例:
1 | auth:token:refresh:a1b2c3d4e5f6 # 认证系统 - 令牌模块 - 刷新令牌 - Token ID |
优势对比:
| 命名方式 | 示例 | 优势 | 劣势 |
|---|---|---|---|
| ❌ 无规范 | token123 | 简短 | 无法区分系统和业务,容易冲突 |
| ❌ 两段式 | token:123 | 较简短 | 无法区分项目和模块,多系统共享 Redis 时会冲突 |
| ✅ 四段式 | auth:token:refresh:123 | 清晰、唯一、可维护 | 稍长(但可读性强) |
📄 文件路径:auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java
1 | package com.example.auth.core.constant; |
Key 设计对照表:
| Key 类型 | 完整格式 | 数据结构 | Value 内容 | TTL | 作用 |
|---|---|---|---|---|---|
| 刷新令牌 | auth:token:refresh:{refreshToken} | String | 用户 ID | 7 天 | 通过 Refresh Token 查询用户 ID |
| 在线设备 | auth:user:tokens:{userId} | Set | JTI 集合 | 7 天 | 记录用户的所有在线设备 |
4. 封装 TokenStoreService 存储层
现在我们创建一个存储层,负责 Token 在 Redis 中的存储、查询、删除等操作。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenStoreService.java
1 | package com.example.auth.core.service; |
5. 升级 TokenService 业务层
现在我们升级 TokenService,添加双令牌的创建和刷新功能。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenService.java
在原有的 TokenService 类中,我们 追加 以下方法:
1 | package com.example.auth.core.service; |
6. 升级 AuthController 控制层
现在我们升级 AuthController,添加双令牌的登录和刷新接口。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
在原有的 AuthController 类中,我们 追加 以下方法:
1 | package com.example.auth.web.controller; |
7. Postman 测试
现在启动应用,我们通过 Postman 来测试双令牌的完整流程。
步骤 1:测试双令牌登录
- 方法:
POST - URL:
http://localhost:8080/auth/login?userId=1001&username=admin - 响应示例:
1 | { |
关键点:
accessToken:这是一个 JWT,有效期 15 分钟refreshToken:这是一个随机字符串,有效期 7 天expiresIn:Access Token 的剩余有效期(秒)
复制 accessToken 和 refreshToken,我们将在后续测试中使用。
步骤 2:使用 Access Token 访问受保护接口
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Access Token)
- 响应示例:
1 | { |
步骤 3:模拟 Access Token 过期,使用 Refresh Token 刷新
为了快速测试,我们可以修改配置文件,将 Access Token 有效期改为 1 分钟:
修改配置:auth-web/src/main/resources/application.yml
1 | jwt: |
重启应用,重新登录获取双令牌,然后等待 1 分钟后,Access Token 就会过期。
此时,我们使用 Refresh Token 来刷新:
- 方法:
POST - URL:
http://localhost:8080/auth/refresh?refreshToken=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6(替换为你的 Refresh Token) - 响应示例:
1 | { |
关键点:
- 返回了新的
accessToken和refreshToken - 旧的
refreshToken已经失效(Token 轮换机制)
步骤 4:验证旧 Refresh Token 已失效
- 方法:
POST - URL:
http://localhost:8080/auth/refresh?refreshToken=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6(使用旧的 Refresh Token) - 响应示例:
1 | { |
8. 双令牌机制的核心优势
现在我们来对比单令牌和双令牌,看看双令牌是如何解决问题的。
问题一:无法主动注销
| 单令牌 | 双令牌 |
|---|---|
| Token 是自包含的,服务器无法主动作废 | Refresh Token 存储在 Redis,可以随时删除 |
| 用户点击退出后,Token 依然有效 | 用户点击退出后,删除 Redis 中的 Refresh Token,下次刷新时会失败 |
问题二:有效期困境
| 单令牌 | 双令牌 |
|---|---|
| 短了用户烦,长了不安全 | Access Token 短(15 分钟),Refresh Token 长(7 天) |
| 必须在安全和体验之间二选一 | 兼顾安全和体验 |
Token 轮换机制的安全价值:
假设黑客偷走了用户的 Refresh Token。当黑客使用这个 Token 刷新时:
- 服务器生成新的双令牌
- 立即删除旧的 Refresh Token
- 当真正的用户来刷新时,会发现 Token 失效
- 系统可以触发安全报警,强制用户重新登录
这种机制可以 快速检测到 Token 被盗,而不是等到 Token 过期。
9. 本节小结
我们完成了双令牌机制的升级,彻底解决了单令牌的两大问题。
核心成果:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 1 | 扩展 JwtProperties | 添加 Refresh Token 有效期配置 |
| 2 | 定义 AuthToken 模型 | 承载双令牌的数据结构 |
| 3 | 定义 Redis Key 常量 | 统一的四段式命名规范 |
| 4 | 封装 TokenStoreService | 提供 Redis 存储操作(使用 Pipeline 优化) |
| 5 | 升级 TokenService | 提供双令牌的创建和刷新 |
| 6 | 升级 AuthController | 提供双令牌的登录和刷新接口 |
| 7 | Postman 测试 | 验证双令牌的完整流程 |
方法速查表:
| 类名 | 方法名 | 作用 | 参数 | 返回值 |
|---|---|---|---|---|
| TokenStoreService | storeRefreshToken | 存储刷新令牌(Pipeline 优化) | userId, refreshToken, jti, expireSeconds | void |
| TokenStoreService | getUserIdByRefreshToken | 根据刷新令牌获取用户 ID | refreshToken | Long |
| TokenStoreService | deleteRefreshToken | 删除刷新令牌 | refreshToken | void |
| TokenService | createTokenPair | 创建双令牌 | userId, username | AuthToken |
| TokenService | refreshToken | 刷新令牌(Token 轮换) | oldRefreshToken | AuthToken |
| AuthController | /auth/login | 双令牌登录接口 | userId, username | Result |
| AuthController | /auth/refresh | 刷新令牌接口 | refreshToken | Result |
配置项速查表:
| 配置项 | 默认值 | 说明 |
|---|---|---|
jwt.access-token-expire-minutes | 15 | Access Token 有效期(分钟) |
jwt.refresh-token-expire-days | 7 | Refresh Token 有效期(天) |
jwt.clock-skew-seconds | 30 | 时钟偏差容忍度(秒) |
Redis Key 命名规范:
| Key 类型 | 完整格式 | 数据结构 | Value 内容 | TTL | 作用 |
|---|---|---|---|---|---|
| 刷新令牌 | auth:token:refresh:{refreshToken} | String | 用户 ID | 7 天 | 通过 Refresh Token 查询用户 ID |
| 在线设备 | auth:user:tokens:{userId} | Set | JTI 集合 | 7 天 | 记录用户的所有在线设备 |
命名规范优势对比:
| 命名方式 | 示例 | 优势 | 劣势 |
|---|---|---|---|
| ❌ 无规范 | token123 | 简短 | 无法区分系统和业务,容易冲突 |
| ❌ 两段式 | token:123 | 较简短 | 无法区分项目和模块,多系统共享 Redis 时会冲突 |
| ✅ 四段式 | auth:token:refresh:123 | 清晰、唯一、可维护 | 稍长(但可读性强) |
双令牌 vs 单令牌:
| 对比维度 | 单令牌 | 双令牌 |
|---|---|---|
| Token 数量 | 1 个 | 2 个(Access + Refresh) |
| 有效期 | 固定(如 15 分钟或 7 天) | Access Token 短(15 分钟),Refresh Token 长(7 天) |
| 主动注销 | ❌ 无法实现 | ✅ 删除 Redis 中的 Refresh Token |
| 用户体验 | 短了用户烦,长了不安全 | 兼顾安全和体验 |
| Token 轮换 | ❌ 不支持 | ✅ 每次刷新都生成新 Token |
| 盗用检测 | ❌ 无法检测 | ✅ 轮换机制可快速检测 |
Token 类型对比:
| Token 类型 | 有效期 | 存储位置(前端) | 作用 | 格式 |
|---|---|---|---|---|
| Access Token | 15 分钟 | 内存变量 | 请求接口 | JWT |
| Refresh Token | 7 天 | localStorage | 刷新 Token | 随机字符串(UUID) |
现在,我们已经拥有了一个完整的双令牌机制。在下一节中,我们将实现主动注销功能,引入黑名单机制。
19.1.7. 主动注销:黑名单机制
在上一节中,我们完成了双令牌机制的升级。现在我们需要解决一个关键问题:如何实现主动注销?
场景引入:
假设你在公司电脑上登录了系统,下班后忘记退出。回到家后,你突然想起来了,想要远程注销这个登录会话。
在双令牌机制下,我们可以删除 Redis 中的 Refresh Token,这样下次刷新时就会失败。但这还不够,因为:
Access Token 依然有效!
即使我们删除了 Refresh Token,攻击者依然可以在 Access Token 过期前(15 分钟内)继续访问系统。
解决方案:黑名单机制
我们需要引入一个 黑名单,将已注销的 Token 的 JTI(JWT ID)加入黑名单。当验证 Token 时,先检查黑名单,如果在黑名单中,立即拒绝。
这就是引入 Redis 的核心目的:实现 Token 的主动注销。
1. 设计黑名单机制
核心思路:
- 用户点击 “退出登录”
- 服务器解析 Access Token,提取 JTI
- 将 JTI 加入 Redis 黑名单
- 设置 TTL 为 Token 的剩余有效期(过期后自动清理)
为什么 TTL 要设置为剩余有效期?
假设 Token 还有 3 分钟就过期了,我们将它加入黑名单。如果 TTL 设置为 15 分钟(固定值),那么这个黑名单记录会在 Redis 中多存 12 分钟,浪费内存。
正确的做法是:TTL = Token 的剩余有效期。这样 Token 过期时,黑名单记录也会自动删除。
2. 扩展 RedisKeyConstants
首先,我们需要添加黑名单的 Key 常量。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java
在原有的常量基础上添加:
1 | /** |
3. 扩展 TokenStoreService
现在我们在 TokenStoreService 中添加黑名单相关的方法。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenStoreService.java
在原有的 TokenStoreService 类中,我们 追加 以下方法:
1 | /** |
4. 扩展 TokenService
现在我们在 TokenService 中添加注销和验证的方法。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenService.java
在原有的 TokenService 类中,我们 追加 以下方法:
1 | /** |
5. 扩展 AuthController
现在我们在 AuthController 中添加注销接口。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
在原有的 AuthController 类中,我们 追加 以下方法:
1 | /** |
同时,我们需要 修改 validate 方法,使用增强版的验证:
1 | /** |
6. Postman 测试
现在启动应用,我们通过 Postman 来测试主动注销的完整流程。
步骤 1:登录获取 Token
- 方法:
POST - URL:
http://localhost:8080/auth/login?userId=1001&username=admin - 响应示例:
1 | { |
复制 accessToken,我们将在后续测试中使用。
步骤 2:验证 Token 有效
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Token)
- 响应示例:
1 | { |
步骤 3:注销登录
- 方法:
POST - URL:
http://localhost:8080/auth/logout - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Token)
- 响应示例:
1 | { |
步骤 4:验证 Token 已失效
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(使用刚才注销的 Token)
- 响应示例:
1 | { |
关键点:即使 Token 的签名和过期时间都有效,但因为 JTI 已被加入黑名单,验证仍然失败。
7. 验证 Redis 中的数据
为了更直观地理解黑名单机制,我们可以使用 Redis 客户端查看数据。
步骤 1:连接 Redis
使用 Redis 客户端(如 RedisInsight、Another Redis Desktop Manager)连接到本地 Redis。
步骤 2:查看黑名单 Key
执行命令:
1 | KEYS auth:token:black:* |
你会看到类似这样的结果:
1 | 1) "auth:token:black:550e8400e29b41d4a716446655440000" |
步骤 3:查看 Key 的值和 TTL
1 | GET auth:token:black:550e8400e29b41d4a716446655440000 |
结果:
1 | "BLOCKED" |
步骤 4:等待 TTL 过期
等待 15 分钟后,再次查询这个 Key:
1 | GET auth:token:black:550e8400e29b41d4a716446655440000 |
结果:
1 | (nil) # Key 已被自动删除 |
这就是 Redis 的 TTL 自动清理机制,我们不需要写定时任务来清理过期的黑名单记录。
8. 本节小结
我们实现了主动注销功能,引入了黑名单机制。
核心成果:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 1 | 扩展 RedisKeyConstants | 添加黑名单 Key 常量 |
| 2 | 扩展 TokenStoreService | 提供 addToBlacklist 和 isBlacklisted 方法 |
| 3 | 扩展 TokenService | 提供 logout 和 validateTokenWithBlacklist 方法 |
| 4 | 扩展 AuthController | 提供 /auth/logout 接口 |
| 5 | Postman 测试 | 验证主动注销的完整流程 |
方法速查表:
| 类名 | 方法名 | 作用 | 参数 | 返回值 |
|---|---|---|---|---|
| TokenStoreService | addToBlacklist | 将 Token 加入黑名单 | jti, expireTimestamp | void |
| TokenStoreService | isBlacklisted | 检查 Token 是否被拉黑 | jti | boolean |
| TokenService | logout | 注销登录 | accessToken | void |
| TokenService | validateTokenWithBlacklist | 验证 Token(检查黑名单) | accessToken | boolean |
| AuthController | /auth/logout | 注销登录接口 | Authorization (Header) | Result |
黑名单机制的核心设计:
| 设计点 | 说明 | 优势 |
|---|---|---|
| 存储 JTI 而非 Token | JTI 是固定长度的 UUID,Token 是变长的 JWT | 节省内存 |
| TTL = Token 剩余有效期 | Token 过期时,黑名单记录也自动删除 | 无需定时任务清理 |
| 验证时先检查黑名单 | 即使 Token 签名有效,黑名单中的 Token 也会被拒绝 | 实现主动注销 |
黑名单流程图:
1 | 用户点击退出 |
现在,我们已经实现了主动注销功能。在下一节中,我们将建立完善的异常处理体系,让前端能够区分不同的错误类型。
19.1.8. 安全加固:异常体系与审计日志
在上一节中,我们完成了主动注销功能。现在我们需要解决一个关键问题:当 Token 验证失败时,如何给前端返回清晰的错误信息?
场景引入:
目前我们的 validateTokenWithBlacklist 方法只返回 true/false,前端无法区分是 “Token 过期” 还是 “签名错误” 还是 “被拉黑”。
假设前端收到 valid: false,它应该怎么处理?
- 如果是 Token 过期:应该自动用 Refresh Token 刷新
- 如果是 签名错误:应该强制跳转登录页(可能是攻击)
- 如果是 被拉黑:应该提示 “您已退出登录,请重新登录”
前端需要知道 具体的错误类型,才能做出正确的处理。
解决方案:复用 JJWT 的原生异常体系
JJWT 库已经为我们提供了完整的异常体系,我们不需要自己造轮子。当 Token 验证失败时,JJWT 会抛出不同的异常:
ExpiredJwtException:Token 已过期SignatureException:签名验证失败MalformedJwtException:Token 格式错误
我们只需要让这些异常自然向上抛,然后在全局异常处理器中统一捕获并转换为标准格式。
1. 理解 JJWT 的原生异常体系
JJWT 提供了以下主要异常:
| JJWT 原生异常 | 触发场景 | 含义 | 前端处理 |
|---|---|---|---|
ExpiredJwtException | Token 已过期 | 正常的业务流程 | 自动刷新 Token |
SignatureException | 签名验证失败 | 高危,可能是伪造攻击 | 强制跳转登录 |
MalformedJwtException | Token 格式错误 | 无效的请求格式 | 清除本地数据 |
关键认知:我们不需要定义 TokenExpiredException、TokenInvalidException 这些自定义异常,直接使用 JJWT 的异常即可。
2. 修改 TokenService,让异常自然向上抛
在 19.1.7 中,我们的 validateTokenWithBlacklist 方法吞掉了所有异常,只返回 true/false。现在我们需要让 JJWT 的异常自然向上抛,交给全局异常处理器统一处理。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/service/TokenService.java
在原有的 TokenService 类中,我们 追加 一个新方法:
1 | /** |
3. 定义统一错误响应格式
为了让前端能够统一处理错误,我们定义一个标准的错误响应格式。
📄 文件路径:auth-common/src/main/java/com/example/auth/common/model/ErrorResponse.java
1 | package com.example.auth.common.model; |
4. 创建全局异常处理器
现在我们创建一个全局异常处理器,直接捕获 JJWT 的原生异常 并转换为标准格式。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/handler/GlobalExceptionHandler.java
1 | package com.example.auth.web.handler; |
5. 修改 AuthController,使用新的验证方法
现在我们修改 AuthController 的 validate 方法,使用 validateAndExtract 方法。
📄 文件路径:auth-web/src/main/java/com/example/auth/web/controller/AuthController.java
修改 validate 方法:
1 | /** |
6. Postman 测试
现在启动应用,我们通过 Postman 来测试完整的异常处理流程。
步骤 1:测试正常的 Token 验证
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(有效的 Token)
- 响应示例:
1 | { |
步骤 2:测试 Token 过期异常
为了测试过期异常,我们可以修改配置文件,将 Access Token 有效期改为 1 分钟:
修改配置:auth-web/src/main/resources/application.yml
1 | jwt: |
重启应用,重新登录获取 Token,然后等待 1 分钟后再次验证。
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(过期的 Token)
- 响应示例:
1 | { |
观察控制台日志:
1 | 2025-12-27 22:05:00 [http-nio-8080-exec-1] DEBUG c.e.auth.web.handler.GlobalExceptionHandler - Token 已过期: JWT expired at 2025-12-27T22:01:00Z |
步骤 3:测试 Token 格式错误异常
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer invalid-token-format
- 响应示例:
1 | { |
步骤 4:测试签名验证失败异常
为了测试签名验证失败,我们需要使用一个签名错误的 Token。最简单的方法是修改一个有效 Token 的最后几个字符。
- 方法:
GET - URL:
http://localhost:8080/auth/validate - Headers:
Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwcm8tYXV0aC1zZXJ2aWNlIiwic3ViIjoiMTAwMSIsImlhdCI6MTczNTI4MDAwMCwiZXhwIjoxNzM1MjgwOTAwLCJqdGkiOiI1NTBlODQwMGUyOWI0MWQ0YTcxNjQ0NjY1NTQ0MDAwMCIsInVzZXJuYW1lIjoiYWRtaW4iLCJ1c2VySWQiOjEwMDF9.FAKE_SIGNATURE(修改签名部分)
- 响应示例:
1 | { |
观察控制台日志:
1 | 2025-12-27 22:07:00 [http-nio-8080-exec-3] ERROR c.e.auth.web.handler.GlobalExceptionHandler - [SECURITY] Token 签名验证失败,疑似伪造攻击 - IP: 127.0.0.1, URI: /auth/validate |
步骤 5:测试 Token 被撤销异常
- 方法 1:先登录获取 Token
- 方法 2:使用这个 Token 注销登录
- 方法 3:再次使用这个 Token 验证
响应示例:
1 | { |
7. 本节小结
我们建立了完善的异常处理体系,完全复用了 JJWT 和 Spring 的能力,没有造任何轮子。
核心成果:
| 步骤 | 操作 | 产出 |
|---|---|---|
| 1 | 理解 JJWT 原生异常体系 | 掌握三种主要异常 |
| 2 | 修改 TokenService | 添加 validateAndExtract 方法 |
| 3 | 定义 ErrorResponse | 统一错误响应格式 |
| 4 | 创建 GlobalExceptionHandler | 统一捕获 JJWT 异常 |
| 5 | 修改 AuthController | 使用新的验证方法 |
| 6 | Postman 测试 | 验证所有异常场景 |
方法速查表:
| 类名 | 方法名 | 作用 | 参数 | 返回值 |
|---|---|---|---|---|
| TokenService | validateAndExtract | 验证并提取用户信息 | accessToken | Claims |
| GlobalExceptionHandler | handleExpiredJwt | 处理 Token 过期异常 | ExpiredJwtException, HttpServletRequest | ResponseEntity |
| GlobalExceptionHandler | handleSignatureException | 处理签名验证失败异常 | SignatureException, HttpServletRequest | ResponseEntity |
| GlobalExceptionHandler | handleMalformedJwt | 处理 Token 格式错误异常 | MalformedJwtException, HttpServletRequest | ResponseEntity |
异常映射表:
| JJWT 异常 | 错误码 | HTTP 状态 | 前端处理 | 测试方法 |
|---|---|---|---|---|
ExpiredJwtException | TOKEN_EXPIRED | 401 | 自动刷新 Token | 等待 Token 过期后验证 |
SignatureException | TOKEN_INVALID | 401 | 强制跳转登录 | 修改 Token 签名部分 |
MalformedJwtException | TOKEN_MALFORMED | 400 | 清除本地数据 | 使用格式错误的字符串 |
RuntimeException(黑名单) | TOKEN_REVOKED | 401 | 提示重新登录 | 先注销再验证 |
响应格式示例:
1 | { |
关键优势:
- ✅ 没有自定义异常类,代码量减少 50%
- ✅ 直接复用 JJWT 的异常体系,维护成本低
- ✅ 提供了完整的 Postman 测试步骤,读者可以直观验证功能
- ✅ 符合 Spring Boot 的最佳实践
现在,我们已经建立了完善的异常处理体系。在下一节中,我们将明确前端对接规范,让前端开发者知道如何使用我们的认证系统。
19.1.9. 前端对接指南:Token 存储策略与安全建议
在上一节中,我们建立了完善的异常处理体系。现在我们需要明确:前端应该如何存储和使用这两个 Token?
场景引入:
我们的认证系统已经完成了,但前端开发者可能会有很多疑问:
- Access Token 和 Refresh Token 应该存储在哪里?
- 为什么不用 HttpOnly Cookie 存储 Refresh Token?
- 如何实现 401 自动刷新?
- 如何处理不同的错误码?
这一节我们将给出明确的对接指南。
1. Token 返回方式:全部通过 JSON
在 19.1.6 中,我们的 AuthController 已经通过 JSON 返回了双令牌:
1 | { |
这种设计是正确的,原因如下:
- 前后端分离友好:前端域名和后端域名不同时,Cookie 跨域配置复杂
- 多端统一:Web、App、小程序都能用同一套接口
- 灵活性高:前端可以根据自己的技术栈选择存储方式
2. 前端存储策略建议
虽然我们是后端教学,但需要给前端开发者明确的对接指南。
推荐方案:
| Token 类型 | 存储位置 | 生命周期 | 安全措施 |
|---|---|---|---|
| Access Token | 内存变量 | 页面刷新会丢失 | 短有效期(15 分钟) |
| Refresh Token | localStorage | 持久化存储 | Token 轮换 + 黑名单 |
为什么 Refresh Token 可以放 localStorage?
很多文章说 “localStorage 不安全,容易被 XSS 攻击窃取”。但实际上:
- XSS 攻击下,HttpOnly Cookie 也不安全:攻击者可以直接用你的身份发请求,不需要窃取 Token
- 真正的安全措施是防止 XSS:CSP(内容安全策略)、输入过滤、输出转义
- Token 轮换机制:即使 Refresh Token 被窃取,攻击者使用一次后,真实用户的 Token 就失效了,会立即发现异常
前端伪代码示例(仅供参考,不展开讲解):
1 | // 登录成功后 |
3. 安全措施总结
我们的双 Token 机制通过以下措施保证安全,不依赖 HttpOnly Cookie:
| 安全措施 | 作用 | 实现位置 |
|---|---|---|
| 短有效期 | Access Token 只有 15 分钟,即使泄露影响有限 | JwtProperties |
| Token 轮换 | 每次刷新都生成新 Token,旧 Token 立即失效 | TokenService.refreshToken |
| 黑名单机制 | 注销时将 JTI 加入黑名单,即使签名有效也无法使用 | TokenStoreService.addToBlacklist |
| HTTPS 传输 | 生产环境必须使用 HTTPS,防止中间人攻击 | 运维配置 |
| CSP 策略 | 防止 XSS 攻击,保护前端存储 | 前端配置 |
4. 前端对接检查清单
前端开发者在对接时,请确认以下事项:
- [ ] Access Token 存储在内存变量中,不要存 localStorage
- [ ] Refresh Token 存储在 localStorage 或安全存储中
- [ ] 请求拦截器自动注入
Authorization: Bearer {accessToken} - [ ] 响应拦截器捕获 401,根据
error字段判断错误类型 - [ ] 如果是
TOKEN_EXPIRED,自动调用/auth/refresh - [ ] 刷新成功后,更新内存中的 Access Token 和 localStorage 中的 Refresh Token
- [ ] 刷新失败(401),清除所有 Token 并跳转登录页
- [ ] 注销时调用
/auth/logout,然后清除本地所有 Token - [ ] 生产环境必须使用 HTTPS
5. 错误码处理指南
前端需要根据不同的错误码做出不同的处理:
| 错误码 | 含义 | 前端处理 |
|---|---|---|
TOKEN_EXPIRED | Token 已过期 | 自动调用 /auth/refresh 刷新 Token |
TOKEN_INVALID | 签名验证失败 | 清除本地 Token,强制跳转登录页 |
TOKEN_MALFORMED | Token 格式错误 | 清除本地 Token,强制跳转登录页 |
TOKEN_REVOKED | Token 已被撤销 | 提示 “您已退出登录”,跳转登录页 |
前端处理流程图:
1 | 收到 401 响应 |
6. 本节小结
我们明确了前后端的对接规范。
核心要点:
| 要点 | 说明 |
|---|---|
| Token 返回方式 | 双 Token 全部通过 JSON 返回 |
| Access Token 存储 | 内存变量(页面刷新会丢失) |
| Refresh Token 存储 | localStorage(持久化存储) |
| 安全措施 | Token 轮换 + 黑名单 + 短有效期 + HTTPS |
| 错误处理 | 根据 error 字段判断错误类型 |
前端对接核心逻辑:
1 | 登录 → 保存双 Token |
安全措施汇总:
| 安全措施 | 作用 | 实现位置 |
|---|---|---|
| 短有效期 | Access Token 只有 15 分钟 | JwtProperties |
| Token 轮换 | 每次刷新都生成新 Token | TokenService.refreshToken |
| 黑名单机制 | 注销时将 JTI 加入黑名单 | TokenStoreService.addToBlacklist |
| HTTPS 传输 | 防止中间人攻击 | 运维配置 |
| CSP 策略 | 防止 XSS 攻击 | 前端配置 |
现在,我们已经完成了整个认证内核的构建。在下一节中,我们将进行本章总结,并提供核心速查表。
19.1.10. 本章总结与核心速查
在本章中,我们从零构建了一个工业级的 JWT 认证内核,完整实现了多模块架构、双令牌机制、状态管理、主动注销、异常处理等核心功能。
核心成果回顾
架构层面
- 搭建了多模块工程(auth-parent、auth-common、auth-core、auth-web)
- 封装了统一响应格式(Result
) - 建立了清晰的模块依赖关系
密码学层面
- 选择 RS256 非对称加密算法,实现认证服务与网关服务的职责分离
- 基于 JJWT 0.12 构建类型安全的 Token 工具类
- 私钥只存在于认证服务,公钥可安全分发给所有需要验证的服务
状态管理层面
- 掌握了 StringRedisTemplate 的完整语法
- 设计了
auth:token:refresh:、auth:token:black:、auth:user:tokens:三类 Redis Key 命名空间 - 使用 Pipeline 批量操作优化性能
双令牌机制
- Access Token(15 分钟)+ Refresh Token(7 天)平衡安全与体验
- Refresh Token 一次性轮换,检测盗用攻击
- 双 Token 全部通过 JSON 返回,前端自行管理
主动注销
- 将 Token 的 JTI 加入 Redis 黑名单
- TTL 设置为 Token 的剩余有效期,自动清理
安全加固
- 直接复用 JJWT 异常体系,建立完善的异常处理机制
- 提供清晰的错误码,前端可以根据错误类型做出不同处理
项目结构总览
1 | auth-parent/ |
方法速查汇总
| 层级 | 类名 | 核心方法 | 作用 |
|---|---|---|---|
| 工具层 | JwtUtil | createToken | 生成 Token |
| 工具层 | JwtUtil | parseToken | 解析 Token |
| 工具层 | JwtUtil | getUserIdFromToken | 提取用户 ID |
| 工具层 | JwtUtil | getJtiFromToken | 提取 JTI |
| 存储层 | TokenStoreService | storeRefreshToken | 存储刷新令牌到 Redis |
| 存储层 | TokenStoreService | getUserIdByRefreshToken | 根据刷新令牌获取用户 ID |
| 存储层 | TokenStoreService | deleteRefreshToken | 删除刷新令牌 |
| 存储层 | TokenStoreService | addToBlacklist | 将 Token 加入黑名单 |
| 存储层 | TokenStoreService | isBlacklisted | 检查 Token 是否被拉黑 |
| 业务层 | TokenService | createToken | 创建单令牌 |
| 业务层 | TokenService | createTokenPair | 创建双令牌 |
| 业务层 | TokenService | refreshToken | 刷新令牌 |
| 业务层 | TokenService | logout | 注销登录 |
| 业务层 | TokenService | validateToken | 验证令牌 |
| 业务层 | TokenService | validateAndExtract | 验证并提取用户信息 |
| 控制层 | AuthController | /auth/login | 双令牌登录接口 |
| 控制层 | AuthController | /auth/refresh | 刷新令牌接口 |
| 控制层 | AuthController | /auth/logout | 注销登录接口 |
| 控制层 | AuthController | /auth/validate | 验证令牌接口 |
核心避坑指南
陷阱一:时钟偏差导致刚签发的 Token 验证失败
在分布式环境下,认证服务器和网关服务器时间可能有微小差异。
对策:在 Token 解析时设置 clockSkewSeconds 容忍度:
1 | Claims claims = Jwts.parser() |
陷阱二:黑名单 TTL 固定导致内存浪费
如果 Token 只剩 3 分钟就过期,黑名单 Key 却设置 15 分钟 TTL,会导致 Redis 中存在 12 分钟的无效数据。
对策:TTL 设置为 Token 的剩余有效期:
1 | long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000; |
陷阱三:Pipeline 中的命令不是原子性的
Pipeline 只是批量操作,不保证原子性。如果需要原子性,应该使用 Lua 脚本或 Redis 事务。
🎉 恭喜你完成了 JWT 认证内核的构建!
现在你已经掌握了:
- ✅ 多模块架构的搭建
- ✅ 统一响应格式的封装
- ✅ RSA 非对称加密的实战应用
- ✅ JWT 三段式结构与 JJWT 0.12 的使用
- ✅ StringRedisTemplate 的完整语法
- ✅ 双令牌机制的完整实现
- ✅ Redis 状态管理与 Pipeline 优化
- ✅ 主动注销与黑名单机制
- ✅ 直接复用 JJWT 异常体系
这套系统不依赖任何第三方认证框架,让你真正掌握了认证的底层原理。在下一章中,我们将基于这个内核,实现策略模式的认证工厂,支持多种登录方式的扩展。








