Note 19(第一章). SpringBoot3-登录校验内核构建:JWT 2.0 体系与双Token验证详细落地指南

Note 19.1. 内核构建:JWT 2.0 体系与双令牌状态机

环境版本锁定

在开始编写内核代码之前,必须统一加密算法库与中间件版本,以确保密码学操作的一致性。

技术组件版本号说明
JDK17 LTS必须支持 Sealed Classes 等新特性
JJWT (Java JWT)0.12.52025 年最新标准,废弃了旧版 Parser 写法
Spring Boot3.2.x配合 Spring Data Redis 3.2.0
Spring Data Redis3.2.0配合 Lettuce 客户端
Hutool5.8.25用于辅助的 UUID 生成
Lombok1.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
2
3
4
5
6
7
8
9
10
11
12
13
src/main/java/com/example/auth/
├── controller/
│ └── AuthController.java
├── service/
│ ├── TokenService.java
│ └── UserService.java
├── util/
│ ├── JwtUtil.java
│ └── RedisUtil.java
├── model/
│ ├── User.java
│ └── AuthToken.java
└── AuthApplication.java

这种结构在项目初期看起来很简洁,但随着业务增长,会遇到三个致命问题:

问题一:职责不清

JwtUtil 是通用的 JWT 工具类,理论上可以被其他项目复用。但现在它和业务代码混在一起,如果其他项目想用,只能复制粘贴代码。

问题二:依赖混乱

AuthController 依赖了 TokenServiceTokenService 依赖了 JwtUtilJwtUtil 依赖了 RedisUtil。这些依赖关系全部隐藏在代码里,新人接手项目时很难理解架构。

问题三:测试困难

如果我想单独测试 JwtUtil 的功能,必须启动整个 Spring Boot 应用,因为它和 Web 层耦合在一起。

解决方案:多模块工程

我们将项目拆分成多个模块,每个模块有明确的职责:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
auth/                                 # 根目录(聚合器)
├── pom.xml # 根聚合器,只负责模块声明

├── auth-parent/ # 父工程(依赖管理)
│ └── pom.xml # 统一管理依赖版本和构建配置

├── auth-common/ # 公共基础模块
│ ├── pom.xml
│ └── src/main/java/com/example/auth/common/
│ ├── model/
│ │ └── Result.java # 统一响应封装
│ ├── constant/
│ │ └── CommonConstants.java # 通用常量
│ └── util/
│ └── SnowflakeIdGenerator.java # 雪花算法

├── auth-core/ # 认证核心模块
│ ├── pom.xml
│ └── src/main/java/com/example/auth/core/
│ ├── util/
│ │ ├── JwtUtil.java # JWT 工具类
│ │ └── crypto/
│ │ └── RsaKeyManager.java
│ ├── service/
│ │ ├── TokenService.java
│ │ └── TokenStoreService.java
│ └── model/
│ └── AuthToken.java

└── auth-web/ # Web 应用模块
├── pom.xml
└── src/main/java/com/example/auth/web/
├── AuthApplication.java # 启动类
└── controller/
└── AuthController.java

架构设计说明

  • 根目录 pom.xml:聚合器,只负责声明模块,不管理依赖
  • auth-parent/pom.xml:父 POM(Parent POM),统一管理依赖版本和构建配置
  • auth-common:存放通用工具,可以被任何项目复用
  • auth-core:存放 JWT 核心逻辑,不依赖 Web 层,可以单独测试
  • auth-web:只负责 HTTP 接口,依赖 auth-core

依赖关系非常清晰:auth-webauth-coreauth-common

为什么需要分离聚合器和父 POM?

  • 职责分离:聚合器只负责模块声明,父 POM 只负责依赖管理,各司其职
  • 易于维护:版本统一在 auth-parent 管理,根目录保持简洁
  • 可扩展性:未来可以复用 auth-parent 给其他项目使用

1. 创建根聚合器和父工程

步骤 1:创建根目录聚合器

在 IDEA 中,选择 FileNewProject,选择 Maven,填写以下信息:

  • Nameauth
  • GroupIdcom.example
  • ArtifactIdauth
  • Version1.0.0

点击 Create,IDEA 会生成根目录。

步骤 2:创建根目录的 pom.xml(聚合器)

📄 文件路径pom.xml(根目录)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>auth</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>

<name>auth</name>
<description>认证系统 - 根聚合器</description>

<!-- 声明子模块(聚合器,只负责模块声明,不包含 auth-parent) -->
<modules>
<module>auth-common</module>
<module>auth-core</module>
<module>auth-web</module>
</modules>
</project>

关键点解释

  • <packaging>pom</packaging>:表示这是一个聚合器,不包含任何代码
  • <modules>:声明了三个业务模块(不包含 auth-parent,因为它只是父 POM,不是业务模块)

步骤 3:创建父工程 auth-parent

在根目录下创建 auth-parent 目录,并在其中创建 pom.xml

📄 文件路径auth-parent/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>auth-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>

<name>auth-parent</name>
<description>认证系统父工程 - 统一管理依赖版本和构建配置</description>

<!-- 统一管理依赖版本 -->
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<spring-boot.version>3.2.0</spring-boot.version>
<jjwt.version>0.12.5</jjwt.version>
<hutool.version>5.8.25</hutool.version>
<lombok.version>1.18.30</lombok.version>
</properties>

<!-- 依赖管理(子模块继承这些版本) -->
<dependencyManagement>
<dependencies>
<!-- Spring Boot 依赖管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!-- JJWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>

<!-- Hutool 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</build>
</project>

关键点解释

  • <packaging>pom</packaging>:表示这是一个父 POM,不包含任何代码
  • 不包含 <modules>auth-parent 是父 POM,不是聚合器,模块声明在根目录的 pom.xml
  • <dependencyManagement>:统一管理依赖版本,子模块继承这些版本,不需要重复写版本号
  • <build>:统一管理构建配置(如编译器版本)

为什么需要 <relativePath>

由于项目结构是:

1
2
3
4
5
6
auth/                    # 根目录(聚合器)
├── pom.xml
├── auth-parent/ # 父POM
│ └── pom.xml
└── auth-common/ # 子模块
└── pom.xml

Maven 默认会在父目录(根目录)查找 parent POM,但根目录的 pom.xmlartifactIdauth,而不是 auth-parent。因此需要在子模块中明确指定 <relativePath>../auth-parent/pom.xml</relativePath>,告诉 Maven 从 auth-parent 目录查找父 POM。


2. 创建子模块 auth-common

步骤 1:创建子模块

在 IDEA 中,右键点击根目录 auth,选择 NewModule,选择 Maven,填写以下信息:

  • Nameauth-common
  • Parentcom.example:auth-parent:1.0.0

点击 Create,IDEA 会在根目录下创建一个子模块。

步骤 2:修改 auth-common 的 pom.xml

📄 文件路径auth-common/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<!-- 继承父工程 -->
<parent>
<groupId>com.example</groupId>
<artifactId>auth-parent</artifactId>
<version>1.0.0</version>
<relativePath>../auth-parent/pom.xml</relativePath>
</parent>

<artifactId>auth-common</artifactId>
<name>auth-common</name>
<description>公共基础模块</description>

<dependencies>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!-- Hutool 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

步骤 3:创建统一响应类 Result

现在我们来解决一个实际问题:Controller 返回的数据格式不统一

假设我们有两个接口:

1
2
3
4
5
6
7
8
9
10
11
// 接口 1:登录
@PostMapping("/login")
public AuthToken login() {
return new AuthToken("token123", "refresh456");
}

// 接口 2:查询用户
@GetMapping("/user")
public User getUser() {
return new User(1001L, "admin");
}

前端收到的响应格式完全不同:

1
2
3
4
5
6
7
8
9
10
11
// 接口 1 的响应
{
"accessToken": "token123",
"refreshToken": "refresh456"
}

// 接口 2 的响应
{
"id": 1001,
"username": "admin"
}

前端开发者会抱怨:“为什么每个接口的格式都不一样?我怎么知道请求成功还是失败?”

解决方案:统一响应格式

我们定义一个 Result<T> 类,所有接口都返回这个格式:

1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
// 实际数据
}
}

📄 文件路径auth-common/src/main/java/com/example/auth/common/model/Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package com.example.auth.common.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
* 统一响应封装
*
* 为什么需要统一响应?
* 1. 前端可以统一处理响应格式
* 2. 可以通过 code 判断请求是否成功
* 3. 可以通过 message 展示错误信息
*
* @param <T> 数据类型(泛型)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> implements Serializable {

/**
* 响应码
* 200 表示成功,其他表示失败
*/
private Integer code;

/**
* 响应消息
* 成功时可以是 "操作成功"
* 失败时可以是具体的错误信息
*/
private String message;

/**
* 响应数据
* 使用泛型,可以是任何类型
*/
private T data;

/**
* 成功响应(带数据)
*
* 使用示例:
* return Result.ok(authToken);
*
* @param data 响应数据
* @return Result 对象
*/
public static <T> Result<T> ok(T data) {
return new Result<>(200, "操作成功", data);
}

/**
* 成功响应(不带数据)
*
* 使用示例:
* return Result.ok();
*
* @return Result 对象
*/
public static <T> Result<T> ok() {
return new Result<>(200, "操作成功", null);
}

/**
* 失败响应
*
* 使用示例:
* return Result.fail(401, "用户名或密码错误");
*
* @param code 错误码
* @param message 错误信息
* @return Result 对象
*/
public static <T> Result<T> fail(Integer code, String message) {
return new Result<>(code, message, null);
}

/**
* 失败响应(默认 500 错误码)
*
* 使用示例:
* return Result.fail("服务器内部错误");
*
* @param message 错误信息
* @return Result 对象
*/
public static <T> Result<T> fail(String message) {
return new Result<>(500, message, null);
}
}

现在,我们的接口可以统一返回格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 接口 1:登录
@PostMapping("/login")
public Result<AuthToken> login() {
AuthToken token = new AuthToken("token123", "refresh456");
return Result.ok(token);
}

// 接口 2:查询用户
@GetMapping("/user")
public Result<User> getUser() {
User user = new User(1001L, "admin");
return Result.ok(user);
}

前端收到的响应格式完全一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 接口 1 的响应
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "token123",
"refreshToken": "refresh456"
}
}

// 接口 2 的响应
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1001,
"username": "admin"
}
}

步骤 4:创建雪花算法工具类

在后续章节中,我们需要生成全局唯一的用户 ID。我们使用 Hutool 提供的雪花算法。

📄 文件路径auth-common/src/main/java/com/example/auth/common/util/SnowflakeIdGenerator.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.auth.common.util;

import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.util.IdUtil;

/**
* 雪花算法 ID 生成器
*
* 为什么需要雪花算法?
* 1. 数据库自增 ID 在分布式环境下会冲突
* 2. UUID 太长(36 位),且无序,不利于数据库索引
* 3. 雪花算法生成的 ID 是 Long 类型(19 位),且趋势递增
*/
public class SnowflakeIdGenerator {

/**
* Hutool 提供的雪花算法实例
* 参数说明:
* - workerId: 工作机器 ID(0-31)
* - datacenterId: 数据中心 ID(0-31)
*/
private static final Snowflake SNOWFLAKE = IdUtil.getSnowflake(1, 1);

/**
* 生成下一个 ID
*
* @return 19 位的 Long 类型 ID
*/
public static Long nextId() {
return SNOWFLAKE.nextId();
}
}

3. 创建子模块 auth-core

