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

SpringBoot3 登录注册系列(一) - 多模块架构与统一响应封装

第一章. 为什么需要多模块架构

在开始构建认证系统之前,我们先解决一个基础问题:为什么不能把所有代码都塞在一个模块里?

假设我们现在有一个单模块项目,所有代码都在 src/main/java/com/example/auth 下。这种结构在项目初期看起来很简洁,但随着业务增长,会遇到三个致命问题。

1.1. 单模块的三大困境

困境一:职责不清

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

困境二:依赖混乱

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

困境三:测试困难

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

1.2. 多模块架构设计

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
auth/                          # 根聚合器
├── pom.xml # 父POM(统一管理依赖版本)
├── auth-common/ # 公共基础模块
│ └── src/main/java/.../common/
│ ├── model/Result.java
│ └── util/SnowflakeIdGenerator.java
├── auth-core/ # 认证核心模块
│ └── src/main/java/.../core/
│ ├── util/JwtUtil.java
│ └── service/TokenService.java
└── auth-web/ # Web应用模块
└── src/main/java/.../web/
├── AuthApplication.java
└── controller/AuthController.java

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

核心设计:聚合器和父 POM 分离,根目录只声明模块,auth-parent 只管理依赖版本。


第二章. 核心配置文件

2.1. 根 POM 配置(聚合器 + 父工程)

根目录的 pom.xml 同时负责两个职责:

  1. 聚合器(Aggregator):通过 <modules> 声明包含哪些子模块
  2. 父工程(Parent):通过 <dependencyManagement> 统一管理依赖版本

📄 文件路径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
89
90
91
92
93
94
95
96
97
98
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
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>Auth System Multi-Module Project</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.4.2</spring-boot.version>
<jjwt.version>0.12.5</jjwt.version>
<hutool.version>5.8.34</hutool.version>
<lombok.version>1.18.30</lombok.version>
</properties>

<modules>
<module>auth-common</module>
<module>auth-core</module>
<module>auth-web</module>
</modules>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>

<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>

<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>UTF-8</encoding>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

标准实践:在 99% 的 Maven 多模块项目中,根目录既是聚合器也是父工程。这样可以:

  • 减少目录层级,结构更清晰
  • 避免维护多余的 POM 文件
  • 简化子模块的 <relativePath> 配置

2.2. 子模块配置

每个子模块都需要指定父 POM 为根目录的 auth,并声明自己的依赖。

auth-common 模块

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

<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</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>auth-common</artifactId>

<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
</dependencies>
</project>

子模块的 <relativePath> 指向 ../pom.xml,即父目录的 POM 文件。这样 Maven 可以正确找到父工程。

auth-core 模块

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

<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</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>auth-core</artifactId>

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

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
</project>

auth-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
36
37
38
39
40
41
42
43
44
45
46
47

<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</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>auth-web</artifactId>

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

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>


第三章. 统一响应封装

3.1. 为什么需要统一响应

假设我们有两个接口,一个返回 Token 对象,一个返回 User 对象。前端收到的响应格式完全不同:

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

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

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

我们需要定义一个统一的响应格式,所有接口都返回这个结构:

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

3.2. Result 泛型类设计

我们使用泛型 Result<T> 来封装响应,其中 T 可以是任何类型的数据。

📄 文件路径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
package com.example.auth.common.model;

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

import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> implements Serializable {
// 响应码(200 表示成功)
private Integer code;
// 响应消息
private String message;
// 响应数据(泛型)
private T data;

// 成功响应(带数据)
public static <T> Result<T> ok(T data) {
return new Result<>(200, "操作成功", data);
}

// 成功响应(不带数据)
public static <T> Result<T> ok() {
return new Result<>(200, "操作成功", null);
}

// 失败响应
public static <T> Result<T> fail(Integer code, String message) {
return new Result<>(code, message, null);
}

// 失败响应(默认 500 错误码)
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

@PostMapping("/login")
public Result<AuthToken> login() {
AuthToken token = new AuthToken("token123", "refresh456");
return Result.ok(token);
}

@GetMapping("/user")
public Result<User> getUser() {
User user = new User(1001L, "admin");
return Result.ok(user);
}

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

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "token123",
"refreshToken": "refresh456"
}
}

3.3. 雪花算法工具类

在后续章节中,我们需要生成全局唯一的用户 ID。我们使用 Hutool 提供的雪花算法生成 19 位 Long 类型的趋势递增 ID来实现。

📄 文件路径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
package com.example.auth.common.util;

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

public class SnowflakeIdGenerator {

// workerId = 1, datacenterId = 1
private static final Snowflake SNOWFLAKE = IdUtil.getSnowflake(1, 1);

public static Long nextId() {
return SNOWFLAKE.nextId();
}
}

第四章. 本章小结

我们完成了标准 Maven 多模块架构的搭建,并封装了统一响应格式。

核心成果

模块职责依赖关系
auth (根 POM)聚合器 + 父工程
auth-common公共基础模块继承 auth
auth-core认证核心模块继承 auth,依赖 auth-common
auth-webWeb 应用模块继承 auth,依赖 auth-core

架构优势

  1. 职责分离:每个模块有明确的职责,易于维护
  2. 可复用性:auth-common 可以被其他项目复用
  3. 易于测试:auth-core 可以独立测试,不依赖 Web 层
  4. 版本统一:所有依赖版本在根 POM 中统一管理
  5. 结构清晰:采用标准的 Maven 多模块结构,减少了多余的目录层级

核心类速查

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

SpringBoot3 登录注册系列(二) - RSA 非对称加密实战

第一章. 为什么选择 RS256

在开始生成密钥之前,我们需要先回答一个核心问题:JWT 的签名算法应该选择 HS256 还是 RS256?

1.1. HS256 的架构困境

HS256 是对称加密签名和验证使用同一个密钥算法。它的工作流程是:

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

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

风险一:攻击面扩大

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

风险二:密钥轮换困难

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

1.2. RS256 的架构优势

RS256 是非对称加密使用私钥签名,公钥验证算法。它的工作流程是:

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

在这种架构下:

  • 认证服务 持有私钥,负责签发 Token。私钥是整个系统中唯一的敏感数据,只需要保护一个点。
  • 网关和业务服务 持有公钥,负责验证 Token。公钥可以公开分发,即使泄漏也不会影响安全性(攻击者无法用公钥伪造签名)。

这种设计的核心价值在于 职责分离:只有认证服务才有 “印钞权”,其他服务只能 “验钞” 而不能 “造钞”。

1.3. 架构对比

对比维度HS256(对称加密)RS256(非对称加密)
密钥数量1 个(签名和验证共用)2 个(私钥签名,公钥验证)
密钥分发所有服务都需要持有密钥只有认证服务持有私钥
安全风险任何一个服务泄漏,全局沦陷只有认证服务泄漏才会沦陷
密钥轮换需要同时更新所有服务只需要更新认证服务
性能快(对称加密)稍慢(非对称加密)

在微服务架构下,我们选择 RS256,用稍微的性能损失换取更高的安全性和更灵活的架构。


第二章. 使用 OpenSSL 生成 RSA 密钥对

2.1. 密钥格式要求

Java 的 KeyFactory 对密钥格式有严格要求:

  • 私钥必须是 PKCS#8 格式
  • 公钥必须是 X.509 格式

如果格式不对,会抛出 InvalidKeySpecException 异常。

2.2. 生成 PKCS#8 格式的私钥

打开终端(Windows 用户可以使用 Git Bash),输入以下命令:

注意,openssl 通常在 git 窗口上才可以执行,请使用 git bash 执行此命令

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:输出文件名
  • -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 格式。

2.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:输出文件名

执行后,你会看到:

1
writing RSA key

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

1
2
3
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtkR...
-----END PUBLIC KEY-----

2.4. 将密钥放入项目

auth-web 模块中,找到 src/main/resources 目录,创建 certs 文件夹:

1
2
3
4
auth-web/src/main/resources/
└── certs/
├── private_key.pem
└── public_key.pem

将刚才生成的两个文件复制到这个目录下。

安全提示:必须在 .gitignore 中添加 **/certs/private_key.pem,防止私钥被提交到 Git 仓库。


第三章. 封装 RsaKeyManager 工具类

3.1. 为什么需要转换

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

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

3.2. RsaKeyManager 核心代码

📄 文件路径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
package com.example.auth.core.util.crypto;


import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.FileCopyUtils;

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;

@Component
public class RsaKeyManager {

// 从 resources 目录读取文件
public String readResourceFile(String resourcePath) throws Exception {
ClassPathResource resource = new ClassPathResource(resourcePath);
try (InputStreamReader reader = new InputStreamReader(
resource.getInputStream(), StandardCharsets.UTF_8)) {
return FileCopyUtils.copyToString(reader);
}
}

// 加载私钥
public PrivateKey loadPrivateKey(String privateKeyPem) throws Exception {
// 清理 PEM 格式的头尾标记
String content = privateKeyPem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
.replace("-----END RSA PRIVATE KEY-----", "")
.replaceAll("\\s", "");

// Base64 解码
byte[] keyBytes = Base64.getDecoder().decode(content);

// 使用 PKCS8 规范解析私钥
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}

// 加载公钥
public PublicKey loadPublicKey(String publicKeyPem) throws Exception {
String content = publicKeyPem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");

byte[] keyBytes = Base64.getDecoder().decode(content);

// 使用 X509 规范解析公钥
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(keySpec);
}
}

3.3. 关键知识点

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

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

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

Java 的 KeyFactory 只能识别纯粹的 Base64 编码数据,不能识别这些标记,所以必须先去除。

PKCS8 和 X509 是什么?

  • PKCS8:私钥的存储格式标准(Public Key Cryptography Standards #8)
  • X509:公钥证书的标准格式

这两个标准定义了密钥在二进制层面的存储结构。

为什么用 ClassPathResource 而不是 File?

当项目打包成 jar 包后,resources 目录下的文件会被打包到 jar 包内部。此时:

  • new File("certs/public_key.pem") 会失败
  • new ClassPathResource("certs/public_key.pem") 可以正确读取

第四章. 本章小结

我们完成了 RSA 密钥对的生成与加载。

核心成果

步骤操作产出
1使用 OpenSSL 生成私钥private_key.pem(PKCS#8 格式)
2从私钥中提取公钥public_key.pem(X.509 格式)
3将密钥放入项目auth-web/src/main/resources/certs/
4封装工具类RsaKeyManager

方法速查

类名方法名作用
RsaKeyManagerreadResourceFile(String path)读取 resources 目录下的文件
RsaKeyManagerloadPrivateKey(String pem)加载私钥
RsaKeyManagerloadPublicKey(String pem)加载公钥

架构对比

对比维度HS256RS256
密钥数量1 个2 个(私钥+公钥)
密钥分发所有服务都需要只有认证服务持有私钥
安全风险任何服务泄漏,全局沦陷只有认证服务泄漏才沦陷

SpringBoot3 登录注册系列(三) - JJWT 0.12 核心 API 详解

第一章. JWT 三段式结构

在开始编写代码之前,我们需要先理解:JWT 到底是什么?它是如何工作的?

1.1. 电影票的类比

假设你现在去电影院看电影。你在售票处买票后,工作人员会给你一张电影票。这张票上印着:

  • 电影名称(如《流浪地球 3》)
  • 场次时间(如 2025-12-27 20:00)
  • 座位号(如 5 排 8 座)
  • 一个防伪水印(防止别人伪造票)

当你进入影厅时,检票员只需要看一眼票上的防伪水印,就知道这张票是真的,不需要回到售票处再次确认。

JWT 的工作原理和这张电影票非常相似:

  • 电影票 = JWT Token
  • 票上的信息(电影名称、场次、座位)= JWT 的 Payload(载荷)
  • 防伪水印 = JWT 的 Signature(签名)
  • 检票员 = 业务服务(验证 Token)

1.2. 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 编码一种 URL 安全的 Base64 编码方式后,就是 Token 的第一段。

第二部分:Payload(载荷)

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

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

第三部分:Signature(签名)

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

签名的作用是 防止 Token 被篡改。如果有人修改了 Payload 中的 userId,签名验证就会失败。


第二章. JJWT 0.12 vs 旧版本

2.1. 为什么要强调版本

JJWT 0.12 引入了 类型安全 的设计理念,API 发生了重大变化。如果你直接照搬网上的旧教程,代码很可能会报错。

旧版本(0.9.x)的写法

1
2
3
4
5
6
7
8
// ❌ 旧版本写法(存在隐患)
Jwts.builder()
.

signWith(SignatureAlgorithm.HS256, rsaPrivateKey) // 运行时才会报错!
.

compact();

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

新版本(0.12.x)的写法

1
2
3
4
5
6
7
8
// ✅ 新版本写法(类型安全)
Jwts.builder()
.

signWith(rsaPrivateKey, Jwts.SIG.RS256) // 编译期检查
.

compact();

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

2.2. 核心 API 对比

对比维度旧版本(0.9.x)新版本(0.12.x)
签名方式signWith(Algorithm, Key)signWith(Key, Algorithm)
类型安全❌ 运行时才报错✅ 编译期检查
解析器构建直接使用必须调用 build()
算法常量SignatureAlgorithm.RS256Jwts.SIG.RS256

第三章. 封装 JwtUtil 工具类

3.1. 配置类设计

首先,我们需要定义一个配置类来承载 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
package com.example.auth.core.config.properties;

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

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

/**
* 签发者标识 (Issuer)
*/
private String issuer = "auth-service";

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

/**
* 时钟偏差容忍度(秒)
*/
private Long clockSkewSeconds = 30L;

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

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

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

1
2
3
4
5
6
jwt:
issuer: pro-auth-service
access-token-expire-minutes: 15
clock-skew-seconds: 30
public-key-resource: certs/public_key.pem
private-key-resource: certs/private_key.pem

3.2. 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
package com.example.auth.core.util;

import cn.hutool.core.util.IdUtil;
import com.example.auth.common.util.crypto.RsaKeyManager;
import com.example.auth.core.config.properties.JwtProperties;
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;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {

private final JwtProperties jwtProperties;
private final RsaKeyManager rsaKeyManager;

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


/**
* 初始化阶段:加载密钥
*/
@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 密钥加载完成。");
}

/**
* 创建 Token
* @param userId 用户 ID
* @param username 用户名
* @param extraClaims 额外的自定义声明
* @return 生成的 JWT 字符串
*/
public String createToken(Long userId, String username, Map<String, Object> extraClaims) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);

// 计算过期时间
Date expiration = new Date(nowMillis + jwtProperties.getAccessTokenExpireMinutes() * 60 * 1000L);
// 生成 JTI(用于后续的黑名单功能)
String jti = IdUtil.fastSimpleUUID();
JwtBuilder builder = Jwts.builder()
.header().type("JWT").and() // 设置头部
.issuer(jwtProperties.getIssuer()) // 签发者
.subject(userId.toString()) // 主题(用户 ID)
.issuedAt(now) // 签发时间
.expiration(expiration) // 过期时间
.id(jti) // JWT ID
.claim("username", username) // 自定义声明:用户名
.claim("userId", userId); // 自定义声明:用户 ID
// 添加额外的载荷
if (extraClaims != null && !extraClaims.isEmpty()) {
builder.claims().add(extraClaims);
}
// 使用 RSA 私钥 + RS256 算法签名
return builder.signWith(privateKey, Jwts.SIG.RS256).compact();
}

