From 289f79cdb4f18b8d1aae6c252e09eee510e173ce Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Tue, 2 Dec 2025 17:34:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=A2=9E=E5=8A=A0=20JWT=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E7=89=88=E6=9C=AC=E5=8F=B7=E4=B8=8E=20TokenM?= =?UTF-8?q?anager.invalidateUserSessions=EF=BC=8C=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E4=B8=8E=E5=AF=86=E7=A0=81=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E7=9A=84=E6=8C=89=E7=94=A8=E6=88=B7=E4=B8=8B=E7=BA=BF=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #ID8B31 --- .../common/constant/JwtClaimConstants.java | 5 ++ .../boot/common/constant/RedisConstants.java | 2 + .../boot/security/token/JwtTokenManager.java | 58 ++++++++++++--- .../security/token/RedisTokenManager.java | 70 ++++++++++++------- .../boot/security/token/TokenManager.java | 8 +++ .../system/controller/UserController.java | 12 ++-- .../boot/system/service/UserService.java | 8 +-- .../service/impl/UserRoleServiceImpl.java | 6 +- .../system/service/impl/UserServiceImpl.java | 20 +++--- 9 files changed, 132 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java index 8f6e2d4f..c0a84a94 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -35,4 +35,9 @@ public interface JwtClaimConstants { */ String AUTHORITIES = "authorities"; + /** + * 安全版本号,用于按用户失效历史令牌 + */ + String SECURITY_VERSION = "securityVersion"; + } diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 717a7614..195f14af 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -36,6 +36,8 @@ public interface RedisConstants { String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; // 黑名单 Token(用于退出登录或注销) String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; + // 用户安全版本号(用于按用户失效历史 JWT) + String USER_SECURITY_VERSION = "auth:user:security_version:{}"; } /** diff --git a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java index 627a533d..bba10d7c 100644 --- a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java @@ -110,7 +110,7 @@ public class JwtTokenManager implements TokenManager { */ @Override public boolean validateToken(String token) { - return validateToken(token,false); + return validateToken(token, false); } /** @@ -121,7 +121,7 @@ public class JwtTokenManager implements TokenManager { */ @Override public boolean validateRefreshToken(String refreshToken) { - return validateToken(refreshToken,true); + return validateToken(refreshToken, true); } /** @@ -138,17 +138,34 @@ public class JwtTokenManager implements TokenManager { boolean isValid = jwt.setKey(secretKey).validate(0); if (isValid) { - // 检查 Token 是否已被加入黑名单(注销、修改密码等场景) JSONObject payloads = jwt.getPayloads(); + // 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用) String jti = payloads.getStr(JWTPayload.JWT_ID); - if(validateRefreshToken) { + if (validateRefreshToken) { //刷新token需要校验token类别 boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE); if (!isRefreshToken) { return false; } } - // 判断是否在黑名单中,如果在,则返回 false 标识Token无效 + // 2. 校验安全版本号(用于按用户维度失效历史 Token) + Long userId = payloads.getLong(JwtClaimConstants.USER_ID); + if (userId != null) { + // 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本 + Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION); + int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0; + + String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); + Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey); + int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0; + + // 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效 + if (tokenVersion < currentVersion) { + return false; + } + } + + // 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效 if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) { return false; } @@ -167,7 +184,7 @@ public class JwtTokenManager implements TokenManager { */ @Override public void invalidateToken(String token) { - if(StringUtils.isBlank(token)) { + if (StringUtils.isBlank(token)) { return; } @@ -186,16 +203,33 @@ public class JwtTokenManager implements TokenManager { // Token已过期,直接返回 return; } - // 计算Token剩余时间,将其加入黑名单 + // 计算Token剩余时间,将其加入黑名单(值使用简单布尔标记即可) int expirationIn = expirationAt - currentTimeSeconds; - redisTemplate.opsForValue().set(blacklistTokenKey, null, expirationIn, TimeUnit.SECONDS); + redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); } else { // 永不过期的Token永久加入黑名单 - redisTemplate.opsForValue().set(blacklistTokenKey, null); + redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE); } ; } + /** + * 失效指定用户的所有会话 + *
+ * 通过提升用户的安全版本号,使携带旧版本号的 Token 在后续校验时全部失效
+ */
+ @Override
+ public void invalidateUserSessions(Long userId) {
+ if (userId == null) {
+ return;
+ }
+
+ String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
+ // 递增版本号
+ redisTemplate.opsForValue().increment(versionKey);
+
+ }
+
/**
* 刷新令牌
*
@@ -259,6 +293,12 @@ public class JwtTokenManager implements TokenManager {
payload.put(JwtClaimConstants.TOKEN_TYPE, true);
}
+ // 设置安全版本号:不存在时默认为 0
+ String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userDetails.getUserId());
+ Integer currentVersion = (Integer) redisTemplate.opsForValue().get(versionKey);
+ int securityVersion = currentVersion != null ? currentVersion : 0;
+ payload.put(JwtClaimConstants.SECURITY_VERSION, securityVersion);
+
// 设置过期时间 -1 表示永不过期
if (ttl != -1) {
Date expiresAt = DateUtil.offsetSecond(now, ttl);
diff --git a/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java
index a2dff3a5..7efefaae 100644
--- a/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java
+++ b/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java
@@ -18,6 +18,7 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -134,17 +135,16 @@ public class RedisTokenManager implements TokenManager {
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
- OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
+ OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue()
+ .get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
if (onlineUser == null) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
}
-
- String oldAccessToken = (String) redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()));
-
+ Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()));
// 删除旧的访问令牌记录
- if (oldAccessToken != null) {
- redisTemplate.delete(formatTokenKey(oldAccessToken));
- }
+ Optional.of(oldAccessTokenValue)
+ .map(String.class::cast)
+ .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken)));
// 生成新访问令牌并存储
String newAccessToken = IdUtil.fastSimpleUUID();
@@ -168,24 +168,42 @@ public class RedisTokenManager implements TokenManager {
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token));
if (onlineUser != null) {
Long userId = onlineUser.getUserId();
- // 1. 删除访问令牌相关
- String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
- String accessToken = (String) redisTemplate.opsForValue().get(userAccessKey);
- if (accessToken != null) {
- redisTemplate.delete(formatTokenKey(accessToken));
- redisTemplate.delete(userAccessKey);
- }
-
- // 2. 删除刷新令牌相关
- String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId);
- String refreshToken = (String) redisTemplate.opsForValue().get(userRefreshKey);
- if (refreshToken != null) {
- redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
- redisTemplate.delete(userRefreshKey);
- }
+ invalidateUserSessions(userId);
}
}
+ /**
+ * 使指定用户的所有会话失效
+ *
+ * @param userId 用户ID
+ */
+ @Override
+ public void invalidateUserSessions(Long userId) {
+ if (userId == null) {
+ return;
+ }
+
+ // 1. 删除访问令牌相关
+ String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
+ Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
+ Optional.of(accessTokenValue)
+ .map(String.class::cast)
+ .ifPresent(accessToken -> redisTemplate.delete(formatTokenKey(accessToken)));
+ // 无论是否存在访问令牌映射,都尝试删除 userAccessKey
+ redisTemplate.delete(userAccessKey);
+
+ // 2. 删除刷新令牌相关
+ String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId);
+ Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey);
+ Optional.of(refreshTokenValue)
+ .map(String.class::cast)
+ .ifPresent(refreshToken ->
+ redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken))
+ );
+ // 同样清理 userRefreshKey 本身
+ redisTemplate.delete(userRefreshKey);
+ }
+
/**
* 将访问令牌和刷新令牌存储至 Redis
*
@@ -218,10 +236,10 @@ public class RedisTokenManager implements TokenManager {
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
// 单设备登录控制,删除旧的访问令牌
if (!allowMultiLogin) {
- String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey);
- if (oldAccessToken != null) {
- redisTemplate.delete(formatTokenKey(oldAccessToken));
- }
+ Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
+ Optional.of(oldAccessTokenValue)
+ .map(String.class::cast)
+ .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken)));
}
// 存储访问令牌映射(用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌
setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive());
diff --git a/src/main/java/com/youlai/boot/security/token/TokenManager.java b/src/main/java/com/youlai/boot/security/token/TokenManager.java
index 3c051dc4..9cadd3bb 100644
--- a/src/main/java/com/youlai/boot/security/token/TokenManager.java
+++ b/src/main/java/com/youlai/boot/security/token/TokenManager.java
@@ -64,5 +64,13 @@ public interface TokenManager {
// throw new UnsupportedOperationException("Not implemented");
}
+ /**
+ * 使指定用户的所有会话失效
+ *
+ * @param userId 用户ID
+ */
+ default void invalidateUserSessions(Long userId) {
+ // 默认空实现,由具体 TokenManager 决定是否支持按用户下线
+ }
}
diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java
index 7b7d98ec..644777df 100644
--- a/src/main/java/com/youlai/boot/system/controller/UserController.java
+++ b/src/main/java/com/youlai/boot/system/controller/UserController.java
@@ -195,24 +195,24 @@ public class UserController {
return Result.judge(result);
}
- @Operation(summary = "重置用户密码")
+ @Operation(summary = "重置指定用户密码")
@PutMapping(value = "/{userId}/password/reset")
@PreAuthorize("@ss.hasPerm('sys:user:reset-password')")
- public Result> resetPassword(
+ public Result> resetUserPassword(
@Parameter(description = "用户ID") @PathVariable Long userId,
@RequestParam String password
) {
- boolean result = userService.resetPassword(userId, password);
+ boolean result = userService.resetUserPassword(userId, password);
return Result.judge(result);
}
- @Operation(summary = "修改密码")
+ @Operation(summary = "当前用户修改密码")
@PutMapping(value = "/password")
- public Result> changePassword(
+ public Result> changeCurrentUserPassword(
@RequestBody PasswordUpdateForm data
) {
Long currUserId = SecurityUtils.getUserId();
- boolean result = userService.changePassword(currUserId, data);
+ boolean result = userService.changeUserPassword(currUserId, data);
return Result.judge(result);
}
diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java
index 9b8660ba..2ee3ca09 100644
--- a/src/main/java/com/youlai/boot/system/service/UserService.java
+++ b/src/main/java/com/youlai/boot/system/service/UserService.java
@@ -107,22 +107,22 @@ public interface UserService extends IService