步骤 1:创建子模块

在 IDEA 中,右键点击根目录 auth,选择 NewModule,选择 Maven,填写以下信息:

  • Nameauth-core
  • Parentcom.example:auth-parent:1.0.0

步骤 2:修改 auth-core 的 pom.xml

📄 文件路径auth-core/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<!-- 继承父工程 -->
<parent>
<groupId>com.example</groupId>
<artifactId>auth-parent</artifactId>
<version>1.0.0</version>
<relativePath>../auth-parent/pom.xml</relativePath>
</parent>

<artifactId>auth-core</artifactId>
<name>auth-core</name>
<description>认证核心模块</description>

<dependencies>
<!-- 依赖 auth-common -->
<dependency>
<groupId>com.example</groupId>
<artifactId>auth-common</artifactId>
<version>1.0.0</version>
</dependency>

<!-- Spring Boot Starter(不包含 Web) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Redis 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<!-- JJWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
</dependencies>
</project>

4. 创建子模块 auth-web

步骤 1:创建子模块

在 IDEA 中,右键点击根目录 auth,选择 NewModule,选择 Maven,填写以下信息:

  • Nameauth-web
  • Parentcom.example:auth-parent:1.0.0

步骤 2:修改 auth-web 的 pom.xml

📄 文件路径auth-web/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<!-- 继承父工程 -->
<parent>
<groupId>com.example</groupId>
<artifactId>auth-parent</artifactId>
<version>1.0.0</version>
<relativePath>../auth-parent/pom.xml</relativePath>
</parent>

<artifactId>auth-web</artifactId>
<name>auth-web</name>
<description>Web 应用模块</description>

<dependencies>
<!-- 依赖 auth-core -->
<dependency>
<groupId>com.example</groupId>
<artifactId>auth-core</artifactId>
<version>1.0.0</version>
</dependency>

<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<!-- Spring Boot 打包插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.2.0</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

步骤 3:创建启动类

📄 文件路径auth-web/src/main/java/com/example/auth/web/AuthApplication.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.auth.web;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* 认证系统启动类
*/
@SpringBootApplication(scanBasePackages = "com.example.auth")
public class AuthApplication {

public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}

步骤 4:创建配置文件

📄 文件路径auth-web/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
server:
port: 8080

spring:
application:
name: auth-service

data:
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0

5. 验证多模块工程

现在,我们的多模块工程已经搭建完成。让我们验证一下是否能够正常启动。

步骤 1:刷新 Maven

在 IDEA 右侧的 Maven 面板中,点击刷新按钮,确保所有依赖都下载完成。

步骤 2:启动应用

运行 AuthApplicationmain 方法,如果看到以下日志,说明启动成功:

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-webWeb 应用模块(Controller + 启动类)继承 auth-parent,依赖 auth-core

架构优势

  1. 职责分离:聚合器和父 POM 各司其职,根目录保持简洁
  2. 易于维护:版本统一在 auth-parent 管理,修改一处即可
  3. 可扩展性:未来可以复用 auth-parent 给其他项目使用
  4. 结构清晰:依赖关系明确,新人容易理解

方法速查表

类名方法名作用
Resultok(T data)成功响应(带数据)
Resultok()成功响应(不带数据)
Resultfail(Integer code, String message)失败响应
SnowflakeIdGeneratornextId()生成全局唯一 ID

现在,我们已经有了一个结构清晰的多模块工程,可以开始编写 JWT 核心逻辑了。


19.1.2. 密码学基石:RSA 密钥对生成与加载

在上一节中,我们搭建了多模块工程的骨架。现在我们需要解决一个核心问题:如何生成和管理 JWT 的签名密钥?

假设我们现在要实现一个登录系统,用户登录成功后,服务器会生成一个 Token 返回给前端。前端在后续的请求中携带这个 Token,服务器验证 Token 的有效性。

这里有一个关键问题:服务器如何确保 Token 没有被篡改

答案是:使用密码学签名。服务器在生成 Token 时,会用一个密钥对 Token 进行签名。当前端发送 Token 回来时,服务器用同一个密钥验证签名。如果 Token 被篡改,签名验证就会失败。

但这里又引出了一个新问题:应该使用什么样的签名算法?


1. HS256 vs RS256:架构抉择

在 JWT 的世界里,有两种主流的签名算法:

HS256(对称加密)

使用同一个密钥进行签名和验证:

1
2
签名:Token + 密钥 → 签名
验证:Token + 密钥 → 验证签名是否匹配

这种方案的问题在于:签名和验证使用同一个密钥

假设我们的系统有 20 个微服务,每个服务都需要验证 Token。那么这 20 个服务都必须持有同一个密钥。这带来两个风险:

风险一:攻击面扩大

密钥存在于 20 个不同的配置文件、环境变量、容器镜像中。任何一个环节泄漏,整个系统就沦陷了。

风险二:密钥轮换困难

当我们需要更换密钥时(如定期安全审计、员工离职),必须同时更新 20 个服务的配置并重启。在生产环境中,这几乎不可能做到原子性切换。

RS256(非对称加密)

使用一对密钥:私钥用于签名,公钥用于验证:

1
2
签名:Token + 私钥 → 签名
验证: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
2
.....+++
.....+++

并且在当前目录下会生成一个 private_key.pem 文件。

验证私钥格式

用文本编辑器打开 private_key.pem,你应该看到:

1
2
3
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
-----END PRIVATE KEY-----

关键标识-----BEGIN PRIVATE KEY-----(没有 “RSA” 字样),这就是 PKCS#8 格式。

❌ 错误格式示例(PKCS#1 格式,Java 无法识别):

1
2
3
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA2Z3qX8vN9k7J...
-----END 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
2
3
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtkR...
-----END PUBLIC KEY-----

关键标识-----BEGIN PUBLIC KEY-----,这就是 X.509 格式(Java 可以识别)。


步骤 4:查看生成的密钥

现在你的目录下应该有两个文件:

1
2
private_key.pem  # 私钥(PKCS#8 格式,敏感文件,不能泄漏)
public_key.pem # 公钥(X.509 格式,可以公开分发)

3. 将密钥放入项目中

步骤 1:创建密钥存放目录

auth-web 模块中,找到 src/main/resources 目录,在其下创建一个名为 certs 的文件夹:

1
2
3
4
auth-web/src/main/resources/
└── certs/
├── private_key.pem # 将生成的私钥复制到这里
└── public_key.pem # 将生成的公钥复制到这里

步骤 2:复制密钥文件

将刚才生成的 private_key.pempublic_key.pem 复制到 auth-web/src/main/resources/certs/ 目录下。

步骤 3:配置 .gitignore(重要!)

为了防止私钥被提交到 Git 仓库,必须在项目根目录的 .gitignore 文件中添加以下内容:

1
2
# 忽略私钥文件
**/certs/private_key.pem

为什么只忽略私钥?

  • 私钥:绝对不能泄漏,必须忽略。
  • 公钥:可以公开,可以提交到 Git,方便团队其他成员使用。

4. 封装 RsaKeyManager 工具类

现在我们有了密钥文件,但它们是 PEM 格式(文本格式)。我们需要将它们加载到 Java 程序中,转换为 PrivateKeyPublicKey 对象。

为什么需要转换?

Java 的 PrivateKeyPublicKey 对象需要的是二进制格式。我们需要一个工具类来完成以下工作:

  1. 读取 resources 目录下的密钥文件
  2. 去除 PEM 格式的头尾标记(-----BEGIN...-----
  3. 将 Base64 编码的字符串解码为字节数组
  4. 使用 Java 的 KeyFactory 将字节数组转换为密钥对象

📄 文件路径auth-core/src/main/java/com/example/auth/core/util/crypto/RsaKeyManager.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package com.example.auth.core.util.crypto;

import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.FileCopyUtils;

import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

/**
* RSA 密钥管理工具类
* 负责加载和解析 PEM 格式的 RSA 密钥
*/
@Slf4j
@Component
public class RsaKeyManager {

/**
* 从 resources 目录读取文件内容
*
* 为什么用 ClassPathResource?
* 因为项目打包成 jar 后,文件会被打包到 jar 包内部,
* 普通的 File 类无法读取 jar 包内的文件,必须使用 ClassPathResource
*
* @param resourcePath 资源路径,例如:certs/public_key.pem
* @return 文件内容(字符串)
* @throws IOException 如果文件不存在或读取失败
*/
public String readResourceFile(String resourcePath) throws IOException {
// 创建 ClassPathResource 对象,指向 resources 目录下的文件
ClassPathResource resource = new ClassPathResource(resourcePath);

// 使用 Spring 提供的 FileCopyUtils 读取文件内容
// InputStreamReader 用于将字节流转换为字符流,并指定编码为 UTF-8
try (InputStreamReader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) {
return FileCopyUtils.copyToString(reader);
}
}

/**
* 加载私钥
*
* @param privateKeyPem PEM 格式的私钥字符串
* @return PrivateKey 对象
* @throws Exception 如果密钥格式错误或解析失败
*/
public PrivateKey loadPrivateKey(String privateKeyPem) throws Exception {
// 步骤 1:清理 PEM 格式的头尾标记
// 原始内容:
// -----BEGIN RSA PRIVATE KEY-----
// MIIEpAIBAAKCAQEA...
// -----END RSA PRIVATE KEY-----
//
// 清理后:
// MIIEpAIBAAKCAQEA...
String privateKeyContent = privateKeyPem
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
.replace("-----END RSA PRIVATE KEY-----", "")
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", ""); // 去除所有空白字符(空格、换行、制表符)

// 步骤 2:将 Base64 编码的字符串解码为字节数组
byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);

// 步骤 3:使用 PKCS8 规范解析私钥
// PKCS8 是一种密钥存储格式标准
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);

// 步骤 4:使用 KeyFactory 生成 PrivateKey 对象
// "RSA" 表示使用 RSA 算法
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

log.info("私钥加载成功");
return privateKey;
}

/**
* 加载公钥
*
* @param publicKeyPem PEM 格式的公钥字符串
* @return PublicKey 对象
* @throws Exception 如果密钥格式错误或解析失败
*/
public PublicKey loadPublicKey(String publicKeyPem) throws Exception {
// 步骤 1:清理 PEM 格式的头尾标记
String publicKeyContent = publicKeyPem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", ""); // 去除所有空白字符

// 步骤 2:将 Base64 编码的字符串解码为字节数组
byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent);

// 步骤 3:使用 X509 规范解析公钥
// X509 是公钥证书的标准格式
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);

// 步骤 4:使用 KeyFactory 生成 PublicKey 对象
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);

log.info("公钥加载成功");
return publicKey;
}
}

关键知识点解读

A. 为什么要去除 PEM 格式的头尾标记?

PEM 格式是一种文本格式,用于存储密钥和证书。它的结构是:

1
2
3
-----BEGIN [类型]-----
[Base64 编码的密钥数据]
-----END [类型]-----

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

方法速查表

类名方法名作用参数返回值
RsaKeyManagerreadResourceFile读取 resources 目录下的文件resourcePathString
RsaKeyManagerloadPrivateKey加载私钥privateKeyPemPrivateKey
RsaKeyManagerloadPublicKey加载公钥publicKeyPemPublicKey

架构对比表

对比维度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
2
3
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzUyODAwMDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

这三部分分别是:

第一部分:Header(头部)

1
2
3
4
{
"alg": "RS256",
"typ": "JWT"
}
  • alg:签名算法(Algorithm),这里是 RS256
  • typ:类型(Type),固定为 JWT