/**
* 解析并验证 Token
* @param token JWT 字符串
* @return 解析后的 Claims 对象
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(jwtProperties.getClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
}

/**
* 快速验证方法
* @param token JWT 字符串
* @return boolean 验证结果
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("Token验证失败: {}", e.getMessage());
return false;
}
}

/**
* 从 Token 中提取用户 ID
* @param token JWT 字符串
* @return 用户 ID
*/
public Long getUserIdFromToken(String token) {
try {
Claims claims = parseToken(token);
return Long.parseLong(claims.getSubject());
} catch (Exception e) {
log.warn("从Token中提取用户ID失败: {}", e.getMessage());
return null;
}
}

/**
* 从 Token 中提取用户名
* @param token JWT 字符串
* @return 用户名
*/
public String getUsernameFromToken(String token) {
try {
Claims claims = parseToken(token);
return claims.get("username", String.class);
} catch (Exception e) {
log.warn("从Token中提取用户名失败: {}", e.getMessage());
return null;
}
}

/**
* 从 Token 中提取 JTI
* @param token JWT 字符串
* @return JTI
*/
public String getJtiFromToken(String token) {
try {
Claims claims = parseToken(token);
return claims.getId();
} catch (Exception e) {
log.warn("从Token中提取JTI失败: {}", e.getMessage());
return null;
}
}
}

3.3. 关键设计细节

时钟偏差(Clock Skew)的必要性

在代码中我们设置了 .clockSkewSeconds(30)。这是一个非常重要的生产环境细节。

在微服务架构中,认证服务和网关服务可能部署在不同的物理机上,时间可能存在微小的偏差。如果认证服务时间快了 1 秒,它签发的
Token 对于网关来说就是 “未来 1 秒才生效” 的,会导致 PrematureJwtException。设置容忍度可以完美解决这个问题。

JTI(JWT ID)的战略意义

我们在创建 Token 时使用 Hutool 的 IdUtil.fastSimpleUUID() 生成了 JTI 并赋值给 .id(jti)。这不仅仅是一个随机数,它是后续实现
Token 黑名单(Token Revocation)的基础。当用户注销时,我们只需将这个 JTI 放入 Redis,即可让未过期的 Token 提前失效。

@PostConstruct 的性能优化

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

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


第四章. 本章小结

我们完成了 JWT 工具类的构建,掌握了 JJWT 0.12 的核心 API。

核心成果

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

方法速查

类名方法名作用
JwtUtilcreateToken(userId, username, extraClaims)生成 Token
JwtUtilparseToken(token)解析 Token
JwtUtilvalidateToken(token)验证 Token
JwtUtilgetUserIdFromToken(token)提取用户 ID
JwtUtilgetUsernameFromToken(token)提取用户名
JwtUtilgetJtiFromToken(token)提取 JTI

JJWT 0.12 vs 旧版本

对比维度旧版本(0.9.x)新版本(0.12.x)
签名方式signWith(Algorithm, Key)signWith(Key, Algorithm)
类型安全❌ 运行时才报错✅ 编译期检查
解析器构建直接使用必须调用 build()

SpringBoot3 登录注册系列(四) - 双令牌机制与 Redis 状态管理

第一章. 单令牌的困境

在上一篇中,我们封装了 JWT 工具类。现在我们来实现一个最简单的登录系统,然后暴露它的问题。

1.1. 单令牌登录实现

首先,我们创建一个简单的 TokenService 来管理令牌。

📄 文件路径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
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;

@Slf4j
@Service
@RequiredArgsConstructor
public class TokenService {

private final JwtUtil jwtUtil;

/**
* 创建令牌(单令牌版本)
*/
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;
}

/**
* 验证令牌是否有效
*/
public boolean validateToken(String token) {
return jwtUtil.validateToken(token);
}
}

然后创建 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
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;

/**
* 单令牌登录接口
*/
@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);
}

/**
* 验证令牌接口
*/
@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);
}
}

创建启动类。

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

1
2
3
4
5
6
7
8
9
10
11
12
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);
}
}

配置文件。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8080

spring:
application:
name: auth-service

jwt:
issuer: pro-auth-service
access-token-expire-minutes: 15
clock-skew-seconds: 30
public-key-resource: certs/public_key.pem
private-key-resource: certs/private_key.pem

1.2. 接口验证与测试

代码编写完成后,我们需要验证以下两个核心流程:

  1. 登录获取 Token:确保能根据用户信息生成合法的 JWT。
  2. 携带 Token 访问:确保服务端能正确解析并验证 Token 的有效性。

⚠ 前置准备:请确保你的 resources/certs 目录下已经存在上一章生成的公私钥文件(private_key.pempublic_key.pem
),否则启动会报错。

1. 启动服务

运行 AuthApplicationmain 方法,观察控制台日志,确保没有报错,且端口运行在 8080

2. 测试登录(获取 Token)

使用 Postman 或终端 Curl 发起登录请求。

  • 请求方式:POST
  • URLhttp://localhost:8080/auth/login
  • 参数userId=1001, username=admin

Curl 命令示例:

1
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

预期响应结果:

1
2
3
4
5
6
7
8
9
{
"code": 200,
"msg": "success",
"data": {
"tokenType": "Bearer",
"token": "eyJhGciOiJ...<省略的长字符串>...W8i9s"
}
}

3. 测试验证(校验 Token)

复制上一步获取的 token 值,添加到请求头的 Authorization 字段中。

  • 请求方式:GET
  • URLhttp://localhost:8080/auth/validate
  • HeaderAuthorization: Bearer <你的Token>

Curl 命令示例:

1
2
3
# 请将 <YOUR_TOKEN> 替换为实际获取的 Token
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <YOUR_TOKEN>"

预期响应结果:

1
2
3
4
5
6
7
{
"code": 200,
"msg": "success",
"data": {
"valid": true
}
}

1.3. 暴露问题:单令牌的“死局”

至此,我们的单令牌登录功能看似完美运行。但是,请尝试修改 application.yml,将过期时间改得极短(例如 1 分钟):

1
2
jwt:
access-token-expire-minutes: 1 # 测试用,改为1分钟

重启服务后,我们会发现一个严重的用户体验问题:

  1. 用户登录,获得 Token。
  2. 用户正常浏览页面(耗时 1 分钟)。
  3. 第 61 秒,用户点击某个按钮,前端携带 Token 请求。
  4. 服务端返回 401 或验证失败,因为 Token 已过期。
  5. 后果:用户被强制登出,必须重新输入密码登录。

这就是 单令牌模式的死局

  • 有效期设置太长(如 7 天):Token 一旦泄露,黑客有 7 天时间为所欲为,服务端无法主动注销(除非引入黑名单,但这增加了复杂度和查库成本)。
  • 有效期设置太短(如 15 分钟):安全性高了,但用户每隔 15 分钟就要重新登录一次,体验极其糟糕。

为了解决这个“安全”与“体验”的矛盾,我们需要引入 双令牌机制(Access Token + Refresh Token)


1.2. 单令牌的两大致命问题

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

问题一:无法主动注销

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

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

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

问题二:有效期困境

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

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

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


第二章. 双令牌机制设计

2.1. 设计理念

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

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

这就像你去银行办业务:

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

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

2.2. 架构流程

mermaid-diagram-2026-01-27-153849

2.3. 为什么需要 Redis

你可能会问:我们不是说好了 JWT 是无状态的吗?为什么还需要 Redis?

确实,JWT 的设计理念是无状态。但在实际业务中,我们需要一些 “状态管理” 的能力:

  • 主动注销:当用户点击 “退出登录” 时,我们需要立即让 Token 失效
  • Token 刷新:当 Access Token 过期时,我们需要验证 Refresh Token 是否有效
  • Token 轮换:每次刷新都生成新的 Refresh Token,旧的立即失效(检测盗用)

这些功能都需要服务器 “记住” 一些状态。而 Redis 就是最适合存储这些临时状态的中间件。

为什么选择 Redis 而不是 MySQL?

对比维度MySQLRedis
读写速度毫秒级(磁盘 IO)微秒级(内存操作)
过期机制需要定时任务清理原生支持 TTL 自动清理
数据结构只有表(Table)支持 String、Set、ZSet 等
适用场景持久化存储临时状态存储

第三章. Redis 状态管理

3.1. Redis 数据结构选型

Redis 支持五种数据结构,我们在认证系统中主要会用到:

  • String:存储 Refresh Token、黑名单标记
  • Set:存储用户的在线设备列表
  • ZSet:存储用户的在线设备列表(按登录时间排序,用于多设备限制)

3.2. StringRedisTemplate 核心语法

Spring 提供了 StringRedisTemplate 来操作 Redis。它的 API 设计非常直观:先选择数据结构,再执行操作

核心方法

  • opsForValue():操作 String
  • opsForSet():操作 Set
  • opsForZSet():操作 ZSet

String 操作(opsForValue)

String 是最常用的数据结构,用于存储简单的键值对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 存储 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");

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

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

expire("auth:login:fail:admin",15,TimeUnit.MINUTES);
}

常用方法速查

方法作用示例
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")
expire(key, timeout, unit)设置过期时间expire("token:123", 10, TimeUnit.MINUTES)

Set 操作(opsForSet)

Set 是无序不重复集合,用于存储不需要排序的元素列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 记录用户的在线设备
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");

常用方法速查

方法作用示例
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")

Pipeline 批量操作

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

问题场景

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 第 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 天(秒)
);

return null;
});

Pipeline 中的命令是顺序执行的,不是原子性的。如果需要原子性,应该使用 Lua 脚本或 Redis 事务。

3.3. Redis Key 命名规范

为了防止 Key 冲突,我们需要定义统一的 Key 命名规范。

命名规范:项目:模块:业务:主键

层级说明示例
项目项目名称auth(认证系统)
模块模块名称token(令牌模块)
业务业务类型refresh(刷新令牌)
主键业务主键a1b2c3d4(refreshToken)

完整示例

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

📄 文件路径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
package com.example.auth.core.constant;

public class RedisKeyConstants {

/**
* 刷新令牌映射 Key 前缀
*/
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";

/**
* 用户在线设备索引 Key 前缀
*/
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";

/**
* 构建刷新令牌 Key
*/
public static String buildRefreshTokenKey(String refreshToken) {
return TOKEN_REFRESH_PREFIX + refreshToken;
}

/**
* 构建用户在线设备 Key
*/
public static String buildUserOnlineKey(Long userId) {
return USER_ONLINE_TOKENS_PREFIX + userId;
}
}

第四章. 双令牌机制实现

4.1. 定义 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
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;

/**
* 刷新令牌(长效,随机串)
*/
private String refreshToken;

