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)
|
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),原因如下:
| 对比维度 | String | Set |
|---|
| 检查性能 | 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
| 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
| Claims claims = jwtUtil.parseToken(token);
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 {
public static final String TOKEN_REFRESH_PREFIX = "auth:token:refresh:";
public static final String USER_ONLINE_TOKENS_PREFIX = "auth:user:tokens:";
public static final String TOKEN_BLACKLIST_PREFIX = "auth:token:black:";
public static String buildRefreshTokenKey(String refreshToken) { return TOKEN_REFRESH_PREFIX + refreshToken; }
public static String buildUserOnlineKey(Long userId) { return USER_ONLINE_TOKENS_PREFIX + userId; }
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;
@Slf4j @Component @RequiredArgsConstructor public class BlacklistRedisManager {
private static final String BLACKLISTED_MARKER = "REVOKED";
private final StringRedisTemplate redisTemplate;
public void add(String jti, Date expiration) { long remainingSeconds = (expiration.getTime() - System.currentTimeMillis()) / 1000;
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); }
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); }
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;
@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);
String accessToken = jwtUtil.createToken(userId, username, null);
String jti = jwtUtil.getJtiFromToken(accessToken);
String refreshToken = IdUtil.fastSimpleUUID();
long expireSeconds = jwtProperties.getRefreshTokenExpireDays() * 86400L;
tokenRedisManager.saveRefreshToken(userId, refreshToken, jti, expireSeconds);
log.info("用户登录成功: userId={}, jti={}", userId, jti);
return AuthToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) .expiresIn((long) jwtProperties.getAccessTokenExpireMinutes() * 60) .build(); }
public AuthToken refresh(String oldRefreshToken) { log.info("刷新令牌请求");
Long userId = tokenRedisManager.getUserIdByRefreshToken(oldRefreshToken);
if (userId == null) { log.warn("Refresh Token 无效或已过期"); throw new RuntimeException("刷新令牌无效或已过期,请重新登录"); }
String username = "User-" + userId;
AuthToken newTokenPair = login(userId, username);
tokenRedisManager.deleteRefreshToken(oldRefreshToken);
log.info("令牌刷新成功: userId={}", userId); return newTokenPair; }
public Claims validateToken(String token) { try { Claims claims = jwtUtil.parseToken(token);
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 { Claims claims = jwtUtil.parseToken(accessToken); String jti = claims.getId(); Date expiration = claims.getExpiration(); Long userId = Long.parseLong(claims.getSubject());
blacklistRedisManager.add(jti, expiration); log.info("Token 已加入黑名单: jti={}, 过期时间={}", jti, DateUtil.formatDateTime(expiration));
tokenRedisManager.deleteRefreshToken(refreshToken);
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 { Set<String> jtiSet = tokenRedisManager.getUserOnlineDevices(userId);
if (CollUtil.isEmpty(jtiSet)) { log.info("用户没有在线设备: userId={}", userId); return; }
long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L; jtiSet.forEach(jti -> blacklistRedisManager.add(jti, maxExpireSeconds));
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 { long maxExpireSeconds = jwtProperties.getAccessTokenExpireMinutes() * 60L; blacklistRedisManager.add(jti, maxExpireSeconds);
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 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;
@Slf4j @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController {
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); } }
private String extractToken(String authHeader) { if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } return authHeader; } }
|
架构优势:Controller 只注入一个 AuthService,所有的 TokenRedisManager、BlacklistRedisManager 都被隐藏在 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
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
| curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
curl -X POST "http://localhost:8080/auth/login?userId=1001&username=admin"
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
| curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <DEVICE_1_ACCESS_TOKEN>"
curl -X GET "http://localhost:8080/auth/validate" \ -H "Authorization: Bearer <DEVICE_2_ACCESS_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
|
步骤 4:等待 1 分钟后再次检查
1 2
| 127.0.0.1:6379> EXISTS auth:token:black:<JTI> (integer) 0
|
第七章. 本章小结
我们完成了 Token 黑名单机制,实现了主动注销功能,并采用了清晰的分层架构。
核心成果:
| 步骤 | 操作 | 产出 |
|---|
| 1 | 设计分层架构 | Controller → AuthService → Manager |
| 2 | 封装 Manager 层 | TokenRedisManager、BlacklistRedisManager |
| 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存取) │ │ (黑名单存取) │ └─────────────────┘ └───────────────┘ └─────────────────────┘
|
方法速查:
| 类名 | 方法名 | 作用 |
|---|
| AuthService | login(userId, username) | 登录,创建双令牌 |
| AuthService | refresh(refreshToken) | 刷新令牌 |
| AuthService | validateToken(token) | 验证 Token(含黑名单检查) |
| AuthService | logout(accessToken, refreshToken) | 单设备注销 |
| AuthService | logoutAll(userId) | 全设备注销 |
| AuthService | kickout(userId, jti) | 踢出指定设备 |
| TokenRedisManager | saveRefreshToken(...) | 存储 Refresh Token |
| TokenRedisManager | getUserIdByRefreshToken(...) | 获取用户 ID |
| TokenRedisManager | deleteRefreshToken(...) | 删除 Refresh Token |
| BlacklistRedisManager | add(jti, expiration) | 将 JTI 加入黑名单 |
| BlacklistRedisManager | exists(jti) | 检查 JTI 是否在黑名单中 |
架构优势:
- 职责清晰:Manager 只负责 CRUD,AuthService 负责业务编排
- 依赖简洁:Controller 只注入一个 AuthService,像树状结构而非蜘蛛网
- 易于测试:每一层都可以独立测试
- 易于扩展:新增功能只需在 AuthService 中添加方法
注销场景对比:
| 场景 | 方法 | 说明 |
|---|
| 用户主动退出 | authService.logout(...) | 注销当前设备 |
| 密码修改 | authService.logoutAll(...) | 注销所有设备 |
| 管理员强制下线 | authService.logoutAll(...) | 注销指定用户的所有设备 |
| 远程踢出设备 | authService.kickout(...) | 注销指定设备 |
本章引用链接
在使用 Token 黑名单的过程中,如果遇到以下问题,可以快速跳转到对应章节查阅:
架构设计相关
代码实现相关
测试验证相关