这部分经过 Base64Url 编码 后,就是 Token 的第一段:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

第二部分:Payload(载荷)

1
2
3
4
5
{
"userId": 1001,
"username": "admin",
"exp": 1735280000
}
  • userId:用户 ID(自定义字段)
  • username:用户名(自定义字段)
  • exp:过期时间(Expiration,标准字段)

这部分经过 Base64Url 编码 后,就是 Token 的第二段:eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzUyODAwMDB9

第三部分:Signature(签名)

1
2
3
4
Signature = RSA_Sign(
Base64Url(Header) + "." + Base64Url(Payload),
私钥
)

签名的作用是 防止 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
2
3
4
{
"alg": "RS256",
"typ": "JWT"
}

Payload

1
2
3
4
5
{
"userId": 1001,
"username": "admin",
"exp": 1735280000
}

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
2
3
4
// ❌ 旧版本写法(存在隐患)
Jwts.builder()
.signWith(SignatureAlgorithm.HS256, rsaPrivateKey) // 运行时才会报错!
.compact();

这种写法的致命伤在于:算法与密钥类型分离。你指定了对称加密算法 HS256,却传入了非对称加密的 RSA 私钥。编译器无法发现这个逻辑错误,只有在代码运行到这一行时才会抛出异常。

新版本(0.12.x)的写法

1
2
3
4
// ✅ 新版本写法(类型安全)
Jwts.builder()
.signWith(rsaPrivateKey, Jwts.SIG.RS256) // 编译期检查
.compact();

新版本强制要求你只传入密钥,算法由库自动推断或通过强类型常量指定。这样编译器就能在编译期发现错误。


4. 封装 JwtUtil 工具类

现在我们来封装一个 JWT 工具类,提供 Token 的生成、解析、验证功能。

步骤 1:创建 JWT 配置类

首先,我们需要定义一个配置类来承载 JWT 的相关配置(如有效期、密钥路径等)。

📄 文件路径auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.auth.core.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
* JWT 核心配置类
* 自动绑定 application.yml 中以 jwt 开头的配置项
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

/**
* 签发者标识 (Issuer)
* 用于在多系统交互中标识 Token 来源
*/
private String issuer = "auth-service";

/**
* Access Token 有效期(分钟)
* 默认 15 分钟
*/
private Integer accessTokenExpireMinutes = 15;

/**
* 时钟偏差容忍度(秒)
* 解决分布式服务器时间不同步问题,默认 30 秒
*/
private Long clockSkewSeconds = 30L;

/**
* 公钥资源路径
* 例如: certs/public_key.pem
*/
private String publicKeyResource = "certs/public_key.pem";

/**
* 私钥资源路径
* 例如: certs/private_key.pem
*/
private String privateKeyResource = "certs/private_key.pem";
}

步骤 2:配置文件映射

auth-web/src/main/resources/application.yml 中添加对应的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jwt:
# 对应 JwtProperties.issuer
issuer: pro-auth-service

# 对应 JwtProperties.accessTokenExpireMinutes
access-token-expire-minutes: 15

# 对应 JwtProperties.clockSkewSeconds
clock-skew-seconds: 30

# 对应 JwtProperties.publicKeyResource
public-key-resource: certs/public_key.pem

# 对应 JwtProperties.privateKeyResource
private-key-resource: certs/private_key.pem

步骤 3:封装 JwtUtil 工具类

📄 文件路径auth-core/src/main/java/com/example/auth/core/util/JwtUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
package com.example.auth.core.util;

import cn.hutool.core.util.IdUtil;
import com.example.auth.core.config.properties.JwtProperties;
import com.example.auth.core.util.crypto.RsaKeyManager;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Date;
import java.util.Map;

/**
* JWT 工具类
* 封装 JJWT 0.12 原生 API,提供开箱即用的 Token 服务
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtUtil {

private final JwtProperties jwtProperties;
private final RsaKeyManager rsaKeyManager;

// 缓存解析后的密钥对象,避免每次请求重复解析 PEM 字符串
private PrivateKey privateKey;
private PublicKey publicKey;

/**
* 初始化阶段:加载密钥
* 利用 Spring 生命周期,在应用启动时完成耗时的密钥解析
*/
@PostConstruct
public void init() throws Exception {
// 从文件加载私钥
String privateKeyContent = rsaKeyManager.readResourceFile(jwtProperties.getPrivateKeyResource());
this.privateKey = rsaKeyManager.loadPrivateKey(privateKeyContent);

// 从文件加载公钥
String publicKeyContent = rsaKeyManager.readResourceFile(jwtProperties.getPublicKeyResource());
this.publicKey = rsaKeyManager.loadPublicKey(publicKeyContent);

log.info("JWT 工具类初始化完成: Issuer ={}, Access Token 有效期 ={}分钟",
jwtProperties.getIssuer(),
jwtProperties.getAccessTokenExpireMinutes());
}

/**
* 创建 Token
* 对应原生 API 的 signWith 流程
*
* @param userId 用户 ID
* @param username 用户名
* @param extraClaims 扩展载荷(如角色、权限),可以为 null
* @return JWT 字符串
*/
public String createToken(Long userId, String username, Map <String, Object> extraClaims) {
// 获取当前时间(毫秒)
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);

// 计算过期时间:当前时间 + 配置的分钟数
// 注意:需要先转换为毫秒(1 分钟 = 60 秒 = 60000 毫秒)
Date expiration = new Date(nowMillis + jwtProperties.getAccessTokenExpireMinutes() * 60 * 1000L);

// 使用 Hutool 生成 JTI (JWT ID),用于后续的黑名单功能
// fastSimpleUUID() 生成的是无连字符的 UUID,性能更高
String jti = IdUtil.fastSimpleUUID();

// 开始构建 JWT
JwtBuilder builder = Jwts.builder()
// 设置头部类型为 JWT
.header().type("JWT").and()

// === 标准声明 (Standard Claims) == =
// issuer: 签发者,标识这个 Token 是谁发的
.issuer(jwtProperties.getIssuer())
// subject: 主题,通常存放用户 ID
.subject(userId.toString())
// issuedAt: 签发时间
.issuedAt(now)
// expiration: 过期时间
.expiration(expiration)
// id: JWT 唯一标识,用于黑名单机制
.id(jti)

// === 私有声明 (Private Claims) == =
// 存放业务相关的自定义字段
.claim("username", username)
.claim("userId", userId);

// 如果有额外的载荷(如角色、权限),则添加进去
if (extraClaims != null && ! extraClaims.isEmpty()) {
builder.claims().add(extraClaims);
}

// 核心签名:使用 RSA 私钥 + RS256 算法
// Jwts.SIG.RS256 是 JJWT 0.12 提供的类型安全常量
return builder.signWith(privateKey, Jwts.SIG.RS256).compact();
}

/**
* 解析并验证 Token
* 对应原生 API 的 verifyWith 流程
*
* @param token JWT 字符串
* @return Claims 载荷对象,包含所有声明信息
* @throws JwtException 如果验证失败(过期、签名错误等)
*/
public Claims parseToken(String token) {
return Jwts.parser()
// 指定公钥验证签名
.verifyWith(publicKey)

// 设置时钟偏差 (核心细节)
// 允许两台服务器之间存在 30 秒的时间差
// 避免刚签发的 Token 因为时间不同步而立即失效
.clockSkewSeconds(jwtProperties.getClockSkewSeconds())

// 构建解析器(JJWT 0.12 必须显式调用 build)
.build()

// 解析签名后的 Token,并返回载荷部分
.parseSignedClaims(token)
.getPayload();
}

/**
* 快速验证方法
* 吞没异常,仅返回布尔值,适用于过滤器中的快速判断
*
* @param token JWT 字符串
* @return true 表示 Token 有效,false 表示无效
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
// 这里不打印完整堆栈,避免日志刷屏
// 只记录简要信息,方便排查问题
log.debug("Token 验证失败: {}", e.getMessage());
return false;
}
}

/**
* 从 Token 中提取用户 ID
* 这是一个便捷方法,避免每次都要先 parseToken 再 getSubject
*
* @param token JWT 字符串
* @return 用户 ID,如果解析失败则返回 null
*/
public Long getUserIdFromToken(String token) {
try {
Claims claims = parseToken(token);
// subject 存储的是字符串形式的用户 ID,需要转换为 Long
return Long.parseLong(claims.getSubject());
} catch (Exception e) {
log.warn("从 Token 中提取用户 ID 失败: {}", e.getMessage());
return null;
}
}

/**
* 从 Token 中提取用户名
*
* @param token JWT 字符串
* @return 用户名,如果解析失败则返回 null
*/
public String getUsernameFromToken(String token) {
try {
Claims claims = parseToken(token);
// 从私有声明中获取 username
return claims.get("username", String.class);
} catch (Exception e) {
log.warn("从 Token 中提取用户名失败: {}", e.getMessage());
return null;
}
}

/**
* 从 Token 中提取 JTI (JWT ID)
* 用于黑名单机制
*
* @param token JWT 字符串
* @return JTI,如果解析失败则返回 null
*/
public String getJtiFromToken(String token) {
try {
Claims claims = parseToken(token);
return claims.getId();
} catch (Exception e) {
log.warn("从 Token 中提取 JTI 失败: {}", e.getMessage());
return null;
}
}
}

关键设计细节解读

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-446655440000
  • IdUtil.fastSimpleUUID() 生成的是无连字符的格式:550e8400e29b41d4a716446655440000
  • 无连字符的格式更短,存储和传输效率更高

C. @PostConstruct 的性能优化

我们在 init() 方法上使用了 @PostConstruct 注解。这意味着密钥的加载和解析只会在应用启动时执行一次,然后缓存在 privateKeypublicKey 字段中。

如果不这样做,每次生成或验证 Token 都需要重新读取文件、解析 PEM 格式,性能会非常差。


5. 本节小结

我们完成了 JWT 工具类的构建。

核心成果

步骤操作产出
1理解 JWT 三段式结构Header + Payload + Signature
2使用 jwt.io 手动解析 Token理解 Base64Url 编码
3掌握 JJWT 0.12 Builder 模式类型安全的 API
4封装 JwtUtil 工具类提供生成、解析、验证功能

方法速查表

类名方法名作用参数返回值
JwtUtilcreateToken生成 TokenuserId, username, extraClaimsString
JwtUtilparseToken解析 TokentokenClaims
JwtUtilvalidateToken验证 Tokentokenboolean
JwtUtilgetUserIdFromToken提取用户 IDtokenLong
JwtUtilgetUsernameFromToken提取用户名tokenString
JwtUtilgetJtiFromToken提取 JTItokenString

JWT 三段式结构

部分内容编码方式
Header{"alg":"RS256","typ":"JWT"}Base64Url
Payload{"userId":1001,"username":"admin","exp":1735280000}Base64Url
SignatureRSA 签名二进制

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 是如何工作的。

场景引入

假设我们现在要开发一个在线文档系统。用户需要先登录,然后才能查看和编辑文档。我们的目标是:

  1. 用户输入用户名和密码,点击登录
  2. 服务器验证通过后,生成一个 Token 返回给前端
  3. 前端在后续的请求中携带这个 Token
  4. 服务器验证 Token 的有效性,允许用户访问受保护的资源

这是最基础的认证流程。我们先用单令牌实现,然后在后续章节中暴露它的问题,再升级为双令牌。


1. 封装 TokenService 业务层

首先,我们需要一个业务层来封装 Token 的创建和验证逻辑。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.auth.core.service;