/**
* Access Token 剩余有效期(秒)
*/
private Long expiresIn;

/**
* 令牌类型
*/
@Builder.Default
private String tokenType = "Bearer";
}

4.2. 扩展 JwtProperties

在原有的 JwtProperties 类中,追加 Refresh Token 的配置。

📄 文件路径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
package com.example.auth.core.config.properties;

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

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

// ...之前的内容

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

更新配置文件。

📄 文件路径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
19
20
21
22
23
24
25
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

jwt:
issuer: pro-auth-service
access-token-expire-minutes: 15
refresh-token-expire-days: 7
clock-skew-seconds: 30
public-key-resource: certs/public_key.pem
private-key-resource: certs/private_key.pem

4.3. 分层架构设计(重构版)

在实现双令牌机制之前,我们先来解决一个架构问题。如果把所有逻辑都堆在 Service 层,会导致:

  • 职责不清TokenServiceTokenStoreServiceTokenBlacklistService 看起来是平级的,但实际上有些是底层基础设施,有些是上层业务逻辑
  • Controller 保姆化:Controller 需要注入多个 Service,知道了太多细节
  • 依赖混乱:像蜘蛛网一样的网状依赖

我们采用 三层架构 来解决这个问题:

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
┌─────────────────────────────────────────────────────────────┐
│ Controller 层 │
│ 只注入一个 AuthService │
└─────────────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Facade 层 (AuthService) │
│ "大管家",负责编排所有业务流程 │
└─────────────────────────┬───────────────────────────────────┘

┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌───────────┐ ┌─────────────────────┐
│ JwtUtil │ │ │ │ │
│ (纯算法工具) │ │ │ │ │
└─────────────────┘ │ │ │ │
│ Manager │ │ Manager │
┌─────────────────┐ │ 层 │ │ 层 │
│ JwtProperties │ │ │ │ │
│ (配置类) │ │ │ │ │
└─────────────────┘ └───────────┘ └─────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌─────────────────────┐
│TokenRedisManager│ │BlacklistRedisManager│
│ (Token存取) │ │ (黑名单存取) │
└───────────────┘ └─────────────────────┘

层级职责

层级类名职责特点
ControllerAuthController接收请求,返回响应只注入 AuthService
FacadeAuthService编排业务流程“大管家”,统一入口
UtilJwtUtilToken 生成/解析纯算法,无 IO
ManagerTokenRedisManagerToken 的 Redis 存取只有 CRUD,无业务逻辑
ManagerBlacklistRedisManager黑名单的 Redis 存取只有 CRUD,无业务逻辑

心态转变:Manager 不是 Service,它们是"底层苦力",只有存取方法,没有复杂的 if-else。

4.4. 封装 TokenRedisManager(基础设施层)

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

📄 文件路径auth-core/src/main/java/com/example/auth/core/manager/TokenRedisManager.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
package com.example.auth.core.manager;

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

import java.util.Set;

/**
* Token Redis 管理器(基础设施层)
* <p>
* 职责:只负责 Token 在 Redis 中的 CRUD 操作,不包含业务逻辑判断。
* 这是一个"底层苦力",只有存取方法,没有复杂的 if-else。
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenRedisManager {

private final StringRedisTemplate redisTemplate;

/**
* 存储 Refresh Token(使用 Pipeline 批量操作)
*/
public void saveRefreshToken(Long userId, String refreshToken,
String jti, long expireSeconds) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {

// 1. 存储 Refresh Token (K = refreshToken, V = userId)
String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
connection.stringCommands().setEx(
refreshKey.getBytes(),
expireSeconds,
userId.toString().getBytes()
);

// 2. 记录用户的在线设备
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
connection.setCommands().sAdd(
userKey.getBytes(),
jti.getBytes()
);

// 3. 给设备记录也设置过期时间
connection.keyCommands().expire(
userKey.getBytes(),
expireSeconds
);

return null;
});

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

/**
* 根据 Refresh Token 获取用户 ID
*/
public Long getUserIdByRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
String userId = redisTemplate.opsForValue().get(key);
return userId == null ? null : Long.parseLong(userId);
}

/**
* 删除 Refresh Token
*/
public void deleteRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
redisTemplate.delete(key);
log.debug("删除 Refresh Token: {}", refreshToken);
}

/**
* 获取用户的所有在线设备(JTI 列表)
*/
public Set<String> getUserOnlineDevices(Long userId) {
String key = RedisKeyConstants.buildUserOnlineKey(userId);
return redisTemplate.opsForSet().members(key);
}

/**
* 从用户在线设备列表中移除指定设备
*/
public void removeUserDevice(Long userId, String jti) {
String key = RedisKeyConstants.buildUserOnlineKey(userId);
redisTemplate.opsForSet().remove(key, jti);
log.debug("移除用户设备: userId={}, jti={}", userId, jti);
}

/**
* 清空用户的所有在线设备
*/
public void clearUserDevices(Long userId) {
String key = RedisKeyConstants.buildUserOnlineKey(userId);
redisTemplate.delete(key);
log.debug("清空用户所有设备: userId={}", userId);
}
}

4.5. 封装 AuthService(Facade 层)

现在我们创建"大管家" AuthService,负责编排所有认证相关的业务流程。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/AuthService.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
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.manager.TokenRedisManager;
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;

/**
* 认证服务(Facade 层 / 应用服务层)
* <p>
* 职责:作为"大管家",负责编排所有认证相关的业务流程。
* Controller 只需要注入这一个 Service,不需要关心底层的实现细节。
* </p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final JwtUtil jwtUtil;
private final JwtProperties jwtProperties;
private final TokenRedisManager tokenRedisManager;

/**
* 登录:创建双令牌
*/
public AuthToken login(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
String jti = jwtUtil.getJtiFromToken(accessToken);

// 3. 生成 Refresh Token(随机字符串)
String refreshToken = IdUtil.fastSimpleUUID();

// 4. 计算 Refresh Token 的过期时间(秒)
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;

// 5. 将 Refresh Token 存入 Redis - IO 操作
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds);

log.info("用户登录成功: userId={}, jti={}", userId, jti);

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

/**
* 刷新令牌:使用 Refresh Token 换取新的令牌对
*/
public AuthToken refresh(String oldRefreshToken) {
log.info("刷新令牌请求");

// 1. 验证 Refresh Token 并获取用户 ID
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);

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

// 2. 查询用户信息(实际业务中应该从数据库查询)
String username = "User-" + userId;

// 3. 令牌轮换 - 生成一对全新的令牌
AuthToken newTokenPair = login(userId, username);

// 4. 立即销毁旧的 Refresh Token(防止重放攻击)
tokenRedisManager.deleteRefreshToken(oldRefreshToken);

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

/**
* 快速验证令牌
*/
public boolean isTokenValid(String token) {
return jwtUtil.validateToken(token);
}
}

4.6. 升级 AuthController(极简版)

现在我们升级 AuthController,它只需要注入一个 AuthService

📄 文件路径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
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.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

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

/**
* 认证控制器
* <p>
* 设计理念:Controller 只注入一个"大管家" AuthService,
* 不需要关心底层的 TokenRedisManager 等实现细节。
* </p>
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

// ✅ 只注入一个 Facade 服务
private final AuthService authService;

/**
* 双令牌登录接口
*/
@PostMapping("/login")
public Result<AuthToken> login(@RequestParam Long userId,
@RequestParam String username) {
log.info("收到登录请求: userId={}, username={}", userId, username);
AuthToken authToken = authService.login(userId, username);
return Result.ok(authToken);
}

/**
* 刷新令牌接口
*/
@PostMapping("/refresh")
public Result<AuthToken> refresh(@RequestParam String refreshToken) {
log.info("收到刷新令牌请求");
try {
AuthToken authToken = authService.refresh(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 authHeader) {
log.info("收到验证令牌请求");
String token = extractToken(authHeader);
boolean valid = authService.isTokenValid(token);
Map<String, Object> data = new HashMap<>();
data.put("valid", valid);
return Result.ok(data);
}

private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}
}

完成上述代码开发后,我们通过 HTTP 请求来验证双令牌机制的完整流程,重点观察 Redis 中数据的变化以及令牌轮换(Token
Rotation)的效果。

首先启动 Spring Boot 应用,使用 Postman 或 curl 发起 登录请求,模拟用户 admin (ID: 1001) 登录:

1
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

响应结果应包含一对 Token:

1
2
3
4
5
6
7
8
9
10
11
{
"code": 200,
"msg": "success",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ...",
"refreshToken": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

此时,连接 Redis 终端查看数据,可以看到 Refresh Token 已被存储,且关联了用户 ID:

1
2
3
4
5
6
7
8
# 查看 Refresh Token
127.0.0.1:6379> GET auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab
"1001"

# 查看过期时间(约 7 天 = 604800 秒)
127.0.0.1:6379> TTL auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab
(integer) 604795

接下来,我们模拟 Access Token 过期,使用刚才获取的 refreshToken 调用 刷新接口

1
curl -X POST "http://localhost:8080/auth/refresh?refreshToken=YOUR_REFRESH_TOKEN"

响应结果会返回一对 全新的 Token(实现了令牌轮换):

1
2
3
4
5
6
7
8
9
10
11
{
"code": 200,
"msg": "success",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ...",
"refreshToken": "f9e8d7c6-b5a4-3210-fedc-0987654321zy",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

最后再次检查 Redis,验证 旧令牌销毁 机制是否生效。旧的 Refresh Token 应该不存在,只能查到新的 Refresh Token:

1
2
3
4
5
6
7
8
# 检查旧 Token(应返回 nil)
127.0.0.1:6379> GET auth:token:refresh:a1b2c3d4-e5f6-7890-abcd-1234567890ab
(nil)

# 检查新 Token(应存在)
127.0.0.1:6379> GET auth:token:refresh:f9e8d7c6-b5a4-3210-fedc-0987654321zy
"1001"

通过以上步骤,我们成功验证了基于 Redis 的双令牌颁发、存储、刷新以及旧令牌自动作废的完整闭环。


SpringBoot3 登录注册系列(五) - Token 黑名单与主动注销

第一章. 双令牌机制的遗留问题

在上一章中,我们实现了双令牌机制,解决了"安全性"与"用户体验"的矛盾。但现在我们面临一个新的问题。

1.1. 注销困境

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

他打开手机 App,点击"退出登录"按钮。前端删除了本地存储的 Token,看起来已经退出了。但实际上:

  • 公司电脑上的 Access Token 依然有效(还有 14 分钟才过期)
  • 公司电脑上的 Refresh Token 依然有效(还有 6 天 23 小时才过期)

如果有人在这段时间内使用公司电脑,依然可以访问用户的数据。这就是 JWT 无状态服务器不存储任何会话信息 设计带来的副作用。

1.2. 业务场景梳理

在实际业务中,我们需要支持以下注销场景:

场景一:用户主动退出

用户点击"退出登录"按钮,当前设备的 Token 应该立即失效。

场景二:远程踢出设备

用户在设备管理页面,点击"踢出"按钮,指定设备的 Token 应该立即失效。

场景三:管理员强制下线

管理员发现某个用户的账号被盗,需要强制注销该用户的所有登录会话。

场景四:密码修改

用户修改密码后,所有设备的 Token 都应该失效,强制重新登录。

这些场景都需要服务器能够 主动让 Token 失效,而不是被动等待 Token 过期。

1.3. JTI 的战略价值

还记得我们在第三章生成 Token 时,埋下的一个伏笔吗?

1
2
3
4
String jti = IdUtil.fastSimpleUUID();
JwtBuilder builder = Jwts.builder()
// ...
.id(jti) // JWT ID

JTI(JWT ID)是 JWT 标准中定义的一个字段,用于唯一标识一个 Token。它的作用就是为了实现 Token 黑名单

当我们需要让某个 Token 失效时,只需要将它的 JTI 加入黑名单。验证 Token 时,先检查 JTI 是否在黑名单中,如果在,就拒绝访问。


第二章. 黑名单架构设计

2.1. Redis 数据结构选型

我们需要在 Redis 中存储黑名单,有两种选择:

方案一:使用 String

1
2
3
Key: auth:token:black:jti123
Value: 1(或任意值)
TTL: Token 剩余有效期

方案二:使用 Set

1
2
Key: auth:token:blacklist
Value: {jti123, jti456, jti789}

我们选择 方案一(String),原因如下:

对比维度StringSet
检查性能O(1) - EXISTS 命令O(1) - SISMEMBER 命令
过期机制✅ 每个 Key 独立过期❌ 需要定时任务清理
内存占用较高(每个 JTI 一个 Key)较低(所有 JTI 在一个 Set)
适用场景Token 数量适中Token 数量极大

在认证系统中,Token 的数量通常不会太大(假设 10 万在线用户,每人 2 个设备,也就 20 万个 Token)。使用 String 可以利用 Redis
的原生 TTL 机制,自动清理过期的黑名单记录,无需额外的定时任务。

2.2. 过期时间的精确计算

当我们将 JTI 加入黑名单时,需要设置一个过期时间。这个过期时间应该等于 Token 的剩余有效期

为什么?因为 Token 过期后,即使不在黑名单中,也无法通过验证。继续在 Redis 中保留这个黑名单记录是浪费内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 从 Token 中提取过期时间
Claims claims = jwtUtil.parseToken(token);
Date expiration = claims.getExpiration();

// 计算剩余有效期(秒)
long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;

// 存入黑名单,设置过期时间
redisTemplate.

opsForValue().

set(
"auth:token:black:"+jti,
"1",
remainingSeconds,
TimeUnit.SECONDS
);

2.3. 黑名单检查的性能优化

在每次请求时,我们都需要检查 Token 是否在黑名单中。这个操作的性能至关重要。

优化策略一:使用 EXISTS 命令

Redis 的 EXISTS 命令是 O(1) 复杂度,性能极高。

1
Boolean isBlacklisted = redisTemplate.hasKey("auth:token:black:" + jti);

优化策略二:先验证签名,再检查黑名单

如果 Token 的签名验证失败(说明 Token 被篡改或格式错误),就没必要再去 Redis 查黑名单了。

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 先验证签名(本地操作,无网络开销)
Claims claims = jwtUtil.parseToken(token);

// 2. 再检查黑名单(需要访问 Redis)
String jti = claims.getId();
if(

isInBlacklist(jti)){
throw new

RuntimeException("Token 已被注销");
}

第三章. 实现黑名单服务

3.1. 扩展 RedisKeyConstants

首先,我们在 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
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.auth.core.constant;

public class RedisKeyConstants {

/**
* 刷新令牌映射 Key 前缀
*/
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";

/**
* 用户在线设备索引 Key 前缀
*/
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";

/**
* Token 黑名单 Key 前缀
*/
public static final String TOKEN_BLACKLIST_PREFIX = "auth:token:black:";

/**
* 构建刷新令牌 Key
*/
public static String buildRefreshTokenKey(String refreshToken) {
return TOKEN_REFRESH_PREFIX + refreshToken;
}

/**
* 构建用户在线设备 Key
*/
public static String buildUserOnlineKey(Long userId) {
return USER_ONLINE_TOKENS_PREFIX + userId;
}

/**
* 构建黑名单 Key
*/
public static String buildBlacklistKey(String jti) {
return TOKEN_BLACKLIST_PREFIX + jti;
}
}

3.2. 封装 BlacklistRedisManager(基础设施层)

按照我们的分层架构,黑名单的 Redis 操作应该放在 Manager 层。

📄 文件路径auth-core/src/main/java/com/example/auth/core/manager/BlacklistRedisManager.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
package com.example.auth.core.manager;

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.Component;

import java.util.Date;
import java.util.concurrent.TimeUnit;

/**
* 黑名单 Redis 管理器(基础设施层)
* <p>
* 职责:只负责黑名单在 Redis 中的 CRUD 操作,不包含业务逻辑判断。
* 这是一个"底层苦力",只有存取方法,没有复杂的 if-else。
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class BlacklistRedisManager {

private static final String BLACKLISTED_MARKER = "REVOKED";

private final StringRedisTemplate redisTemplate;

/**
* 将 JTI 加入黑名单
*
* @param jti JWT ID
* @param expiration Token 的过期时间
*/
public void add(String jti, Date expiration) {
// 计算剩余有效期(秒)
long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;

// 如果 Token 已经过期,无需加入黑名单
if (remainingSeconds <= 0) {
log.debug("Token 已过期,无需加入黑名单: jti={}", jti);
return;
}

// 存入黑名单
String key = RedisKeyConstants.buildBlacklistKey(jti);
redisTemplate.opsForValue().set(key, BLACKLISTED_MARKER, remainingSeconds, TimeUnit.SECONDS);

log.debug("JTI 已加入黑名单: jti={}, 剩余有效期={}秒", jti, remainingSeconds);
}

/**
* 将 JTI 加入黑名单(指定过期秒数)
*
* @param jti JWT ID
* @param expireSeconds 过期时间(秒)
*/
public void add(String jti, long expireSeconds) {
if (expireSeconds <= 0) {
log.debug("过期时间无效,无需加入黑名单: jti={}", jti);
return;
}

String key = RedisKeyConstants.buildBlacklistKey(jti);
redisTemplate.opsForValue().set(key, BLACKLISTED_MARKER, expireSeconds, TimeUnit.SECONDS);

log.debug("JTI 已加入黑名单: jti={}, 过期时间={}秒", jti, expireSeconds);
}

/**
* 检查 JTI 是否在黑名单中
*/
public boolean exists(String jti) {
String key = RedisKeyConstants.buildBlacklistKey(jti);
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
}

设计要点BlacklistRedisManager 只负责 Redis 的存取操作,不依赖 JwtUtil。Token 的解析工作由上层的 AuthService 完成。

3.3. 关键设计细节

为什么要判断 Token 是否已过期?

如果 Token 已经过期,即使不在黑名单中,也无法通过验证。此时将它加入黑名单是浪费 Redis 内存和网络开销。

为什么使用 DateUtil.formatDateTime?

Hutool 的 DateUtil.formatDateTime 可以将 Date 对象格式化为 yyyy-MM-dd HH:mm:ss 格式,方便日志查看。这比
SimpleDateFormat 简洁得多。


第四章. 升级 AuthService(集成黑名单)

4.1. 在 AuthService 中集成黑名单检查

按照我们的分层架构,所有业务逻辑都收敛到 AuthService 中。现在我们升级它,集成黑名单检查和注销功能。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/AuthService.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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
package com.example.auth.core.service;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import com.example.auth.core.config.properties.JwtProperties;
import com.example.auth.core.manager.BlacklistRedisManager;
import com.example.auth.core.manager.TokenRedisManager;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.util.JwtUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.Set;

/**
* 认证服务(Facade 层 / 应用服务层)
* <p>
* 职责:作为"大管家",负责编排所有认证相关的业务流程。
* Controller 只需要注入这一个 Service,不需要关心底层的实现细节。
* </p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final JwtUtil jwtUtil;
private final JwtProperties jwtProperties;
private final TokenRedisManager tokenRedisManager;
private final BlacklistRedisManager blacklistRedisManager;

// ==================== 登录相关 ====================

/**
* 登录:创建双令牌
*/
public AuthToken login(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
String jti = jwtUtil.getJtiFromToken(accessToken);

// 3. 生成 Refresh Token(随机字符串)
String refreshToken = IdUtil.fastSimpleUUID();

// 4. 计算 Refresh Token 的过期时间(秒)
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;

// 5. 将 Refresh Token 存入 Redis - IO 操作
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds);

log.info("用户登录成功: userId={}, jti={}", userId, jti);

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

// ==================== 令牌刷新 ====================

/**
* 刷新令牌:使用 Refresh Token 换取新的令牌对
*/
public AuthToken refresh(String oldRefreshToken) {
log.info("刷新令牌请求");

// 1. 验证 Refresh Token 并获取用户 ID
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);

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

// 2. 查询用户信息(实际业务中应该从数据库查询)
String username = "User-" + userId;

// 3. 令牌轮换 - 生成一对全新的令牌
AuthToken newTokenPair = login(userId, username);

// 4. 立即销毁旧的 Refresh Token(防止重放攻击)
tokenRedisManager.deleteRefreshToken(oldRefreshToken);

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

// ==================== 令牌验证(集成黑名单检查) ====================

/**
* 验证 Token(集成黑名单检查)
*/
public Claims validateToken(String token) {
try {
// 1. 验证签名和过期时间(本地操作,无网络开销)
Claims claims = jwtUtil.parseToken(token);

// 2. 检查是否在黑名单中(Redis 查询,O(1) 复杂度)
String jti = claims.getId();
if (blacklistRedisManager.exists(jti)) {
log.warn("Token 已被注销: jti={}", jti);
throw new RuntimeException("Token 已被注销,请重新登录");
}

return claims;

} catch (ExpiredJwtException e) {
log.debug("Token 已过期: {}", e.getMessage());
throw new RuntimeException("Token 已过期,请重新登录");
} catch (JwtException e) {
log.warn("Token 验证失败: {}", e.getMessage());
throw new RuntimeException("Token 无效");
}
}

/**
* 快速验证(只返回布尔值)
*/
public boolean isTokenValid(String token) {
try {
validateToken(token);
return true;
} catch (Exception e) {
return false;
}
}

// ==================== 注销相关 ====================

/**
* 单设备注销(用户主动退出)
*/
public void logout(String accessToken, String refreshToken) {
log.info("单设备注销请求");

try {
// 1. 解析 Token 获取必要信息
Claims claims = jwtUtil.parseToken(accessToken);
String jti = claims.getId();
Date expiration = claims.getExpiration();
Long userId = Long.parseLong(claims.getSubject());

// 2. 将 Access Token 加入黑名单
blacklistRedisManager.add(jti, expiration);
log.info("Token 已加入黑名单: jti={}, 过期时间={}", jti, DateUtil.formatDateTime(expiration));

// 3. 删除 Refresh Token
tokenRedisManager.deleteRefreshToken(refreshToken);

// 4. 从用户在线设备列表中移除
tokenRedisManager.removeUserDevice(userId, jti);

log.info("单设备注销成功: userId={}, jti={}", userId, jti);

} catch (Exception e) {
log.error("单设备注销失败", e);
throw new RuntimeException("注销失败", e);
}
}

/**
* 全设备注销(密码修改、管理员强制下线)
*/
public void logoutAll(Long userId) {
log.info("全设备注销请求: userId={}", userId);

try {
// 1. 获取用户的所有在线设备
Set<String> jtiSet = tokenRedisManager.getUserOnlineDevices(userId);

if (CollUtil.isEmpty(jtiSet)) {
log.info("用户没有在线设备: userId={}", userId);
return;
}

// 2. 将所有 JTI 加入黑名单
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L;
jtiSet.forEach(jti -> blacklistRedisManager.add(jti, maxExpireSeconds));

// 3. 清空用户的在线设备列表
tokenRedisManager.clearUserDevices(userId);

log.info("全设备注销成功: userId={}, 设备数量={}", userId, jtiSet.size());

} catch (Exception e) {
log.error("全设备注销失败: userId={}", userId, e);
throw new RuntimeException("全设备注销失败", e);
}
}

/**
* 踢出指定设备(远程注销)
*/
public void kickout(Long userId, String jti) {
log.info("踢出设备请求: userId={}, jti={}", userId, jti);

try {
// 1. 将 JTI 加入黑名单
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L;
blacklistRedisManager.add(jti, maxExpireSeconds);

// 2. 从用户在线设备列表中移除
tokenRedisManager.removeUserDevice(userId, jti);

log.info("踢出设备成功: userId={}, jti={}", userId, jti);

} catch (Exception e) {
log.error("踢出设备失败: userId={}, jti={}", userId, jti, e);
throw new RuntimeException("踢出设备失败", e);
}
}

// ==================== 辅助方法 ====================

/**
* 从 Token 中提取用户 ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = validateToken(token);
return Long.parseLong(claims.getSubject());
}
}

4.2. 验证流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────┐
│ 收到 Token │
└────────┬────────┘


┌─────────────────┐
│ 1. 验证签名 │ ◄─── 本地操作,无网络开销
└────────┬────────┘


┌─────────────────┐
│ 2. 检查过期时间 │ ◄─── 本地操作,无网络开销
└────────┬────────┘


┌─────────────────┐
│ 3. 检查黑名单 │ ◄─── Redis 查询,O(1) 复杂度
└────────┬────────┘


┌─────────────────┐
│ 验证通过 │
└─────────────────┘

第五章. 升级 AuthController(完整版)

5.1. 完整的 AuthController

现在我们升级 AuthController,添加注销相关的接口。注意:Controller 依然只注入一个 AuthService

📄 文件路径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
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
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.AuthService;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

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

/**
* 认证控制器
* <p>
* 设计理念:Controller 只注入一个"大管家" AuthService,
* 不需要关心底层的 TokenRedisManager、BlacklistRedisManager 等实现细节。
* </p>
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

// ✅ 只注入一个 Facade 服务
private final AuthService authService;

/**
* 双令牌登录接口
*/
@PostMapping("/login")
public Result<AuthToken> login(@RequestParam Long userId,
@RequestParam String username) {
log.info("收到登录请求: userId={}, username={}", userId, username);
AuthToken authToken = authService.login(userId, username);
return Result.ok(authToken);
}

/**
* 刷新令牌接口
*/
@PostMapping("/refresh")
public Result<AuthToken> refresh(@RequestParam String refreshToken) {
log.info("收到刷新令牌请求");
try {
AuthToken authToken = authService.refresh(refreshToken);
return Result.ok(authToken);
} catch (Exception e) {
log.error("刷新令牌失败", e);
return Result.fail(401, e.getMessage());
}
}

/**
* 单设备注销接口
*/
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader("Authorization") String authHeader,
@RequestParam String refreshToken) {
log.info("收到注销请求");
try {
String accessToken = extractToken(authHeader);
authService.logout(accessToken, refreshToken);
return Result.ok();
} catch (Exception e) {
log.error("注销失败", e);
return Result.fail(500, "注销失败:" + e.getMessage());
}
}

/**
* 全设备注销接口(需要管理员权限或用户本人)
*/
@PostMapping("/logout/all")
public Result<Void> logoutAll(@RequestHeader("Authorization") String authHeader) {
log.info("收到全设备注销请求");
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
authService.logoutAll(userId);
return Result.ok();
} catch (Exception e) {
log.error("全设备注销失败", e);
return Result.fail(500, "全设备注销失败:" + e.getMessage());
}
}

