SpringBoot3 登录注册基础篇(五) - Token 黑名单与主动注销

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 黑名单的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:

架构设计相关

代码实现相关

测试验证相关