import com.example.auth.core.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
* Token 管理服务
* 负责令牌的创建、验证等业务逻辑
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenService {

private final JwtUtil jwtUtil;

/**
* 创建令牌(登录成功后调用)
*
* @param userId 用户 ID
* @param username 用户名
* @return JWT Token 字符串
*/
public String createToken(Long userId, String username) {
log.info("开始创建令牌: userId ={}, username ={}", userId, username);

// 调用 JwtUtil 生成 Token
String token = jwtUtil.createToken(userId, username, null);

log.info("令牌创建成功: userId ={}", userId);
return token;
}

/**
* 验证令牌是否有效
*
* @param token JWT Token 字符串
* @return true 表示有效,false 表示无效
*/
public boolean validateToken(String token) {
return jwtUtil.validateToken(token);
}
}

2. 创建 AuthController 控制层

现在我们创建一个 Controller,提供登录和验证接口。

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package com.example.auth.web.controller;

import com.example.auth.common.model.Result;
import com.example.auth.core.service.TokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
* 认证控制器
* 提供登录、验证等接口
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final TokenService tokenService;

/**
* 模拟登录接口
*
* 实际业务中应该:
* 1. 接收用户名和密码
* 2. 验证密码是否正确
* 3. 查询用户信息
* 4. 生成令牌
*
* 这里为了演示简化处理,直接生成令牌
*
* @param userId 用户 ID
* @param username 用户名
* @return 统一响应格式
*/
@PostMapping("/login")
public Result <Map<String, Object> > login(@RequestParam Long userId, @RequestParam String username) {
log.info("收到登录请求: userId ={}, username ={}", userId, username);

// 生成令牌
String token = tokenService.createToken(userId, username);

// 构建响应数据
Map <String, Object> data = new HashMap <>();
data.put("token", token);
data.put("tokenType", "Bearer");

return Result.ok(data);
}

/**
* 验证令牌接口
*
* @param token 访问令牌
* @return 统一响应格式
*/
@GetMapping("/validate")
public Result <Map<String, Object> > validate(@RequestHeader("Authorization") String token) {
log.info("收到验证令牌请求");

// 移除 "Bearer " 前缀
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

// 验证令牌
boolean valid = tokenService.validateToken(token);

// 构建响应数据
Map <String, Object> data = new HashMap <>();
data.put("valid", valid);

return Result.ok(data);
}
}

3. Postman 测试

现在启动应用,我们通过 Postman 来测试单令牌的完整流程。

步骤 1:启动应用

运行 AuthApplicationmain 方法,确保应用启动成功。

步骤 2:测试登录接口

  • 方法POST
  • URLhttp://localhost:8080/auth/login?userId=1001&username=admin
  • 响应示例
1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwcm8tYXV0aC1zZXJ2aWNlIiwic3ViIjoiMTAwMSIsImlhdCI6MTczNTI4MDAwMCwiZXhwIjoxNzM1MjgwOTAwLCJqdGkiOiI1NTBlODQwMGUyOWI0MWQ0YTcxNjQ0NjY1NTQ0MDAwMCIsInVzZXJuYW1lIjoiYWRtaW4iLCJ1c2VySWQiOjEwMDF9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"tokenType": "Bearer"
}
}

复制 token 的值,我们将在后续测试中使用。

步骤 3:测试验证接口

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Token)
  • 响应示例
1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true
}
}

步骤 4:测试 Token 过期

为了快速测试 Token 过期的情况,我们可以修改配置文件,将有效期改为 1 分钟:

修改配置auth-web/src/main/resources/application.yml

1
2
jwt:
access-token-expire-minutes: 1 # 改为 1 分钟

重启应用,重新登录获取 Token,然后等待 1 分钟后再次验证。

响应示例

1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false
}
}

4. 暴露单令牌的两大致命问题

现在我们的单令牌系统已经能够正常工作了。但在实际使用中,我们会遇到两个致命问题:

问题一:无法主动注销

假设用户在公司电脑上登录了系统,下班后忘记退出。第二天他发现后,想要远程注销这个登录会话。

但在单令牌机制下,这是做不到的。因为 Token 是自包含的,服务器没有存储任何状态。只要 Token 没有过期,它就一直有效。

即使用户点击了 “退出登录” 按钮,前端删除了 Token,但如果有人之前复制了这个 Token,他依然可以在 Token 过期前继续使用。

问题二:有效期困境

我们现在面临一个两难的选择:

  • 有效期设置太短(如 15 分钟):用户体验差。用户正在编辑文档,突然 Token 过期了,所有操作都失败了,必须重新登录。
  • 有效期设置太长(如 7 天):安全风险高。如果 Token 被盗,攻击者可以在 7 天内随意访问用户的数据。

这两个问题看起来是矛盾的:要么牺牲用户体验,要么牺牲安全性。

解决方案预告

在下一节中,我们将引入 Redis双令牌机制,完美解决这两个问题:

  • Redis 黑名单:实现主动注销功能
  • 双令牌机制:用短效的 Access Token 保证安全,用长效的 Refresh Token 保证体验

5. 本节小结

我们实现了一个最简单的单令牌登录系统,并暴露了它的两大致命问题。

核心成果

步骤操作产出
1封装 TokenService提供 createTokenvalidateToken 方法
2创建 AuthController提供 /login/validate 接口
3Postman 测试验证单令牌的完整流程
4暴露问题无法主动注销 + 有效期困境

方法速查表

类名方法名作用参数返回值
TokenServicecreateToken创建令牌userId, usernameString
TokenServicevalidateToken验证令牌tokenboolean
AuthController/auth/login登录接口userId, usernameResult
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?

对比维度MySQLRedis
读写速度毫秒级(磁盘 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():操作 String
  • opsForList():操作 List
  • opsForSet():操作 Set
  • opsForZSet():操作 ZSet
  • opsForHash():操作 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 存储 Refresh Token(7 天过期)
redisTemplate.opsForValue().set(
"auth:token:refresh: abc123", // Key
"1001", // Value(用户 ID)
7, // 过期时间
TimeUnit.DAYS // 时间单位
);

// 获取 Refresh Token 对应的用户 ID
String userId = redisTemplate.opsForValue().get("auth:token:refresh: abc123");

// 记录登录失败次数
Long failCount = redisTemplate.opsForValue().increment("auth:login:fail: admin");
if (failCount == 1) {
// 第一次失败,设置 15 分钟过期
redisTemplate.expire("auth:login:fail: admin", 15, TimeUnit.MINUTES);
}

// 检查 Token 是否在黑名单中
Boolean isBlacklisted = redisTemplate.hasKey("auth:token:black: jti123");

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
4
5
6
7
8
9
10
11
12
13
14
// 记录用户的在线设备
redisTemplate.opsForSet().add("auth:user:tokens: 1001", "jti123");

// 获取用户的所有在线设备
Set <String> devices = redisTemplate.opsForSet().members("auth:user:tokens: 1001");

// 检查某个设备是否在线
Boolean isOnline = redisTemplate.opsForSet().isMember("auth:user:tokens: 1001", "jti123");

// 用户注销,移除设备
redisTemplate.opsForSet().remove("auth:user:tokens: 1001", "jti123");

// 获取在线设备数量
Long deviceCount = redisTemplate.opsForSet().size("auth:user:tokens: 1001");

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 记录用户的在线设备(按登录时间排序)
long loginTime = System.currentTimeMillis();
redisTemplate.opsForZSet().add("auth:user:tokens:sorted:1001", "jti123", loginTime);

// 获取最早登录的设备(Score 最小的)
Set <String> oldestDevices = redisTemplate.opsForZSet().range("auth:user:tokens:sorted:1001", 0, 0);

// 获取所有在线设备(按登录时间倒序,最新的在前)
Set <String> allDevices = redisTemplate.opsForZSet().reverseRange("auth:user:tokens:sorted:1001", 0, -1);

// 获取在线设备数量
Long deviceCount = redisTemplate.opsForZSet().size("auth:user:tokens:sorted:1001");

// 移除最早登录的设备
redisTemplate.opsForZSet().removeRange("auth:user:tokens:sorted:1001", 0, 0);

3. Pipeline 批量操作

在实际业务中,我们经常需要执行多个 Redis 操作。如果每个操作都是一次网络请求,性能会很差。

问题场景

假设用户登录时,我们需要做三件事:

  1. 存储 Refresh Token
  2. 将 Token ID 记录到用户的在线设备列表
  3. 给在线列表设置过期时间

普通写法(3 次网络请求):

1
2
3
4
5
6
7
8
// 第 1 次网络请求
redisTemplate.opsForValue().set("auth:token:refresh: abc123", "1001", 7, TimeUnit.DAYS);

// 第 2 次网络请求
redisTemplate.opsForSet().add("auth:user:tokens: 1001", "jti123");

// 第 3 次网络请求
redisTemplate.expire("auth:user:tokens: 1001", 7, TimeUnit.DAYS);

每次网络请求的耗时大约是 1-2 毫秒(局域网环境)。3 次请求就是 3-6 毫秒

Pipeline 写法(1 次网络请求):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
redisTemplate.executePipelined((RedisCallback <Object>) connection -> {

// 操作 1:存储 Refresh Token
connection.stringCommands().setEx(
"auth:token:refresh: abc123".getBytes(),
7 * 86400, // 7 天(秒)
"1001".getBytes()
);

// 操作 2:记录在线设备
connection.setCommands().sAdd(
"auth:user:tokens: 1001".getBytes(),
"jti123".getBytes()
);

// 操作 3:设置过期时间
connection.keyCommands().expire(
"auth:user:tokens: 1001".getBytes(),
7 * 86400 // 7 天(秒)
);

// Pipeline 不需要返回值
return null;
});

性能对比

方式网络请求次数耗时性能提升
普通写法3 次3-6 毫秒-
Pipeline1 次1-2 毫秒60% 提升

什么是 Pipeline?

Pipeline(管道)是 Redis 提供的一种批量操作机制。它的原理是:

  1. 客户端将多个命令打包成一个请求
  2. 一次性发送给 Redis 服务器
  3. Redis 服务器依次执行这些命令
  4. 将所有结果打包返回给客户端

这样就把 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 的完整语法,为双令牌机制打下了基础。

核心成果

数据结构操作方法适用场景
StringopsForValue()存储 Token、黑名单标记、计数器
SetopsForSet()存储在线设备列表(无序)
ZSetopsForZSet()存储在线设备列表(按登录时间排序)

方法速查表(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 毫秒)-
Pipeline1 次1-2 毫秒60% 提升

现在,我们已经掌握了 Redis 的基本操作。在下一节中,我们将升级为双令牌机制,彻底解决单令牌的两大问题。


19.1.6. 双令牌升级:解决问题

在上一节中,我们掌握了 Redis 的基本操作。现在我们来升级为双令牌机制,彻底解决单令牌的两大问题。

场景引入

回顾一下单令牌的两大问题:

  1. 无法主动注销:Token 是自包含的,服务器无法主动作废
  2. 有效期困境:短了用户烦,长了不安全

现在我们用一个巧妙的设计来解决这两个问题:双令牌机制

设计理念

我们不再只发一个 Token,而是发两个:

  • Access Token(访问令牌):短效(15 分钟),真正用来请求接口的凭证
  • Refresh Token(刷新令牌):长效(7 天),专门用来换取新 Access Token 的凭证

这就像你去银行办业务:

  • Access Token = 排队号码牌(有效期很短,过期就作废)
  • Refresh Token = 身份证(有效期很长,可以用来重新取号)

当你的号码牌过期了,你不需要重新排队,只需要拿着身份证去柜台换一个新号码牌。

架构图

1
2
3
4
5
6
7
8
9
10
11
12
用户登录

服务器生成双令牌
├─ Access Token (15 分钟) → 前端存储在内存
└─ Refresh Token (7 天) → 前端存储在 localStorage

用户请求接口

携带 Access Token

Access Token 有效 → 正常访问
Access Token 过期 → 自动用 Refresh Token 刷新 → 获取新的双令牌

1. 扩展 JWT 配置类

在实现双令牌之前,我们需要先将 Refresh Token 的有效期配置化,而不是硬编码在代码里。

📄 文件路径auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java

在原有的 JwtProperties 类中,我们 追加 以下字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.auth.core.config.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
* JWT 核心配置类
* 自动绑定 application.yml 中以 jwt 开头的配置项
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

// 其余保持不变

/**
* Refresh Token 有效期(天)
* 默认 7 天
*/
private Integer refreshTokenExpireDays = 7;
}