/**
* 验证令牌接口(集成黑名单检查)
*/
@GetMapping("/validate")
public Result<Map<String, Object>> validate(@RequestHeader("Authorization") String authHeader) {
log.info("收到验证令牌请求");
try {
String token = extractToken(authHeader);
Claims claims = authService.validateToken(token);

Map<String, Object> data = new HashMap<>();
data.put("valid", true);
data.put("userId", claims.getSubject());
data.put("username", claims.get("username"));

return Result.ok(data);
} catch (Exception e) {
log.debug("Token 验证失败: {}", e.getMessage());
Map<String, Object> data = new HashMap<>();
data.put("valid", false);
data.put("reason", e.getMessage());
return Result.ok(data);
}
}

/**
* 从 Authorization Header 中提取 Token
*/
private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}
}

架构优势:Controller 只注入一个 AuthService,所有的 TokenRedisManagerBlacklistRedisManager 都被隐藏在 AuthService 内部。Controller 不需要知道这些细节。


第六章. 完整测试流程

6.1. 测试单设备注销

步骤 1:登录获取 Token

1
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

响应:

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"refreshToken": "a1b2c3d4e5f6...",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

步骤 2:验证 Token 有效

1
2
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

响应:

1
2
3
4
5
6
7
8
9
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true,
"userId": "1001",
"username": "admin"
}
}

步骤 3:注销登录

1
2
curl -X POST "http://localhost:8080/auth/logout?refreshToken=<YOUR_REFRESH_TOKEN>" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

响应:

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": null
}

步骤 4:再次验证 Token(应该失败)

1
2
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

响应:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false,
"reason": "Token 已被注销,请重新登录"
}
}

步骤 5:检查 Redis 数据

1
2
3
4
5
6
7
8
9
10
11
# 检查黑名单(应该存在)
127.0.0.1:6379> EXISTS auth:token:black:<JTI>
(integer) 1

# 检查 Refresh Token(应该不存在)
127.0.0.1:6379> GET auth:token:refresh:<REFRESH_TOKEN>
(nil)

# 检查用户在线设备(应该为空)
127.0.0.1:6379> SMEMBERS auth:user:tokens:1001
(empty array)

6.2. 测试全设备注销

步骤 1:模拟多设备登录

1
2
3
4
5
6
7
8
# 设备 1 登录
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

# 设备 2 登录
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

# 设备 3 登录
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

步骤 2:检查 Redis 中的在线设备

1
2
3
4
127.0.0.1:6379> SMEMBERS auth:user:tokens:1001
1) "jti-device-1"
2) "jti-device-2"
3) "jti-device-3"

步骤 3:执行全设备注销

1
2
curl -X POST "http://localhost:8080/auth/logout/all" \
-H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"

步骤 4:验证所有设备的 Token 都已失效

1
2
3
4
5
6
7
8
9
10
11
# 验证设备 1 的 Token
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"

# 验证设备 2 的 Token
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <DEVICE_2_ACCESS_TOKEN>"

# 验证设备 3 的 Token
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <DEVICE_3_ACCESS_TOKEN>"

所有请求都应该返回:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false,
"reason": "Token 已被注销,请重新登录"
}
}

6.3. 测试黑名单自动过期

步骤 1:修改配置文件,将 Access Token 有效期改为 1 分钟

1
2
jwt:
access-token-expire-minutes: 1 # 测试用

步骤 2:登录并立即注销

1
2
3
4
5
6
# 登录
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"

# 注销
curl -X POST "http://localhost:8080/auth/logout?refreshToken=<YOUR_REFRESH_TOKEN>" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

步骤 3:检查黑名单的 TTL

1
2
127.0.0.1:6379> TTL auth:token:black:<JTI>
(integer) 58 # 约 58 秒(因为 Token 有效期是 1 分钟)

步骤 4:等待 1 分钟后再次检查

1
2
127.0.0.1:6379> EXISTS auth:token:black:<JTI>
(integer) 0 # 已自动过期删除

第七章. 本章小结

我们完成了 Token 黑名单机制,实现了主动注销功能,并采用了清晰的分层架构。

核心成果

步骤操作产出
1设计分层架构Controller → AuthService → Manager
2封装 Manager 层TokenRedisManagerBlacklistRedisManager
3封装 Facade 层AuthService(统一入口,编排业务流程)
4升级 Controller只注入一个 AuthService

架构依赖关系图(重构后)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────┐
│ Controller 层 │
│ 只注入一个 AuthService │
└─────────────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Facade 层 (AuthService) │
│ "大管家",负责编排所有业务流程 │
│ login / refresh / validateToken / logout / logoutAll │
└─────────────────────────┬───────────────────────────────────┘

┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌───────────────┐ ┌─────────────────────┐
│ JwtUtil │ │TokenRedisManager│ │BlacklistRedisManager│
│ (纯算法工具) │ │ (Token存取) │ │ (黑名单存取) │
└─────────────────┘ └───────────────┘ └─────────────────────┘

方法速查

类名方法名作用
AuthServicelogin(userId, username)登录,创建双令牌
AuthServicerefresh(refreshToken)刷新令牌
AuthServicevalidateToken(token)验证 Token(含黑名单检查)
AuthServicelogout(accessToken, refreshToken)单设备注销
AuthServicelogoutAll(userId)全设备注销
AuthServicekickout(userId, jti)踢出指定设备
TokenRedisManagersaveRefreshToken(...)存储 Refresh Token
TokenRedisManagergetUserIdByRefreshToken(...)获取用户 ID
TokenRedisManagerdeleteRefreshToken(...)删除 Refresh Token
BlacklistRedisManageradd(jti, expiration)将 JTI 加入黑名单
BlacklistRedisManagerexists(jti)检查 JTI 是否在黑名单中

架构优势

  1. 职责清晰:Manager 只负责 CRUD,AuthService 负责业务编排
  2. 依赖简洁:Controller 只注入一个 AuthService,像树状结构而非蜘蛛网
  3. 易于测试:每一层都可以独立测试
  4. 易于扩展:新增功能只需在 AuthService 中添加方法

注销场景对比

场景方法说明
用户主动退出authService.logout(...)注销当前设备
密码修改authService.logoutAll(...)注销所有设备
管理员强制下线authService.logoutAll(...)注销指定用户的所有设备
远程踢出设备authService.kickout(...)注销指定设备

本章引用链接

在使用 Token 黑名单的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:

架构设计相关

代码实现相关

测试验证相关


SpringBoot3 登录注册系列(六) - 多设备管理与在线状态

第一章. 当前架构的盲点

在上一章中,我们实现了 Token 黑名单机制,用户可以主动注销登录。但现在我们面临一个新的问题:用户根本不知道自己有哪些设备在线

1.1. 看不见的在线设备

假设你现在打开手机,想查看自己的账号有哪些设备在线。你会发现:

  • 没有设备列表:不知道自己有几台设备在线
  • 没有登录时间:不知道每个设备是什么时候登录的
  • 没有设备信息:不知道是手机还是电脑,是 Chrome 还是 Safari

这就像你的家里有很多把钥匙,但你不知道每把钥匙在谁手里,也不知道谁最近用过。

1.2. 无法管理的设备列表

更严重的问题是:即使你发现了异常登录,也不知道如何处理。

场景一:发现异常登录

你在北京,突然收到一条通知:“您的账号在上海登录”。你确定自己没有去过上海,怀疑账号被盗。但你打开 App,只能看到一个"退出所有设备"的按钮。

点击后,你自己的手机也被踢下线了,必须重新登录。这就像为了抓一个小偷,把整个小区的门都锁上了。

场景二:账号共享泛滥

你的视频网站账号被朋友借用,结果他又分享给了其他人。现在有 10 个人在用你的账号,但你无法限制设备数量。

1.3. 业务场景梳理

在实际业务中,我们需要支持以下设备管理场景:

场景用户需求技术实现
查看在线设备我想知道有哪些设备在线查询 Redis 中的设备列表
远程踢出设备我想踢出某个可疑设备将指定设备的 JTI 加入黑名单
设备数量限制我想限制最多 3 台设备同时在线登录时检查设备数量,超过则踢出最早的设备
查看登录历史我想知道每个设备的登录时间使用 ZSet 按时间排序存储

第二章. 从 Set 到 ZSet 的架构升级

在第四章中,我们使用 Redis 的 Set 来存储用户的在线设备列表。但现在我们发现,Set 有一个致命缺陷:无法排序

2.1. Set 的局限性

回顾一下我们当前的数据结构:

1
2
Key: auth:user:tokens:1001
Value: {jti-device-1, jti-device-2, jti-device-3}

这个 Set 只能告诉我们"有哪些设备在线",但无法回答:

  • 哪个设备是最早登录的?
  • 哪个设备是最近登录的?
  • 如果要踢出一个设备,应该踢出哪个?

2.2. ZSet 的排序能力

ZSet(Sorted Set)是 Redis 的有序集合每个元素都有一个分数(score),按分数排序。我们可以用登录时间作为分数,这样设备列表就自动按登录时间排序了。

1
2
3
4
5
6
Key: auth:user:tokens:1001
Value: {
jti-device-1: 1735200000, # 2025-12-26 10:00:00
jti-device-2: 1735203600, # 2025-12-26 11:00:00
jti-device-3: 1735207200 # 2025-12-26 12:00:00
}

现在我们可以轻松回答:

  • 最早登录的设备ZRANGE auth:user:tokens:1001 0 0(返回 jti-device-1)
  • 最近登录的设备ZREVRANGE auth:user:tokens:1001 0 0(返回 jti-device-3)
  • 所有设备按时间排序ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES

2.3. 设备信息的存储策略

但是,ZSet 只能存储 JTI 和登录时间,无法存储设备类型、IP、User-Agent 等详细信息。我们需要一个额外的数据结构来存储这些信息。

方案一:使用 Hash

1
2
3
4
5
6
7
8
Key: auth:device:jti-device-1
Value: {
"deviceType": "Mobile",
"os": "iOS 17.2",
"browser": "Safari",
"ip": "192.168.1.100",
"loginTime": "2025-12-26 10:00:00"
}

方案二:使用 String(JSON)

1
2
Key: auth:device:jti-device-1
Value: "{\"deviceType\":\"Mobile\",\"os\":\"iOS 17.2\",...}"

我们选择 方案一(Hash),原因如下:

对比维度HashString(JSON)
读取单个字段HGET 直接读取❌ 需要反序列化整个 JSON
更新单个字段HSET 直接更新❌ 需要读取、修改、写回
内存占用较低(Redis 优化)较高(JSON 字符串)
可读性✅ Redis 客户端直接查看❌ 需要 JSON 格式化工具

第三章. 设备信息模型设计

3.1. DeviceInfo 模型

首先,我们定义一个模型来承载设备信息。

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/DeviceInfo.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
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 DeviceInfo implements Serializable {

/**
* JWT ID(设备唯一标识)
*/
private String jti;

/**
* 设备类型(Mobile / Desktop / Tablet)
*/
private String deviceType;

/**
* 操作系统(如 iOS 17.2, Windows 11)
*/
private String os;

/**
* 浏览器(如 Chrome 120, Safari 17)
*/
private String browser;

/**
* IP 地址
*/
private String ip;

/**
* 登录时间(时间戳,秒)
*/
private Long loginTime;

/**
* 登录时间(格式化字符串)
*/
private String loginTimeFormatted;
}

3.2. 设备信息提取工具类

现在我们需要从 HTTP 请求中提取设备信息。主要来源是:

  • User-Agent:包含浏览器、操作系统、设备类型等信息
  • IP 地址:需要考虑代理和负载均衡

📄 文件路径auth-core/src/main/java/com/example/auth/core/util/DeviceInfoExtractor.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
package com.example.auth.core.util;

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;

/**
* 设备信息提取工具类
*/
@Slf4j
public class DeviceInfoExtractor {

/**
* 从请求中提取设备类型
*/
public static String extractDeviceType(HttpServletRequest request) {
String userAgentStr = request.getHeader("User-Agent");
if (StrUtil.isBlank(userAgentStr)) {
return "Unknown";
}

UserAgent ua = UserAgentUtil.parse(userAgentStr);

// 判断设备类型
if (ua.isMobile()) {
return "Mobile";
} else if (ua.isIpad()) {
return "Tablet";
} else {
return "Desktop";
}
}

/**
* 从请求中提取操作系统
*/
public static String extractOs(HttpServletRequest request) {
String userAgentStr = request.getHeader("User-Agent");
if (StrUtil.isBlank(userAgentStr)) {
return "Unknown";
}

UserAgent ua = UserAgentUtil.parse(userAgentStr);
return ua.getOs().getName();
}

/**
* 从请求中提取浏览器
*/
public static String extractBrowser(HttpServletRequest request) {
String userAgentStr = request.getHeader("User-Agent");
if (StrUtil.isBlank(userAgentStr)) {
return "Unknown";
}

UserAgent ua = UserAgentUtil.parse(userAgentStr);
return ua.getBrowser().getName() + " " + ua.getVersion();
}

/**
* 从请求中提取真实 IP 地址
* <p>
* 考虑了代理和负载均衡的情况:
* 1. X-Forwarded-For(标准代理头)
* 2. X-Real-IP(Nginx 常用)
* 3. Proxy-Client-IP(Apache 常用)
* 4. WL-Proxy-Client-IP(WebLogic 常用)
* 5. 最后才使用 request.getRemoteAddr()
* </p>
*/
public static String extractIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (isValidIp(ip)) {
// X-Forwarded-For 可能包含多个 IP,取第一个
return ip.split(",")[0].trim();
}

ip = request.getHeader("X-Real-IP");
if (isValidIp(ip)) {
return ip;
}

ip = request.getHeader("Proxy-Client-IP");
if (isValidIp(ip)) {
return ip;
}

ip = request.getHeader("WL-Proxy-Client-IP");
if (isValidIp(ip)) {
return ip;
}

ip = request.getRemoteAddr();
return ip;
}

/**
* 验证 IP 是否有效
*/
private static boolean isValidIp(String ip) {
return StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip);
}
}

Hutool 的 UserAgentUtil:Hutool 提供了开箱即用的 User-Agent 解析工具,可以识别常见的浏览器、操作系统、设备类型。

3.3. 关键知识点

为什么要处理 X-Forwarded-For?

在生产环境中,用户的请求通常会经过多层代理:

1
用户 → CDN → 负载均衡 → Nginx → Spring Boot

如果直接使用 request.getRemoteAddr(),获取到的是 Nginx 的 IP,而不是用户的真实 IP。

X-Forwarded-For 是一个标准的 HTTP 头,用于记录请求经过的所有代理的 IP 地址。格式如下:

1
X-Forwarded-For: 用户真实IP, 代理1的IP, 代理2的IP

我们只需要取第一个 IP,就是用户的真实 IP。

为什么要判断 “unknown”?

某些代理服务器在无法获取真实 IP 时,会将 X-Forwarded-For 设置为 "unknown"。我们需要跳过这种无效值。


第四章. 扩展 Redis 管理器

4.1. 扩展 RedisKeyConstants

首先,我们在 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
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
package com.example.auth.core.constant;

public class RedisKeyConstants {

/**
* 刷新令牌映射 Key 前缀
*/
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";

/**
* 用户在线设备索引 Key 前缀(ZSet)
*/
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";

/**
* Token 黑名单 Key 前缀
*/
public static final String TOKEN_BLACKLIST_PREFIX = "auth:token:black:";

/**
* 设备详情 Key 前缀(Hash)
*/
public static final String DEVICE_INFO_PREFIX = "auth:device:";

/**
* 构建刷新令牌 Key
*/
public static String buildRefreshTokenKey(String refreshToken) {
return TOKEN_REFRESH_PREFIX + refreshToken;
}

/**
* 构建用户在线设备 Key
*/
public static String buildUserOnlineKey(Long userId) {
return USER_ONLINE_TOKENS_PREFIX + userId;
}

/**
* 构建黑名单 Key
*/
public static String buildBlacklistKey(String jti) {
return TOKEN_BLACKLIST_PREFIX + jti;
}

/**
* 构建设备详情 Key
*/
public static String buildDeviceInfoKey(String jti) {
return DEVICE_INFO_PREFIX + jti;
}
}

4.2. 重构 TokenRedisManager(升级为 ZSet)

现在我们需要重构 TokenRedisManager,将 Set 升级为 ZSet,并添加设备详情的存储。

📄 文件路径auth-core/src/main/java/com/example/auth/core/manager/TokenRedisManager.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
package com.example.auth.core.manager;

import cn.hutool.core.date.DateUtil;
import com.example.auth.core.constant.RedisKeyConstants;
import com.example.auth.core.model.DeviceInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.stream.Collectors;

/**
* Token Redis 管理器(基础设施层)
* <p>
* 职责:只负责 Token 在 Redis 中的 CRUD 操作,不包含业务逻辑判断。
* 这是一个"底层苦力",只有存取方法,没有复杂的 if-else。
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenRedisManager {

private final StringRedisTemplate redisTemplate;

/**
* 存储 Refresh Token 和设备信息(使用 Pipeline 批量操作)
*/
public void saveRefreshToken(Long userId, String refreshToken,
String jti, long expireSeconds, DeviceInfo deviceInfo) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {

// 1. 存储 Refresh Token (K = refreshToken, V = userId)
String refreshKey = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
connection.stringCommands().setEx(
refreshKey.getBytes(),
expireSeconds,
userId.toString().getBytes()
);

// 2. 将设备加入用户的在线设备列表(ZSet,按登录时间排序)
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
double score = deviceInfo.getLoginTime().doubleValue();
connection.zSetCommands().zAdd(
userKey.getBytes(),
score,
jti.getBytes()
);

// 3. 给设备列表设置过期时间
connection.keyCommands().expire(
userKey.getBytes(),
expireSeconds
);

// 4. 存储设备详情(Hash)
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
Map<byte[], byte[]> deviceMap = new HashMap<>();
deviceMap.put("jti".getBytes(), jti.getBytes());
deviceMap.put("deviceType".getBytes(), deviceInfo.getDeviceType().getBytes());
deviceMap.put("os".getBytes(), deviceInfo.getOs().getBytes());
deviceMap.put("browser".getBytes(), deviceInfo.getBrowser().getBytes());
deviceMap.put("ip".getBytes(), deviceInfo.getIp().getBytes());
deviceMap.put("loginTime".getBytes(), deviceInfo.getLoginTime().toString().getBytes());
connection.hashCommands().hMSet(deviceKey.getBytes(), deviceMap);

// 5. 给设备详情设置过期时间
connection.keyCommands().expire(
deviceKey.getBytes(),
expireSeconds
);

return null;
});

log.debug("存储 Refresh Token 和设备信息成功: userId={}, jti={}, 设备类型={}, 有效期={}秒",
userId, jti, deviceInfo.getDeviceType(), expireSeconds);
}

/**
* 根据 Refresh Token 获取用户 ID
*/
public Long getUserIdByRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
String userId = redisTemplate.opsForValue().get(key);
return userId == null ? null : Long.parseLong(userId);
}

/**
* 删除 Refresh Token
*/
public void deleteRefreshToken(String refreshToken) {
String key = RedisKeyConstants.buildRefreshTokenKey(refreshToken);
redisTemplate.delete(key);
log.debug("删除 Refresh Token: {}", refreshToken);
}

/**
* 获取用户的所有在线设备(按登录时间排序)
*/
public List<DeviceInfo> getUserOnlineDevices(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);

// 1. 从 ZSet 中获取所有 JTI(按登录时间倒序)
Set<String> jtiSet = redisTemplate.opsForZSet().reverseRange(userKey, 0, -1);

if (jtiSet == null || jtiSet.isEmpty()) {
return Collections.emptyList();
}

// 2. 批量获取设备详情
return jtiSet.stream()
.map(this::getDeviceInfo)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}

/**
* 获取设备详情
*/
public DeviceInfo getDeviceInfo(String jti) {
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
Map<Object, Object> deviceMap = redisTemplate.opsForHash().entries(deviceKey);

if (deviceMap.isEmpty()) {
return null;
}

Long loginTime = Long.parseLong((String) deviceMap.get("loginTime"));

return DeviceInfo.builder()
.jti((String) deviceMap.get("jti"))
.deviceType((String) deviceMap.get("deviceType"))
.os((String) deviceMap.get("os"))
.browser((String) deviceMap.get("browser"))
.ip((String) deviceMap.get("ip"))
.loginTime(loginTime)
.loginTimeFormatted(DateUtil.formatDateTime(new Date(loginTime * 1000)))
.build();
}

/**
* 从用户在线设备列表中移除指定设备
*/
public void removeUserDevice(Long userId, String jti) {
// 1. 从 ZSet 中移除
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
redisTemplate.opsForZSet().remove(userKey, jti);

// 2. 删除设备详情
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
redisTemplate.delete(deviceKey);

log.debug("移除用户设备: userId={}, jti={}", userId, jti);
}

/**
* 清空用户的所有在线设备
*/
public void clearUserDevices(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);

// 1. 获取所有 JTI
Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0, -1);

if (jtiSet != null && !jtiSet.isEmpty()) {
// 2. 批量删除设备详情
List<String> deviceKeys = jtiSet.stream()
.map(RedisKeyConstants::buildDeviceInfoKey)
.collect(Collectors.toList());
redisTemplate.delete(deviceKeys);
}

// 3. 删除设备列表
redisTemplate.delete(userKey);

log.debug("清空用户所有设备: userId={}", userId);
}

/**
* 获取用户的在线设备数量
*/
public Long getUserDeviceCount(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
return redisTemplate.opsForZSet().zCard(userKey);
}

/**
* 获取最早登录的设备(用于顶号逻辑)
*/
public String getOldestDevice(Long userId) {
String userKey = RedisKeyConstants.buildUserOnlineKey(userId);
Set<String> jtiSet = redisTemplate.opsForZSet().range(userKey, 0, 0);
return (jtiSet != null && !jtiSet.isEmpty()) ? jtiSet.iterator().next() : null;
}
}

4.3. 关键设计细节

为什么使用 reverseRange 而不是 range?

reverseRange 是倒序查询,返回的设备列表是按登录时间从新到旧排序的。这样用户看到的设备列表,最上面的是最近登录的设备,符合用户习惯。

为什么要批量删除设备详情?

clearUserDevices 方法中,我们需要删除所有设备的详情。如果一个一个删除,会产生大量的网络请求继续完成第六章的内容:


为什么要批量删除设备详情?

clearUserDevices 方法中,我们需要删除所有设备的详情。如果一个一个删除,会产生大量的网络请求。使用 redisTemplate.delete(List<String> keys) 可以一次性删除多个 Key,大幅提升性能。

1
2
3
4
5
6
7
8
9
10
11
// ❌ 低效写法(N 次网络请求)
for (String jti : jtiSet) {
String deviceKey = RedisKeyConstants.buildDeviceInfoKey(jti);
redisTemplate.delete(deviceKey);
}

// ✅ 高效写法(1 次网络请求)
List<String> deviceKeys = jtiSet.stream()
.map(RedisKeyConstants::buildDeviceInfoKey)
.collect(Collectors.toList());
redisTemplate.delete(deviceKeys);

为什么 ZSet 的 score 使用登录时间?