步骤 2:修改配置文件

📄 文件路径auth-web/src/main/resources/application.yml

在原有的配置基础上,追加 Refresh Token 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
jwt:
# 签发者标识
issuer: pro-auth-service

# Access Token 有效期(分钟)
access-token-expire-minutes: 15

# Refresh Token 有效期(天)
refresh-token-expire-days: 7

# 时钟偏差容忍度(秒)
clock-skew-seconds: 30

# 公钥资源路径
public-key-resource: certs/public_key.pem

# 私钥资源路径
private-key-resource: certs/private_key.pem

2. 定义 AuthToken 模型

首先,我们需要定义一个模型来承载双令牌。

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/AuthToken.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.auth.core.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
* 双令牌响应模型
* 用于返回给前端的认证令牌信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthToken implements Serializable {

/**
* 访问令牌 (短效, JWT)
* 用于请求受保护的接口
*/
private String accessToken;

/**
* 刷新令牌 (长效, 随机串)
* 用于在 accessToken 过期后换取新的令牌
*/
private String refreshToken;

/**
* Access Token 剩余有效期 (秒)
* 前端可根据此时间提前发起刷新
*/
private Long expiresIn;

/**
* 令牌类型,通常为 "Bearer"
* 前端在请求头中使用:Authorization: Bearer {accessToken}
*/
@Builder.Default
private String tokenType = "Bearer";
}

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
2
3
auth:token:refresh:a1b2c3d4e5f6    # 认证系统 - 令牌模块 - 刷新令牌 - Token ID
auth:token:black:550e8400e29b # 认证系统 - 令牌模块 - 黑名单 - JTI
auth:user:tokens:1001 # 认证系统 - 用户模块 - 在线设备 - 用户 ID

优势对比

命名方式示例优势劣势
❌ 无规范token123简短无法区分系统和业务,容易冲突
❌ 两段式token:123较简短无法区分项目和模块,多系统共享 Redis 时会冲突
✅ 四段式auth:token:refresh:123清晰、唯一、可维护稍长(但可读性强)

📄 文件路径auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.auth.core.constant;

/**
* Redis Key 常量类
* 统一管理所有 Redis Key 的前缀,防止 Key 冲突
*
* 命名规范:项目: 模块: 业务: 主键
* - 项目:auth(认证系统)
* - 模块:token(令牌模块)、user(用户模块)
* - 业务:refresh(刷新令牌)、black(黑名单)、tokens(在线设备)
* - 主键:具体的业务 ID
*/
public class RedisKeyConstants {

/**
* 刷新令牌映射 Key 前缀
* 完整格式:auth:token:refresh:{refreshToken}
* 示例:auth:token:refresh: a1b2c3d4e5f6
* 存储内容:用户 ID(String)
* 作用:通过 Refresh Token 快速查询用户 ID
*/
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";

/**
* 用户在线设备索引 Key 前缀
* 完整格式:auth:user:tokens:{userId}
* 示例:auth:user:tokens: 1001
* 存储内容:该用户所有在线设备的 JTI 集合(Set 结构)
* 作用:当用户注销时,可以通过 userId 找到所有设备并踢出
*/
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";

/**
* 构建刷新令牌 Key
* @param refreshToken 刷新令牌
* @return 完整的 Redis Key
*/
public static String buildRefreshTokenKey(String refreshToken) {
return TOKEN_REFRESH_PREFIX + refreshToken;
}

/**
* 构建用户在线设备 Key
* @param userId 用户 ID
* @return 完整的 Redis Key
*/
public static String buildUserOnlineKey(Long userId) {
return USER_ONLINE_TOKENS_PREFIX + userId;
}
}

Key 设计对照表

Key 类型完整格式数据结构Value 内容TTL作用
刷新令牌auth:token:refresh:{refreshToken}String用户 ID7 天通过 Refresh Token 查询用户 ID
在线设备auth:user:tokens:{userId}SetJTI 集合7 天记录用户的所有在线设备

4. 封装 TokenStoreService 存储层

现在我们创建一个存储层,负责 Token 在 Redis 中的存储、查询、删除等操作。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenStoreService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
package com.example.auth.core.service;

import com.example.auth.core.constant.RedisKeyConstants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
* Token 存储服务
* 负责 Token 在 Redis 中的存储、查询、删除等操作
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenStoreService {

private final StringRedisTemplate redisTemplate;

/**
* 存储 Refresh Token(使用 Pipeline 批量操作)
*
* 为什么要用 Pipeline?
* 普通写法需要 3 次网络请求:
* 1. 存储 Refresh Token
* 2. 记录用户在线设备
* 3. 设置过期时间
*
* Pipeline 可以把这 3 个命令打包成 1 次网络请求,性能提升约 60%
*
* @param userId 用户 ID
* @param refreshToken 刷新令牌
* @param jti JWT ID(用于黑名单机制)
* @param expireSeconds 过期时间(秒)
*/
public void storeRefreshToken(Long userId, String refreshToken, String jti, long expireSeconds) {
// 开启 Pipeline 模式
redisTemplate.executePipelined((org.springframework.data.redis.core.RedisCallback<Object>) connection -> {

// 1. 存储 Refresh Token (K = refreshToken, V = userId)
// 使用 setEx 命令:set + expire 的组合,存值并设置过期时间
String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
connection.stringCommands().setEx(
refreshKey.getBytes(), // Key
expireSeconds, // 过期时间(秒)
userId.toString().getBytes() // Value
);

// 2. 记录用户的在线设备 (Set 结构)
// 作用:当用户注销时,可以通过 userId 找到所有设备并踢出
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
connection.setCommands().sAdd(
userKey.getBytes(), // Key
jti.getBytes() // Value(添加到 Set 中)
);

// 3. 给设备记录也设置过期时间(防止数据残留)
connection.keyCommands().expire(
userKey.getBytes(), // Key
expireSeconds // 过期时间(秒)
);

// Pipeline 不需要返回值
return null;
});

log.debug("存储 Refresh Token 成功: userId={}, jti={}, 有效期={}秒", userId, jti, expireSeconds);
}

/**
* 验证 Refresh Token 并获取用户 ID
*
* @param refreshToken 刷新令牌
* @return 用户 ID,如果 Token 无效或已过期则返回 null
*/
public Long getUserIdByRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
String userId = redisTemplate.opsForValue().get(key);

// 如果 Redis 中没有这个 Key,说明 Token 无效或已过期
if (userId == null) {
return null;
}

// 将字符串转换为 Long 类型
return Long.parseLong(userId);
}

/**
* 删除 Refresh Token(Token 轮换时使用)
*
* @param refreshToken 刷新令牌
*/
public void deleteRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
redisTemplate.delete(key);
log.debug("删除 Refresh Token: {}", refreshToken);
}
}

5. 升级 TokenService 业务层

现在我们升级 TokenService,添加双令牌的创建和刷新功能。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenService.java

在原有的 TokenService 类中,我们 追加 以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package com.example.auth.core.service;

import cn.hutool.core.util.IdUtil;
import com.example.auth.core.config.properties.JwtProperties;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
* Token 管理服务
* 负责令牌的创建、刷新、验证等业务逻辑
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenService {

private final JwtUtil jwtUtil;
private final TokenStoreService tokenStoreService;
private final JwtProperties jwtProperties;

/**
* 创建令牌(单令牌版本,保留用于对比)
*
* @param userId 用户 ID
* @param username 用户名
* @return JWT Token 字符串
*/
public String createToken(Long userId, String username) {
log.info("开始创建令牌: userId={}, username={}", userId, username);
String token = jwtUtil.createToken(userId, username, null);
log.info("令牌创建成功: userId={}", userId);
return token;
}

/**
* 创建双令牌(登录成功后调用)
*
* @param userId 用户 ID
* @param username 用户名
* @return 包含 Access Token 和 Refresh Token 的对象
*/
public AuthToken createTokenPair(Long userId, String username) {
log.info("开始创建双令牌: userId={}, username={}", userId, username);

// 1. 生成 Access Token (JWT)
String accessToken = jwtUtil.createToken(userId, username, null);

// 2. 从 Access Token 中提取 JTI
// 因为 JTI 是在 createToken 内部生成的,我们需要解析 Token 拿回它
// 以便存入 Redis 的在线设备列表
String jti = jwtUtil.getJtiFromToken(accessToken);

// 3. 生成 Refresh Token(使用 Hutool 生成随机字符串)
// 为什么不用 JWT?因为 Refresh Token 不需要携带信息,只是一个随机凭证
String refreshToken = IdUtil.fastSimpleUUID();

// 4. 计算 Refresh Token 的过期时间(秒)
// 从配置文件读取天数,转换为秒:天 * 24 * 60 * 60
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;

// 5. 将 Refresh Token 存入 Redis(使用 Pipeline 优化)
tokenStoreService.storeRefreshToken(userId, refreshToken, jti, expireSeconds);

// 6. 打包返回
return AuthToken.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn((long) jwtProperties.getAccessTokenExpireMinutes() * 60)
.build();
}

/**
* 刷新令牌(核心逻辑)
*
* 场景:前端发现 Access Token 过期了,拿着 Refresh Token 来换新的
*
* @param oldRefreshToken 旧的刷新令牌
* @return 新的双令牌
*/
public AuthToken refreshToken(String oldRefreshToken) {
log.info("开始刷新令牌: oldRefreshToken={}", oldRefreshToken);

// 1. 去 Redis 查:这个 Refresh Token 是谁的?
Long userId = tokenStoreService.getUserIdByRefreshToken(oldRefreshToken);

if (userId == null) {
log.warn("Refresh Token 无效或已过期: {}", oldRefreshToken);
throw new RuntimeException("刷新令牌无效或已过期,请重新登录");
}

// 2. 查询用户信息
// 实际业务中应该从数据库查询,这里为了演示简化处理
String username = "User-" + userId;

// 3. 令牌轮换 (Token Rotation) —— 安全的核心!
// 生成一对全新的令牌
AuthToken newTokenPair = createTokenPair(userId, username);

// 4. 【重要】立即销毁旧的 Refresh Token
// 如果旧 Token 是被黑客偷走的,他用过一次后,这个 Token 就废了
// 当真正的主人来刷新时,会发现 Token 失效,从而触发重新登录报警
tokenStoreService.deleteRefreshToken(oldRefreshToken);

log.info("令牌刷新成功: userId={}", userId);
return newTokenPair;
}

/**
* 验证令牌是否有效
*
* @param token JWT Token 字符串
* @return true 表示有效,false 表示无效
*/
public boolean validateToken(String token) {
return jwtUtil.validateToken(token);
}
}

6. 升级 AuthController 控制层

现在我们升级 AuthController,添加双令牌的登录和刷新接口。

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java

在原有的 AuthController 类中,我们 追加 以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package com.example.auth.web.controller;

import com.example.auth.common.model.Result;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.service.TokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
* 认证控制器
* 提供登录、刷新、验证等接口
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final TokenService tokenService;

/**
* 单令牌登录接口(保留用于对比)
*/
@PostMapping("/login-single")
public Result<Map<String, Object>> loginSingle(@RequestParam Long userId, @RequestParam String username) {
log.info("收到单令牌登录请求: userId={}, username={}", userId, username);

String token = tokenService.createToken(userId, username);

Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("tokenType", "Bearer");

return Result.ok(data);
}

/**
* 双令牌登录接口
*
* @param userId 用户 ID
* @param username 用户名
* @return 统一响应格式
*/
@PostMapping("/login")
public Result<AuthToken> login(@RequestParam Long userId, @RequestParam String username) {
log.info("收到双令牌登录请求: userId={}, username={}", userId, username);

// 生成双令牌
AuthToken authToken = tokenService.createTokenPair(userId, username);

return Result.ok(authToken);
}

/**
* 刷新令牌接口
*
* @param refreshToken 刷新令牌
* @return 统一响应格式
*/
@PostMapping("/refresh")
public Result<AuthToken> refresh(@RequestParam String refreshToken) {
log.info("收到刷新令牌请求");

try {
// 刷新令牌
AuthToken authToken = tokenService.refreshToken(refreshToken);
return Result.ok(authToken);
} catch (Exception e) {
log.error("刷新令牌失败", e);
return Result.fail(401, e.getMessage());
}
}

/**
* 验证令牌接口
*/
@GetMapping("/validate")
public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String token) {
log.info("收到验证令牌请求");

if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

boolean valid = tokenService.validateToken(token);

Map<String, Object> data = new HashMap<>();
data.put("valid", valid);

return Result.ok(data);
}
}

7. Postman 测试

现在启动应用,我们通过 Postman 来测试双令牌的完整流程。

步骤 1:测试双令牌登录

  • 方法POST
  • URLhttp://localhost:8080/auth/login?userId=1001&username=admin
  • 响应示例
1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwcm8tYXV0aC1zZXJ2aWNlIiwic3ViIjoiMTAwMSIsImlhdCI6MTczNTI4MDAwMCwiZXhwIjoxNzM1MjgwOTAwLCJqdGkiOiI1NTBlODQwMGUyOWI0MWQ0YTcxNjQ0NjY1NTQ0MDAwMCIsInVzZXJuYW1lIjoiYWRtaW4iLCJ1c2VySWQiOjEwMDF9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"refreshToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

关键点

  • accessToken:这是一个 JWT,有效期 15 分钟
  • refreshToken:这是一个随机字符串,有效期 7 天
  • expiresIn:Access Token 的剩余有效期(秒)

复制 accessTokenrefreshToken,我们将在后续测试中使用。

步骤 2:使用 Access Token 访问受保护接口

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Access Token)
  • 响应示例
1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true
}
}

步骤 3:模拟 Access Token 过期,使用 Refresh Token 刷新

为了快速测试,我们可以修改配置文件,将 Access Token 有效期改为 1 分钟:

修改配置auth-web/src/main/resources/application.yml

1
2
jwt:
access-token-expire-minutes: 1 # 改为 1 分钟

重启应用,重新登录获取双令牌,然后等待 1 分钟后,Access Token 就会过期。

此时,我们使用 Refresh Token 来刷新:

  • 方法POST
  • URLhttp://localhost:8080/auth/refresh?refreshToken=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6(替换为你的 Refresh Token)
  • 响应示例
1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "q1w2e3r4t5y6u7i8o9p0a1s2d3f4g5h6",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

关键点

  • 返回了新的 accessTokenrefreshToken
  • 旧的 refreshToken 已经失效(Token 轮换机制)

步骤 4:验证旧 Refresh Token 已失效

  • 方法POST
  • URLhttp://localhost:8080/auth/refresh?refreshToken=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6(使用旧的 Refresh Token)
  • 响应示例
1
2
3
4
5
{
"code": 401,
"message": "刷新令牌无效或已过期,请重新登录",
"data": null
}

8. 双令牌机制的核心优势

现在我们来对比单令牌和双令牌,看看双令牌是如何解决问题的。

问题一:无法主动注销

单令牌双令牌
Token 是自包含的,服务器无法主动作废Refresh Token 存储在 Redis,可以随时删除
用户点击退出后,Token 依然有效用户点击退出后,删除 Redis 中的 Refresh Token,下次刷新时会失败

问题二:有效期困境

单令牌双令牌
短了用户烦,长了不安全Access Token 短(15 分钟),Refresh Token 长(7 天)
必须在安全和体验之间二选一兼顾安全和体验

Token 轮换机制的安全价值

假设黑客偷走了用户的 Refresh Token。当黑客使用这个 Token 刷新时:

  1. 服务器生成新的双令牌
  2. 立即删除旧的 Refresh Token
  3. 当真正的用户来刷新时,会发现 Token 失效
  4. 系统可以触发安全报警,强制用户重新登录

这种机制可以 快速检测到 Token 被盗,而不是等到 Token 过期。


9. 本节小结

我们完成了双令牌机制的升级,彻底解决了单令牌的两大问题。

核心成果

步骤操作产出
1扩展 JwtProperties添加 Refresh Token 有效期配置
2定义 AuthToken 模型承载双令牌的数据结构
3定义 Redis Key 常量统一的四段式命名规范
4封装 TokenStoreService提供 Redis 存储操作(使用 Pipeline 优化)
5升级 TokenService提供双令牌的创建和刷新
6升级 AuthController提供双令牌的登录和刷新接口
7Postman 测试验证双令牌的完整流程

方法速查表

类名方法名作用参数返回值
TokenStoreServicestoreRefreshToken存储刷新令牌(Pipeline 优化)userId, refreshToken, jti, expireSecondsvoid
TokenStoreServicegetUserIdByRefreshToken根据刷新令牌获取用户 IDrefreshTokenLong
TokenStoreServicedeleteRefreshToken删除刷新令牌refreshTokenvoid
TokenServicecreateTokenPair创建双令牌userId, usernameAuthToken
TokenServicerefreshToken刷新令牌(Token 轮换)oldRefreshTokenAuthToken
AuthController/auth/login双令牌登录接口userId, usernameResult
AuthController/auth/refresh刷新令牌接口refreshTokenResult

配置项速查表

配置项默认值说明
jwt.access-token-expire-minutes15Access Token 有效期(分钟)
jwt.refresh-token-expire-days7Refresh Token 有效期(天)
jwt.clock-skew-seconds30时钟偏差容忍度(秒)

Redis Key 命名规范

Key 类型完整格式数据结构Value 内容TTL作用
刷新令牌auth:token:refresh:{refreshToken}String用户 ID7 天通过 Refresh Token 查询用户 ID
在线设备auth:user:tokens:{userId}SetJTI 集合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 Token15 分钟内存变量请求接口JWT
Refresh Token7 天localStorage刷新 Token随机字符串(UUID)

现在,我们已经拥有了一个完整的双令牌机制。在下一节中,我们将实现主动注销功能,引入黑名单机制。


19.1.7. 主动注销:黑名单机制

在上一节中,我们完成了双令牌机制的升级。现在我们需要解决一个关键问题:如何实现主动注销?

场景引入

假设你在公司电脑上登录了系统,下班后忘记退出。回到家后,你突然想起来了,想要远程注销这个登录会话。

在双令牌机制下,我们可以删除 Redis 中的 Refresh Token,这样下次刷新时就会失败。但这还不够,因为:

Access Token 依然有效!

即使我们删除了 Refresh Token,攻击者依然可以在 Access Token 过期前(15 分钟内)继续访问系统。

解决方案:黑名单机制

我们需要引入一个 黑名单,将已注销的 Token 的 JTI(JWT ID)加入黑名单。当验证 Token 时,先检查黑名单,如果在黑名单中,立即拒绝。

这就是引入 Redis 的核心目的:实现 Token 的主动注销。


1. 设计黑名单机制

核心思路

  1. 用户点击 “退出登录”
  2. 服务器解析 Access Token,提取 JTI
  3. 将 JTI 加入 Redis 黑名单
  4. 设置 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 黑名单 Key 前缀
* 完整格式:auth:token:black:{jti}
* 示例:auth:token:black: 550e8400e29b41d4a716446655440000
* 存储内容:标记值(如 "BLOCKED")
*/
public static final String TOKEN_BLACKLIST_PREFIX = "auth:token:black:";

/**
* 构建黑名单 Key
* @param jti JWT ID
* @return 完整的 Redis Key
*/
public static String buildBlacklistKey(String jti) {
return TOKEN_BLACKLIST_PREFIX + jti;
}

3. 扩展 TokenStoreService

现在我们在 TokenStoreService 中添加黑名单相关的方法。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenStoreService.java

在原有的 TokenStoreService 类中,我们 追加 以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 加入黑名单
*
* 技巧:黑名单的 TTL = Token 剩余有效期
* Redis 会自动帮我们清理过期的黑名单记录,不需要写定时任务
*
* @param jti JWT ID
* @param expireTimestamp Token 的过期时间戳(毫秒)
*/
public void addToBlacklist(String jti, long expireTimestamp) {
// 计算 Token 剩余有效期(秒)
long now = System.currentTimeMillis();
long ttlSeconds = (expireTimestamp - now) / 1000;

// 只有在 Token 还没过期的情况下才加入黑名单
if (ttlSeconds > 0) {
String key = RedisKeyConstants.buildBlacklistKey(jti);
// 存储一个标记值 "BLOCKED",表示这个 JTI 已被拉黑
redisTemplate.opsForValue().set(key, "BLOCKED", ttlSeconds, TimeUnit.SECONDS);
log.info("Token 已加入黑名单: jti={}, 剩余有效期={}秒", jti, ttlSeconds);
}
}

/**
* 检查 Token 是否被拉黑
*
* @param jti JWT ID
* @return true 表示已被拉黑,false 表示未被拉黑
*/
public boolean isBlacklisted(String jti) {
String key = RedisKeyConstants.buildBlacklistKey(jti);
// hasKey 方法返回 Boolean 对象,可能为 null,需要用 Boolean.TRUE.equals 判断
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}

4. 扩展 TokenService

现在我们在 TokenService 中添加注销和验证的方法。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenService.java

在原有的 TokenService 类中,我们 追加 以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 注销登录
*
* @param accessToken 访问令牌
*/
public void logout(String accessToken) {
log.info("开始注销登录");

// 解析 Token 获取 JTI 和过期时间
Claims claims = jwtUtil.parseToken(accessToken);
String jti = claims.getId();
Date expiration = claims.getExpiration();

// 加入黑名单,有效期至 Token 自然过期
if (expiration != null) {
tokenStoreService.addToBlacklist(jti, expiration.getTime());
log.info("注销成功: jti={}", jti);
}
}