ZSet 的 score 必须是数值类型。我们使用 Unix 时间戳(秒)作为 score,这样:

  • 自动排序:Redis 会自动按 score 排序,无需额外操作
  • 范围查询:可以查询"最近 7 天登录的设备"(ZRANGEBYSCORE
  • 性能优化:数值比较比字符串比较快得多

第五章. 升级 AuthService(集成设备管理)

5.1. 在登录时记录设备信息

现在我们需要升级 AuthServicelogin 方法,在登录时记录设备信息。

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/AuthService.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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
package com.example.auth.core.service;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
import com.example.auth.core.config.properties.JwtProperties;
import com.example.auth.core.manager.BlacklistRedisManager;
import com.example.auth.core.manager.TokenRedisManager;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.model.DeviceInfo;
import com.example.auth.core.util.JwtUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.List;

/**
* 认证服务(Facade 层 / 应用服务层)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final JwtUtil jwtUtil;
private final JwtProperties jwtProperties;
private final TokenRedisManager tokenRedisManager;
private final BlacklistRedisManager blacklistRedisManager;

// ==================== 登录相关 ====================

/**
* 登录:创建双令牌(带设备信息)
*/
public AuthToken login(Long userId, String username, DeviceInfo deviceInfo) {
log.info("用户登录: userId={}, username={}, 设备类型={}",
userId, username, deviceInfo.getDeviceType());

// 1. 生成 Access Token (JWT) - 纯内存操作
String accessToken = jwtUtil.createToken(userId, username, null);

// 2. 从 Access Token 中提取 JTI
String jti = jwtUtil.getJtiFromToken(accessToken);

// 3. 补充设备信息中的 JTI 和登录时间
deviceInfo.setJti(jti);
deviceInfo.setLoginTime(System.currentTimeMillis() / 1000);

// 4. 生成 Refresh Token(随机字符串)
String refreshToken = IdUtil.fastSimpleUUID();

// 5. 计算 Refresh Token 的过期时间(秒)
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;

// 6. 将 Refresh Token 和设备信息存入 Redis
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds, deviceInfo);

log.info("用户登录成功: userId={}, jti={}, IP={}", userId, jti, deviceInfo.getIp());

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

// ==================== 令牌刷新 ====================

/**
* 刷新令牌:使用 Refresh Token 换取新的令牌对
*/
public AuthToken refresh(String oldRefreshToken, DeviceInfo deviceInfo) {
log.info("刷新令牌请求");

// 1. 验证 Refresh Token 并获取用户 ID
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);

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

// 2. 查询用户信息(实际业务中应该从数据库查询)
String username = "User-" + userId;

// 3. 令牌轮换 - 生成一对全新的令牌
AuthToken newTokenPair = login(userId, username, deviceInfo);

// 4. 立即销毁旧的 Refresh Token(防止重放攻击)
tokenRedisManager.deleteRefreshToken(oldRefreshToken);

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

// ==================== 令牌验证(集成黑名单检查) ====================

/**
* 验证 Token(集成黑名单检查)
*/
public Claims validateToken(String token) {
try {
// 1. 验证签名和过期时间(本地操作,无网络开销)
Claims claims = jwtUtil.parseToken(token);

// 2. 检查是否在黑名单中(Redis 查询,O(1) 复杂度)
String jti = claims.getId();
if (blacklistRedisManager.exists(jti)) {
log.warn("Token 已被注销: jti={}", jti);
throw new RuntimeException("Token 已被注销,请重新登录");
}

return claims;

} catch (ExpiredJwtException e) {
log.debug("Token 已过期: {}", e.getMessage());
throw new RuntimeException("Token 已过期,请重新登录");
} catch (JwtException e) {
log.warn("Token 验证失败: {}", e.getMessage());
throw new RuntimeException("Token 无效");
}
}

/**
* 快速验证(只返回布尔值)
*/
public boolean isTokenValid(String token) {
try {
validateToken(token);
return true;
} catch (Exception e) {
return false;
}
}

// ==================== 注销相关 ====================

/**
* 单设备注销(用户主动退出)
*/
public void logout(String accessToken, String refreshToken) {
log.info("单设备注销请求");

try {
// 1. 解析 Token 获取必要信息
Claims claims = jwtUtil.parseToken(accessToken);
String jti = claims.getId();
Date expiration = claims.getExpiration();
Long userId = Long.parseLong(claims.getSubject());

// 2. 将 Access Token 加入黑名单
blacklistRedisManager.add(jti, expiration);

// 3. 删除 Refresh Token
tokenRedisManager.deleteRefreshToken(refreshToken);

// 4. 从用户在线设备列表中移除
tokenRedisManager.removeUserDevice(userId, jti);

log.info("单设备注销成功: userId={}, jti={}", userId, jti);

} catch (Exception e) {
log.error("单设备注销失败", e);
throw new RuntimeException("注销失败", e);
}
}

/**
* 全设备注销(密码修改、管理员强制下线)
*/
public void logoutAll(Long userId) {
log.info("全设备注销请求: userId={}", userId);

try {
// 1. 获取用户的所有在线设备
List<DeviceInfo> devices = tokenRedisManager.getUserOnlineDevices(userId);

if (CollUtil.isEmpty(devices)) {
log.info("用户没有在线设备: userId={}", userId);
return;
}

// 2. 将所有 JTI 加入黑名单
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L;
devices.forEach(device -> blacklistRedisManager.add(device.getJti(), maxExpireSeconds));

// 3. 清空用户的在线设备列表
tokenRedisManager.clearUserDevices(userId);

log.info("全设备注销成功: userId={}, 设备数量={}", userId, devices.size());

} catch (Exception e) {
log.error("全设备注销失败: userId={}", userId, e);
throw new RuntimeException("全设备注销失败", e);
}
}

/**
* 踢出指定设备(远程注销)
*/
public void kickoutDevice(Long userId, String jti) {
log.info("踢出设备请求: userId={}, jti={}", userId, jti);

try {
// 1. 将 JTI 加入黑名单
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L;
blacklistRedisManager.add(jti, maxExpireSeconds);

// 2. 从用户在线设备列表中移除
tokenRedisManager.removeUserDevice(userId, jti);

log.info("踢出设备成功: userId={}, jti={}", userId, jti);

} catch (Exception e) {
log.error("踢出设备失败: userId={}, jti={}", userId, jti, e);
throw new RuntimeException("踢出设备失败", e);
}
}

// ==================== 设备管理 ====================

/**
* 查询用户的在线设备列表
*/
public List<DeviceInfo> getOnlineDevices(Long userId) {
log.info("查询在线设备: userId={}", userId);
return tokenRedisManager.getUserOnlineDevices(userId);
}

/**
* 获取设备详情
*/
public DeviceInfo getDeviceInfo(String jti) {
return tokenRedisManager.getDeviceInfo(jti);
}

// ==================== 辅助方法 ====================

/**
* 从 Token 中提取用户 ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = validateToken(token);
return Long.parseLong(claims.getSubject());
}
}

5.2. 关键设计细节

为什么 login 方法需要传入 DeviceInfo?

在之前的版本中,login 方法只需要 userIdusername。现在我们需要记录设备信息,所以必须传入 DeviceInfo 对象。

但是,DeviceInfo 的提取工作不应该在 Service 层完成,而应该在 Controller 层完成。因为:

  • Service 层不应该依赖 HttpServletRequest:Service 层是业务逻辑层,不应该依赖 Web 层的对象
  • 便于单元测试:如果 Service 层依赖 HttpServletRequest,单元测试时需要 Mock 这个对象,非常麻烦

为什么 refresh 方法也需要传入 DeviceInfo?

刷新令牌时,我们会生成一对全新的 Token。这意味着会产生一个新的 JTI,需要更新设备信息。

但是,刷新令牌时的设备信息应该和登录时的设备信息保持一致吗?不一定。

假设用户在手机上登录,然后切换到 Wi-Fi 网络,IP 地址变了。此时刷新令牌,应该更新 IP 地址。

所以,我们在刷新令牌时也需要传入最新的设备信息。


第六章. 设备管理接口实现

6.1. 升级 AuthController

现在我们需要升级 AuthController,添加设备管理相关的接口。

📄 文件路径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
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
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.model.DeviceInfo;
import com.example.auth.core.service.AuthService;
import com.example.auth.core.util.DeviceInfoExtractor;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

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

/**
* 认证控制器
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthService authService;

/**
* 双令牌登录接口(带设备信息)
*/
@PostMapping("/login")
public Result<AuthToken> login(@RequestParam Long userId,
@RequestParam String username,
HttpServletRequest request) {
log.info("收到登录请求: userId={}, username={}", userId, username);

// 提取设备信息
DeviceInfo deviceInfo = buildDeviceInfo(request);

AuthToken authToken = authService.login(userId, username, deviceInfo);
return Result.ok(authToken);
}

/**
* 刷新令牌接口(带设备信息)
*/
@PostMapping("/refresh")
public Result<AuthToken> refresh(@RequestParam String refreshToken,
HttpServletRequest request) {
log.info("收到刷新令牌请求");
try {
// 提取设备信息
DeviceInfo deviceInfo = buildDeviceInfo(request);

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

/**
* 单设备注销接口
*/
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader("Authorization") String authHeader,
@RequestParam String refreshToken) {
log.info("收到注销请求");
try {
String accessToken = extractToken(authHeader);
authService.logout(accessToken, refreshToken);
return Result.ok();
} catch (Exception e) {
log.error("注销失败", e);
return Result.fail(500, "注销失败:" + e.getMessage());
}
}

/**
* 全设备注销接口
*/
@PostMapping("/logout/all")
public Result<Void> logoutAll(@RequestHeader("Authorization") String authHeader) {
log.info("收到全设备注销请求");
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
authService.logoutAll(userId);
return Result.ok();
} catch (Exception e) {
log.error("全设备注销失败", e);
return Result.fail(500, "全设备注销失败:" + e.getMessage());
}
}

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

Map<String, Object> data = new HashMap<>();
data.put("valid", true);
data.put("userId", claims.getSubject());
data.put("username", claims.get("username"));

return Result.ok(data);
} catch (Exception e) {
log.debug("Token 验证失败: {}", e.getMessage());
Map<String, Object> data = new HashMap<>();
data.put("valid", false);
data.put("reason", e.getMessage());
return Result.ok(data);
}
}

// ==================== 设备管理接口 ====================

/**
* 查询在线设备列表
*/
@GetMapping("/devices")
public Result<List<DeviceInfo>> getDevices(@RequestHeader("Authorization") String authHeader) {
log.info("收到查询在线设备请求");
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
List<DeviceInfo> devices = authService.getOnlineDevices(userId);
return Result.ok(devices);
} catch (Exception e) {
log.error("查询在线设备失败", e);
return Result.fail(500, "查询失败:" + e.getMessage());
}
}

/**
* 踢出指定设备
*/
@DeleteMapping("/devices/{jti}")
public Result<Void> kickoutDevice(@RequestHeader("Authorization") String authHeader,
@PathVariable String jti) {
log.info("收到踢出设备请求: jti={}", jti);
try {
String accessToken = extractToken(authHeader);
Long userId = authService.getUserIdFromToken(accessToken);
authService.kickoutDevice(userId, jti);
return Result.ok();
} catch (Exception e) {
log.error("踢出设备失败: jti={}", jti, e);
return Result.fail(500, "踢出设备失败:" + e.getMessage());
}
}

// ==================== 辅助方法 ====================

/**
* 从 Authorization Header 中提取 Token
*/
private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}

/**
* 构建设备信息
*/
private DeviceInfo buildDeviceInfo(HttpServletRequest request) {
return DeviceInfo.builder()
.deviceType(DeviceInfoExtractor.extractDeviceType(request))
.os(DeviceInfoExtractor.extractOs(request))
.browser(DeviceInfoExtractor.extractBrowser(request))
.ip(DeviceInfoExtractor.extractIp(request))
.build();
}
}

6.2. 接口设计说明

为什么踢出设备使用 DELETE 方法?

RESTful API 的设计原则是:

  • GET:查询资源
  • POST:创建资源
  • PUT:更新资源
  • DELETE:删除资源

踢出设备本质上是"删除一个在线会话",所以使用 DELETE 方法更符合语义。

为什么路径是 /devices/{jti} 而不是 /devices/kickout

RESTful API 的设计原则是:资源用名词,操作用 HTTP 方法

  • /devices/kickout?jti=xxx(动词 + 参数)
  • /devices/{jti}(名词 + DELETE 方法)

第七章. 设备数量限制(顶号逻辑)

7.1. 业务场景

在视频网站、音乐平台等业务中,通常会限制同一账号的最大在线设备数。例如:

  • Netflix:标准套餐最多 2 台设备同时观看
  • Spotify:免费用户只能 1 台设备在线
  • 腾讯视频:VIP 会员最多 5 台设备登录

当用户在第 6 台设备上登录时,系统会自动踢出最早登录的设备。这就是顶号逻辑新设备登录时,自动踢出旧设备

7.2. 扩展 JwtProperties

首先,我们在配置类中添加设备数量限制的配置。

📄 文件路径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
46
package com.example.auth.core.config.properties;

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

@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

/**
* 签发者标识 (Issuer)
*/
private String issuer = "auth-service";

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

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

/**
* 时钟偏差容忍度(秒)
*/
private Long clockSkewSeconds = 30L;

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

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

/**
* 最大在线设备数(0 表示不限制)
*/
private Integer maxDevices = 0;
}

更新配置文件。

📄 文件路径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
19
20
21
22
23
24
25
26
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

jwt:
issuer: pro-auth-service
access-token-expire-minutes: 15
refresh-token-expire-days: 7
clock-skew-seconds: 30
public-key-resource: certs/public_key.pem
private-key-resource: certs/private_key.pem
max-devices: 3 # 最多 3 台设备同时在线

7.3. 在 AuthService 中实现顶号逻辑

现在我们需要在 login 方法中添加设备数量检查。

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

login 方法的开头添加以下逻辑:

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
/**
* 登录:创建双令牌(带设备信息 + 设备数量限制)
*/
public AuthToken login(Long userId, String username, DeviceInfo deviceInfo) {
log.info("用户登录: userId={}, username={}, 设备类型={}",
userId, username, deviceInfo.getDeviceType());

// ========== 新增:设备数量限制检查 ==========
Integer maxDevices = jwtProperties.getMaxDevices();
if (maxDevices != null && maxDevices > 0) {
Long currentDeviceCount = tokenRedisManager.getUserDeviceCount(userId);

if (currentDeviceCount >= maxDevices) {
// 达到设备数量上限,踢出最早登录的设备
String oldestJti = tokenRedisManager.getOldestDevice(userId);
if (oldestJti != null) {
log.info("设备数量达到上限,踢出最早登录的设备: userId={}, jti={}", userId, oldestJti);
kickoutDevice(userId, oldestJti);
}
}
}
// ========== 设备数量限制检查结束 ==========

// 1. 生成 Access Token (JWT) - 纯内存操作
String accessToken = jwtUtil.createToken(userId, username, null);

// ... 后续逻辑保持不变
}

7.4. 顶号流程图

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
┌─────────────────┐
│ 用户登录请求 │
└────────┬────────┘


┌─────────────────┐
│ 检查设备数量 │
└────────┬────────┘

├─ 未达上限 ──────────────┐
│ │
└─ 达到上限 │
│ │
▼ │
┌─────────────────┐ │
│ 获取最早的设备 │ │
└────────┬────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ 踢出该设备 │ │
│ (加入黑名单) │ │
└────────┬────────┘ │
│ │
└────────────────┤


┌─────────────────┐
│ 生成新的 Token │
└─────────────────┘

7.5. 关键设计细节

为什么要先踢出旧设备,再生成新 Token?

如果先生成新 Token,再踢出旧设备,会出现一个短暂的时间窗口:设备数量超过了上限。虽然这个时间窗口很短(几毫秒),但在高并发场景下可能会被利用。

为什么使用 maxDevices > 0 而不是 maxDevices != null

配置文件中可能会设置 max-devices: 0,表示不限制设备数量。我们需要同时检查 != null> 0

为什么不使用分布式锁?

你可能会担心:如果用户同时在两台设备上登录,会不会出现竞态条件?

答案是:不会。因为我们的检查和踢出操作都是在同一个方法中完成的,而 Spring 的 Service 方法默认是单线程执行的(除非你手动开启异步)。

即使在高并发场景下,最坏的情况是:两个请求都通过了设备数量检查,导致设备数量暂时超过上限。但下一次登录时,会自动踢出多余的设备,系统会自动恢复到正常状态。

如果你的业务对设备数量有严格要求(如付费会员系统),可以使用 Redis 的分布式锁(Redisson)来保证原子性。


第八章. 完整测试流程

8.1. 测试设备列表查询

步骤 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
# 设备 1 登录(模拟 iPhone)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML好的,我继续完成第六章的剩余内容:

---

## 第八章. 完整测试流程

### 8.1. 测试设备列表查询

**步骤 1:模拟多设备登录**

```bash
# 设备 1 登录(模拟 iPhone)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15"

# 设备 2 登录(模拟 Chrome 桌面版)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"

# 设备 3 登录(模拟 Android)
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin" \
-H "User-Agent: Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile"

每次登录都会返回一对 Token,请保存第一个设备的 accessToken,后续查询设备列表时需要用到。

步骤 2:查询在线设备列表

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"

预期响应

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
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "a1b2c3d4e5f6",
"deviceType": "Mobile",
"os": "Android",
"browser": "Chrome Mobile 120.0.0.0",
"ip": "127.0.0.1",
"loginTime": 1735280400,
"loginTimeFormatted": "2025-12-27 14:00:00"
},
{
"jti": "f6e5d4c3b2a1",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome 120.0.0.0",
"ip": "127.0.0.1",
"loginTime": 1735280300,
"loginTimeFormatted": "2025-12-27 13:58:20"
},
{
"jti": "1a2b3c4d5e6f",
"deviceType": "Mobile",
"os": "iOS",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735280200,
"loginTimeFormatted": "2025-12-27 13:56:40"
}
]
}

观察要点:设备列表按登录时间倒序排列,最新登录的设备在最上面。

步骤 3:检查 Redis 数据

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
# 查看用户的在线设备列表(ZSet)
127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES
1) "1a2b3c4d5e6f"
2) "1735280200"
3) "f6e5d4c3b2a1"
4) "好的,我继续完成第六章的剩余内容:

---

**步骤 3:检查 Redis 数据**

```bash
# 查看用户的在线设备列表(ZSet)
127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1 WITHSCORES
1) "1a2b3c4d5e6f"
2) "1735280200"
3) "f6e5d4c3b2a1"
4) "1735280300"
5) "a1b2c3d4e5f6"
6) "1735280400"

# 查看某个设备的详细信息(Hash)
127.0.0.1:6379> HGETALL auth:device:1a2b3c4d5e6f
1) "jti"
2) "1a2b3c4d5e6f"
3) "deviceType"
4) "Mobile"
5) "os"
6) "iOS"
7) "browser"
8) "Safari"
9) "ip"
10) "127.0.0.1"
11) "loginTime"
12) "1735280200"

8.2. 测试远程踢出设备

步骤 1:获取要踢出的设备 JTI

从上一步的设备列表中,选择一个设备的 JTI(如 f6e5d4c3b2a1)。

步骤 2:踢出该设备

1
2
curl -X DELETE "http://localhost:8080/auth/devices/f6e5d4c3b2a1" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": null
}

步骤 3:再次查询设备列表(应该少了一个设备)

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <YOUR_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "a1b2c3d4e5f6",
"deviceType": "Mobile",
"os": "Android",
"browser": "Chrome Mobile 120.0.0.0",
"ip": "127.0.0.1",
"loginTime": 1735280400,
"loginTimeFormatted": "2025-12-27 14:00:00"
},
{
"jti": "1a2b3c4d5e6f",
"deviceType": "Mobile",
"os": "iOS",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735280200,
"loginTimeFormatted": "2025-12-27 13:56:40"
}
]
}

步骤 4:验证被踢出的设备无法访问

使用被踢出设备的 Access Token 尝试访问:

1
2
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <KICKED_DEVICE_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false,
"reason": "Token 已被注销,请重新登录"
}
}

步骤 5:检查 Redis 数据

1
2
3
4
5
6
7
8
9
10
11
12
# 检查黑名单(应该存在)
127.0.0.1:6379> EXISTS auth:token:black:f6e5d4c3b2a1
(integer) 1

# 检查设备详情(应该不存在)
127.0.0.1:6379> EXISTS auth:device:f6e5d4c3b2a1
(integer) 0

# 检查用户在线设备列表(应该少了一个)
127.0.0.1:6379> ZRANGE auth:user:tokens:1001 0 -1
1) "1a2b3c4d5e6f"
2) "a1b2c3d4e5f6"

8.3. 测试设备数量限制(顶号逻辑)

步骤 1:修改配置文件,设置最大设备数为 3

1
2
jwt:
max-devices: 3

重启应用。

步骤 2:模拟 3 台设备登录

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 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (iPhone)"

# 设备 2 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0)"

# 设备 3 登录
curl -X POST "好的,我继续完成第六章的剩余内容:

---

**步骤 2:模拟 3 台设备登录**

```bash
# 设备 1 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (iPhone)"

# 设备 2 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0)"

# 设备 3 登录
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (Macintosh)"

保存第一个设备的 Access Token,用于后续查询。

步骤 3:查询设备列表(应该有 3 台设备)

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"

预期响应

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
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "mac-device-jti",
"deviceType": "Desktop",
"os": "Mac OS X",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735281000,
"loginTimeFormatted": "2025-12-27 14:10:00"
},
{
"jti": "windows-device-jti",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome",
"ip": "127.0.0.1",
"loginTime": 1735280800,
"loginTimeFormatted": "2025-12-27 14:06:40"
},
{
"jti": "iphone-device-jti",
"deviceType": "Mobile",
"os": "iOS",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735280600,
"loginTimeFormatted": "2025-12-27 14:03:20"
}
]
}

步骤 4:第 4 台设备登录(触发顶号)

1
2
curl -X POST "http://localhost:8080/auth/login?userId=2001&username=testuser" \
-H "User-Agent: Mozilla/5.0 (Linux; Android 14)"

观察服务端日志,应该看到:

1
2
3
4
INFO  - 用户登录: userId=2001, username=testuser, 设备类型=Mobile
INFO - 设备数量达到上限,踢出最早登录的设备: userId=2001, jti=iphone-device-jti
INFO - 踢出设备成功: userId=2001, jti=iphone-device-jti
INFO - 用户登录成功: userId=2001, jti=android-device-jti, IP=127.0.0.1

步骤 5:再次查询设备列表(应该还是 3 台,但最早的设备被替换了)

1
2
curl -X GET "http://localhost:8080/auth/devices" \
-H "Authorization: Bearer <NEW_DEVICE_ACCESS_TOKEN>"

预期响应

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
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "android-device-jti",
"deviceType": "Mobile",
"os": "Android",
"browser": "Chrome Mobile",
"ip": "127.0.0.1",
"loginTime": 1735281200,
"loginTimeFormatted": "2025-12-27 14:13:20"
},
{
"jti": "mac-device-jti",
"deviceType": "Desktop",
"os": "Mac OS X",
"browser": "Safari",
"ip": "127.0.0.1",
"loginTime": 1735281000,
"loginTimeFormatted": "2025-12-27 14:10:00"
},
{
"jti": "windows-device-jti",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome",
"ip": "127.0.0.1",
"loginTime": 1735280800,
"loginTimeFormatted": "2025-12-27 14:06:40"
}
]
}

观察要点:iPhone 设备(最早登录)已经被踢出,Android 设备(最新登录)成功加入。

步骤 6:验证被踢出的设备无法访问

使用 iPhone 设备的 Access Token 尝试访问:

1
2
curl -X GET "http://localhost:8080/auth/validate" \
-H "Authorization: Bearer <IPHONE_ACCESS_TOKEN>"

预期响应

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"valid": false,
"reason": "Token 已被注销,请重新登录"
}
}

8.4. 测试设备信息的准确性

步骤 1:使用真实的浏览器访问

打开浏览器,访问以下 URL(需要先实现一个简单的登录页面,或使用 Postman):

1
POST http://localhost:8080/auth/login?userId=3001&username=browseruser

步骤 2:查询设备列表

1
2
GET http://localhost:8080/auth/devices
Authorization: Bearer <YOUR_ACCESS_TOKEN>

预期响应(以 Chrome 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"code": 200,
"message": "操作成功",
"data": [
{
"jti": "real-browser-jti",
"deviceType": "Desktop",
"os": "Windows 10",
"browser": "Chrome 120.0.0.0",
"ip": "192.168.1.100",
"loginTime": 1735281500,
"loginTimeFormatted": "2025-12-27 14:18:20"
}
]
}

验证要点:检查 deviceTypeosbrowser 是否与你的实际环境一致。


第九章. 本章小结

我们完成了多设备管理功能,实现了设备列表查询、远程踢出、设备数量限制等核心能力。

9.1. 本章回顾

本章涵盖了以下核心能力模块:

数据结构升级:将用户在线设备列表从 Set 升级为 ZSet,支持按登录时间排序。同时使用 Hash 存储设备详情,实现了高效的单字段读取和更新。

设备信息提取:封装了 DeviceInfoExtractor 工具类,从 HTTP 请求中提取设备类型、操作系统、浏览器、IP 地址等信息。特别处理了代理和负载均衡场景下的真实 IP 获取。

设备管理接口:提供了查询在线设备列表、远程踢出指定设备的 RESTful API。Controller 层负责提取设备信息,Service 层负责业务编排,Manager 层负责 Redis 操作,职责清晰。

设备数量限制:实现了顶号逻辑,当用户在新设备登录时,如果超过最大设备数限制,自动踢出最早登录的设备。支持通过配置文件灵活调整设备数量上限。

9.2. 核心汇总表

知识点使用场景跳转链接
Set vs ZSet需要对设备列表排序时2.1. Set 的局限性
Hash vs String存储设备详情时2.3. 设备信息的存储策略
DeviceInfo 模型定义设备信息结构3.1. DeviceInfo 模型
真实 IP 提取处理代理和负载均衡3.2. 设备信息提取工具类
TokenRedisManager 升级存储和查询设备信息4.2. 重构 TokenRedisManager
设备列表查询用户查看在线设备6.1. 升级 AuthController
远程踢出设备用户主动踢出可疑设备6.1. 升级 AuthController
设备数量限制防止账号共享7.3. 在 AuthService 中实现顶号逻辑

9.3. 方法速查表

类名方法名作用
DeviceInfoExtractorextractDeviceType(request)提取设备类型
DeviceInfoExtractorextractOs(request)提取操作系统
DeviceInfoExtractorextractBrowser(request)提取浏览器
DeviceInfoExtractorextractIp(request)提取真实 IP
TokenRedisManagersaveRefreshToken(..., deviceInfo)存储 Token 和设备信息
TokenRedisManagergetUserOnlineDevices(userId)获取在线设备列表
TokenRedisManagergetDeviceInfo(jti)获取设备详情
TokenRedisManagergetUserDeviceCount(userId)获取设备数量
TokenRedisManagergetOldestDevice(userId)获取最早登录的设备
AuthServicelogin(userId, username, deviceInfo)登录(带设备信息)
AuthServicegetOnlineDevices(userId)查询在线设备
AuthServicekickoutDevice(userId, jti)踢出指定设备

本章引用链接

在使用多设备管理功能的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:

架构设计相关

代码实现相关

测试验证相关

常见问题


第六章完成!这一章我们实现了完整的多设备管理功能,用户可以查看在线设备、远程踢出可疑设备,系统也可以通过设备数量限制来防止账号共享。

下一章我们将实现登录安全增强功能,包括登录失败次数限制、验证码机制、异地登录检测等,进一步提升系统的安全性。