/**
* 验证 Token 是否有效(未过期且未被拉黑)
*
* 这是一个增强版的验证方法,不仅验证签名和过期时间,还检查黑名单
*
* @param accessToken 访问令牌
* @return true 表示有效,false 表示无效
*/
public boolean validateTokenWithBlacklist(String accessToken) {
// 1. 先验证 Token 格式和签名是否正确
if (!jwtUtil.validateToken(accessToken)) {
return false;
}

// 2. 再检查是否被拉黑
String jti = jwtUtil.getJtiFromToken(accessToken);
if (jti == null) {
return false;
}

return !tokenStoreService.isBlacklisted(jti);
}

5. 扩展 AuthController

现在我们在 AuthController 中添加注销接口。

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java

在原有的 AuthController 类中,我们 追加 以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 注销登录接口
*
* @param token 访问令牌
* @return 统一响应格式
*/
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader("Authorization") String token) {
log.info("收到注销请求");

try {
// 移除 "Bearer " 前缀
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

// 注销
tokenService.logout(token);

return Result.ok();
} catch (Exception e) {
log.error("注销失败", e);
return Result.fail(e.getMessage());
}
}

同时,我们需要 修改 validate 方法,使用增强版的验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 验证令牌接口(增强版,检查黑名单)
*/
@GetMapping("/validate")
public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String token) {
log.info("收到验证令牌请求");

if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

// 使用增强版验证,检查黑名单
boolean valid = tokenService.validateTokenWithBlacklist(token);

Map<String, Object> data = new HashMap<>();
data.put("valid", valid);

return Result.ok(data);
}

6. Postman 测试

现在启动应用,我们通过 Postman 来测试主动注销的完整流程。

步骤 1:登录获取 Token

  • 方法POST
  • URLhttp://localhost:8080/auth/login?userId=1001&username=admin
  • 响应示例
1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

复制 accessToken,我们将在后续测试中使用。

步骤 2:验证 Token 有效

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Token)
  • 响应示例
1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true
}
}

步骤 3:注销登录

  • 方法POST
  • URLhttp://localhost:8080/auth/logout
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(替换为你的 Token)
  • 响应示例
1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": null
}

步骤 4:验证 Token 已失效

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(使用刚才注销的 Token)
  • 响应示例
1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false
}
}

关键点:即使 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
2
GET auth:token:black:550e8400e29b41d4a716446655440000
TTL auth:token:black:550e8400e29b41d4a716446655440000

结果:

1
2
"BLOCKED"
895 # 剩余有效期(秒)

步骤 4:等待 TTL 过期

等待 15 分钟后,再次查询这个 Key:

1
GET auth:token:black:550e8400e29b41d4a716446655440000

结果:

1
(nil)  # Key 已被自动删除

这就是 Redis 的 TTL 自动清理机制,我们不需要写定时任务来清理过期的黑名单记录。


8. 本节小结

我们实现了主动注销功能,引入了黑名单机制。

核心成果

步骤操作产出
1扩展 RedisKeyConstants添加黑名单 Key 常量
2扩展 TokenStoreService提供 addToBlacklistisBlacklisted 方法
3扩展 TokenService提供 logoutvalidateTokenWithBlacklist 方法
4扩展 AuthController提供 /auth/logout 接口
5Postman 测试验证主动注销的完整流程

方法速查表

类名方法名作用参数返回值
TokenStoreServiceaddToBlacklist将 Token 加入黑名单jti, expireTimestampvoid
TokenStoreServiceisBlacklisted检查 Token 是否被拉黑jtiboolean
TokenServicelogout注销登录accessTokenvoid
TokenServicevalidateTokenWithBlacklist验证 Token(检查黑名单)accessTokenboolean
AuthController/auth/logout注销登录接口Authorization (Header)Result

黑名单机制的核心设计

设计点说明优势
存储 JTI 而非 TokenJTI 是固定长度的 UUID,Token 是变长的 JWT节省内存
TTL = Token 剩余有效期Token 过期时,黑名单记录也自动删除无需定时任务清理
验证时先检查黑名单即使 Token 签名有效,黑名单中的 Token 也会被拒绝实现主动注销

黑名单流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户点击退出

服务器解析 Token,提取 JTI

将 JTI 加入 Redis 黑名单

设置 TTL = Token 剩余有效期

后续请求验证 Token 时

先检查黑名单

如果在黑名单中 → 拒绝访问
如果不在黑名单中 → 继续验证签名和过期时间

现在,我们已经实现了主动注销功能。在下一节中,我们将建立完善的异常处理体系,让前端能够区分不同的错误类型。


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 原生异常触发场景含义前端处理
ExpiredJwtExceptionToken 已过期正常的业务流程自动刷新 Token
SignatureException签名验证失败高危,可能是伪造攻击强制跳转登录
MalformedJwtExceptionToken 格式错误无效的请求格式清除本地数据

关键认知:我们不需要定义 TokenExpiredExceptionTokenInvalidException 这些自定义异常,直接使用 JJWT 的异常即可。


2. 修改 TokenService,让异常自然向上抛

在 19.1.7 中,我们的 validateTokenWithBlacklist 方法吞掉了所有异常,只返回 true/false。现在我们需要让 JJWT 的异常自然向上抛,交给全局异常处理器统一处理。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/TokenService.java

在原有的 TokenService 类中,我们 追加 一个新方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 验证 Token 并提取用户信息(抛出异常版本)
*
* 与 validateTokenWithBlacklist(String) 的区别:
* - validateTokenWithBlacklist 吞掉异常,只返回 boolean
* - 本方法让 JJWT 的异常自然向上抛,由全局异常处理器统一处理
*
* 适用场景:需要给前端返回详细错误信息的接口(如受保护的业务接口)
*
* @param accessToken 访问令牌
* @return Claims 载荷对象
* @throws ExpiredJwtException Token 已过期(JJWT 原生异常)
* @throws SignatureException Token 签名错误(JJWT 原生异常)
* @throws MalformedJwtException Token 格式错误(JJWT 原生异常)
*/
public Claims validateAndExtract(String accessToken) {
// 1. 解析 Token(会自动验证签名和过期时间)
// 如果验证失败,JJWT 会抛出对应的异常,我们不捕获,让它向上抛
Claims claims = jwtUtil.parseToken(accessToken);

// 2. 检查是否在黑名单中
String jti = claims.getId();
if (tokenStoreService.isBlacklisted(jti)) {
// 这里我们抛出一个运行时异常,表示 Token 已被撤销
throw new RuntimeException("TOKEN_REVOKED: 用户已注销");
}

return claims;
}

3. 定义统一错误响应格式

为了让前端能够统一处理错误,我们定义一个标准的错误响应格式。

📄 文件路径auth-common/src/main/java/com/example/auth/common/model/ErrorResponse.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.auth.common.model;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
* 统一错误响应格式
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {

/**
* 时间戳
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timestamp;

/**
* HTTP 状态码
*/
private Integer status;

/**
* 错误码(用于前端识别具体错误类型)
*/
private String error;

/**
* 错误描述(用于展示给用户)
*/
private String message;

/**
* 请求路径
*/
private String path;
}

4. 创建全局异常处理器

现在我们创建一个全局异常处理器,直接捕获 JJWT 的原生异常 并转换为标准格式。

📄 文件路径auth-web/src/main/java/com/example/auth/web/handler/GlobalExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
package com.example.auth.web.handler;

import com.example.auth.common.model.ErrorResponse;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.stream.Collectors;

/**
* 全局异常处理器
* 统一处理所有认证相关的异常
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 处理 Token 过期异常
* 这是正常的业务流程,日志级别为 DEBUG
*/
@ExceptionHandler(ExpiredJwtException.class)
public ResponseEntity<ErrorResponse> handleExpiredJwt(ExpiredJwtException e, HttpServletRequest request) {
log.debug("Token 已过期: {}", e.getMessage());
return buildErrorResponse(HttpStatus.UNAUTHORIZED, "TOKEN_EXPIRED", "登录已过期,请刷新页面", request);
}

/**
* 处理签名验证失败异常
* 这是高危安全事件,需要记录详细日志
*/
@ExceptionHandler(SignatureException.class)
public ResponseEntity<ErrorResponse> handleSignatureException(SignatureException e, HttpServletRequest request) {
log.error("[SECURITY] Token 签名验证失败,疑似伪造攻击 - IP: {}, URI: {}",
getClientIp(request), request.getRequestURI());
return buildErrorResponse(HttpStatus.UNAUTHORIZED, "TOKEN_INVALID", "身份验证失败", request);
}

/**
* 处理 Token 格式错误异常
*/
@ExceptionHandler(MalformedJwtException.class)
public ResponseEntity<ErrorResponse> handleMalformedJwt(MalformedJwtException e, HttpServletRequest request) {
log.warn("Token 格式错误: {}", e.getMessage());
return buildErrorResponse(HttpStatus.BAD_REQUEST, "TOKEN_MALFORMED", "无效的请求格式", request);
}

/**
* 处理参数校验异常
* 当 @Valid 校验失败时触发
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return buildErrorResponse(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", message, request);
}

/**
* 处理非法参数异常
* 如不支持的认证方式、参数格式错误等
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) {
log.warn("非法参数: {}", e.getMessage());
return buildErrorResponse(HttpStatus.BAD_REQUEST, "ILLEGAL_ARGUMENT", e.getMessage(), request);
}

/**
* 处理 Token 被撤销异常(黑名单)
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
if (e.getMessage() != null && e.getMessage().startsWith("TOKEN_REVOKED")) {
log.info("Token 已被撤销: {}", e.getMessage());
return buildErrorResponse(HttpStatus.UNAUTHORIZED, "TOKEN_REVOKED", "登录已被撤销,请重新登录", request);
}

log.error("运行时异常: ", e);
return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误,请稍后重试", request);
}

/**
* 处理所有未捕获的异常
* 作为最后的兜底方案
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e, HttpServletRequest request) {
log.error("未捕获的异常: ", e);
return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误,请稍后重试", request);
}

/**
* 构建错误响应(统一方法,避免重复代码)
*/
private ResponseEntity<ErrorResponse> buildErrorResponse(
HttpStatus status, String error, String message, HttpServletRequest request) {
ErrorResponse response = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(status.value())
.error(error)
.message(message)
.path(request.getRequestURI())
.build();
return ResponseEntity.status(status).body(response);
}

/**
* 获取客户端真实 IP(考虑反向代理的情况)
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}


5. 修改 AuthController,使用新的验证方法

现在我们修改 AuthControllervalidate 方法,使用 validateAndExtract 方法。

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java

修改 validate 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 验证令牌接口(用于测试异常处理)
*
* 改进点:
* 1. 使用 validateAndExtract 方法,让 JJWT 的异常自然向上抛
* 2. 全局异常处理器会自动捕获并转换为标准格式
*/
@GetMapping("/validate")
public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String token) {
log.info("收到验证令牌请求");

// 移除 "Bearer " 前缀
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

// 验证并提取用户信息(如果验证失败,会抛出异常,由全局异常处理器处理)
Claims claims = tokenService.validateAndExtract(token);

// 构建响应
Map<String, Object> data = new HashMap<>();
data.put("valid", true);
data.put("userId", claims.get("userId"));
data.put("username", claims.get("username"));

return Result.ok(data);
}

6. Postman 测试

现在启动应用,我们通过 Postman 来测试完整的异常处理流程。

步骤 1:测试正常的 Token 验证

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(有效的 Token)
  • 响应示例
1
2
3
4
5
6
7
8
9
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true,
"userId": 1001,
"username": "admin"
}
}

步骤 2:测试 Token 过期异常

为了测试过期异常,我们可以修改配置文件,将 Access Token 有效期改为 1 分钟:

修改配置auth-web/src/main/resources/application.yml

1
2
jwt:
access-token-expire-minutes: 1 # 改为 1 分钟

重启应用,重新登录获取 Token,然后等待 1 分钟后再次验证。

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...(过期的 Token)
  • 响应示例
1
2
3
4
5
6
7
{
"timestamp": "2025-12-27 22:05:00",
"status": 401,
"error": "TOKEN_EXPIRED",
"message": "登录已过期,请刷新页面",
"path": "/auth/validate"
}

观察控制台日志

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
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer invalid-token-format
  • 响应示例
1
2
3
4
5
6
7
{
"timestamp": "2025-12-27 22:06:00",
"status": 400,
"error": "TOKEN_MALFORMED",
"message": "无效的请求格式",
"path": "/auth/validate"
}

步骤 4:测试签名验证失败异常

为了测试签名验证失败,我们需要使用一个签名错误的 Token。最简单的方法是修改一个有效 Token 的最后几个字符。

  • 方法GET
  • URLhttp://localhost:8080/auth/validate
  • Headers
    • Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwcm8tYXV0aC1zZXJ2aWNlIiwic3ViIjoiMTAwMSIsImlhdCI6MTczNTI4MDAwMCwiZXhwIjoxNzM1MjgwOTAwLCJqdGkiOiI1NTBlODQwMGUyOWI0MWQ0YTcxNjQ0NjY1NTQ0MDAwMCIsInVzZXJuYW1lIjoiYWRtaW4iLCJ1c2VySWQiOjEwMDF9.FAKE_SIGNATURE(修改签名部分)
  • 响应示例
1
2
3
4
5
6
7
{
"timestamp": "2025-12-27 22:07:00",
"status": 401,
"error": "TOKEN_INVALID",
"message": "身份验证失败",
"path": "/auth/validate"
}

观察控制台日志

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
2
3
4
5
6
7
{
"timestamp": "2025-12-27 22:08:00",
"status": 401,
"error": "TOKEN_REVOKED",
"message": "登录已被撤销,请重新登录",
"path": "/auth/validate"
}

7. 本节小结

我们建立了完善的异常处理体系,完全复用了 JJWT 和 Spring 的能力,没有造任何轮子。

核心成果

步骤操作产出
1理解 JJWT 原生异常体系掌握三种主要异常
2修改 TokenService添加 validateAndExtract 方法
3定义 ErrorResponse统一错误响应格式
4创建 GlobalExceptionHandler统一捕获 JJWT 异常
5修改 AuthController使用新的验证方法
6Postman 测试验证所有异常场景

方法速查表

类名方法名作用参数返回值
TokenServicevalidateAndExtract验证并提取用户信息accessTokenClaims
GlobalExceptionHandlerhandleExpiredJwt处理 Token 过期异常ExpiredJwtException, HttpServletRequestResponseEntity
GlobalExceptionHandlerhandleSignatureException处理签名验证失败异常SignatureException, HttpServletRequestResponseEntity
GlobalExceptionHandlerhandleMalformedJwt处理 Token 格式错误异常MalformedJwtException, HttpServletRequestResponseEntity

异常映射表

JJWT 异常错误码HTTP 状态前端处理测试方法
ExpiredJwtExceptionTOKEN_EXPIRED401自动刷新 Token等待 Token 过期后验证
SignatureExceptionTOKEN_INVALID401强制跳转登录修改 Token 签名部分
MalformedJwtExceptionTOKEN_MALFORMED400清除本地数据使用格式错误的字符串
RuntimeException(黑名单)TOKEN_REVOKED401提示重新登录先注销再验证

响应格式示例

1
2
3
4
5
6
7
{
"timestamp": "2025-12-27 22:00:00",
"status": 401,
"error": "TOKEN_EXPIRED",
"message": "登录已过期,请刷新页面",
"path": "/auth/validate"
}

关键优势

  • ✅ 没有自定义异常类,代码量减少 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
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGc...",
"refreshToken": "a1b2c3d4...",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

这种设计是正确的,原因如下:

  1. 前后端分离友好:前端域名和后端域名不同时,Cookie 跨域配置复杂
  2. 多端统一:Web、App、小程序都能用同一套接口
  3. 灵活性高:前端可以根据自己的技术栈选择存储方式

2. 前端存储策略建议

虽然我们是后端教学,但需要给前端开发者明确的对接指南。

推荐方案

Token 类型存储位置生命周期安全措施
Access Token内存变量页面刷新会丢失短有效期(15 分钟)
Refresh TokenlocalStorage持久化存储Token 轮换 + 黑名单

为什么 Refresh Token 可以放 localStorage?

很多文章说 “localStorage 不安全,容易被 XSS 攻击窃取”。但实际上:

  1. XSS 攻击下,HttpOnly Cookie 也不安全:攻击者可以直接用你的身份发请求,不需要窃取 Token
  2. 真正的安全措施是防止 XSS:CSP(内容安全策略)、输入过滤、输出转义
  3. Token 轮换机制:即使 Refresh Token 被窃取,攻击者使用一次后,真实用户的 Token 就失效了,会立即发现异常

前端伪代码示例(仅供参考,不展开讲解):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 登录成功后
const { accessToken, refreshToken } = response.data;
window.__ACCESS_TOKEN__ = accessToken; // 存内存
localStorage.setItem('refresh_token', refreshToken); // 持久化

// 请求拦截器
axios.interceptors.request.use(config => {
config.headers['Authorization'] = `Bearer ${window.__ACCESS_TOKEN__}`;
return config;
});

// 响应拦截器(401 自动刷新)
axios.interceptors.response.use(null, async error => {
if (error.response?.status === 401 && error.response?.data?.error === 'TOKEN_EXPIRED') {
const newToken = await refreshToken();
window.__ACCESS_TOKEN__ = newToken.accessToken;
localStorage.setItem('refresh_token', newToken.refreshToken);
return axios(error.config); // 重试原请求
}
});

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_EXPIREDToken 已过期自动调用 /auth/refresh 刷新 Token
TOKEN_INVALID签名验证失败清除本地 Token,强制跳转登录页
TOKEN_MALFORMEDToken 格式错误清除本地 Token,强制跳转登录页
TOKEN_REVOKEDToken 已被撤销提示 “您已退出登录”,跳转登录页

前端处理流程图

1
2
3
4
5
6
7
8
收到 401 响应

检查 error 字段

TOKEN_EXPIRED → 自动刷新 Token → 重试原请求
TOKEN_INVALID → 清除 Token → 跳转登录页
TOKEN_MALFORMED → 清除 Token → 跳转登录页
TOKEN_REVOKED → 提示用户 → 跳转登录页

6. 本节小结

我们明确了前后端的对接规范。

核心要点

要点说明
Token 返回方式双 Token 全部通过 JSON 返回
Access Token 存储内存变量(页面刷新会丢失)
Refresh Token 存储localStorage(持久化存储)
安全措施Token 轮换 + 黑名单 + 短有效期 + HTTPS
错误处理根据 error 字段判断错误类型

前端对接核心逻辑

1
2
3
4
5
6
登录 → 保存双 Token
请求 → 自动注入 Access Token
401 → 根据 error 字段判断 → 自动刷新或跳转登录
刷新成功 → 更新 Token → 重试请求
刷新失败 → 清除 Token → 跳转登录
注销 → 调用后端 → 清除本地 Token

安全措施汇总

安全措施作用实现位置
短有效期Access Token 只有 15 分钟JwtProperties
Token 轮换每次刷新都生成新 TokenTokenService.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
auth-parent/
├── auth-common/ # 公共基础模块
│ └── src/main/java/.../common/
│ ├── model/
│ │ ├── Result.java # 统一响应
│ │ └── ErrorResponse.java # 错误响应
│ └── util/
│ └── SnowflakeIdGenerator.java

├── auth-core/ # 认证核心模块
│ └── src/main/java/.../core/
│ ├── config/properties/
│ │ └── JwtProperties.java
│ ├── constant/
│ │ └── RedisKeyConstants.java
│ ├── util/
│ │ ├── JwtUtil.java
│ │ └── crypto/RsaKeyManager.java
│ ├── service/
│ │ ├── TokenService.java
│ │ └── TokenStoreService.java
│ └── model/
│ └── AuthToken.java

└── auth-web/ # Web 应用模块
└── src/main/java/.../web/
├── AuthApplication.java
├── controller/
│ └── AuthController.java
└── handler/
└── GlobalExceptionHandler.java

方法速查汇总

层级类名核心方法作用
工具层JwtUtilcreateToken生成 Token
工具层JwtUtilparseToken解析 Token
工具层JwtUtilgetUserIdFromToken提取用户 ID
工具层JwtUtilgetJtiFromToken提取 JTI
存储层TokenStoreServicestoreRefreshToken存储刷新令牌到 Redis
存储层TokenStoreServicegetUserIdByRefreshToken根据刷新令牌获取用户 ID
存储层TokenStoreServicedeleteRefreshToken删除刷新令牌
存储层TokenStoreServiceaddToBlacklist将 Token 加入黑名单
存储层TokenStoreServiceisBlacklisted检查 Token 是否被拉黑
业务层TokenServicecreateToken创建单令牌
业务层TokenServicecreateTokenPair创建双令牌
业务层TokenServicerefreshToken刷新令牌
业务层TokenServicelogout注销登录
业务层TokenServicevalidateToken验证令牌
业务层TokenServicevalidateAndExtract验证并提取用户信息
控制层AuthController/auth/login双令牌登录接口
控制层AuthController/auth/refresh刷新令牌接口
控制层AuthController/auth/logout注销登录接口
控制层AuthController/auth/validate验证令牌接口

核心避坑指南

陷阱一:时钟偏差导致刚签发的 Token 验证失败

在分布式环境下,认证服务器和网关服务器时间可能有微小差异。

对策:在 Token 解析时设置 clockSkewSeconds 容忍度:

1
2
3
4
5
6
Claims claims = Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(30) // 容忍 30 秒偏差
.build()
.parseSignedClaims(token)
.getPayload();

陷阱二:黑名单 TTL 固定导致内存浪费

如果 Token 只剩 3 分钟就过期,黑名单 Key 却设置 15 分钟 TTL,会导致 Redis 中存在 12 分钟的无效数据。

对策:TTL 设置为 Token 的剩余有效期:

1
2
3
4
long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;
if (remainingSeconds > 0) {
redisTemplate.opsForValue().set(blacklistKey, "BLOCKED", remainingSeconds, TimeUnit.SECONDS);
}

陷阱三:Pipeline 中的命令不是原子性的

Pipeline 只是批量操作,不保证原子性。如果需要原子性,应该使用 Lua 脚本或 Redis 事务。


🎉 恭喜你完成了 JWT 认证内核的构建!

现在你已经掌握了:

  • ✅ 多模块架构的搭建
  • ✅ 统一响应格式的封装
  • ✅ RSA 非对称加密的实战应用
  • ✅ JWT 三段式结构与 JJWT 0.12 的使用
  • ✅ StringRedisTemplate 的完整语法
  • ✅ 双令牌机制的完整实现
  • ✅ Redis 状态管理与 Pipeline 优化
  • ✅ 主动注销与黑名单机制
  • ✅ 直接复用 JJWT 异常体系

这套系统不依赖任何第三方认证框架,让你真正掌握了认证的底层原理。在下一章中,我们将基于这个内核,实现策略模式的认证工厂,支持多种登录方式的扩展。