fix(auth): 增加 JWT 安全版本号与 TokenManager.invalidateUserSessions,统一角色与密码变更的按用户下线行为

Closes #ID8B31
This commit is contained in:
Ray.Hao
2025-12-02 17:34:36 +08:00
parent 6e8769ccb7
commit 289f79cdb4
9 changed files with 132 additions and 57 deletions

View File

@@ -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);
}
;
}
/**
* 失效指定用户的所有会话
* <p>
* 通过提升用户的安全版本号,使携带旧版本号的 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);

View File

@@ -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());

View File

@@ -64,5 +64,13 @@ public interface TokenManager {
// throw new UnsupportedOperationException("Not implemented");
}
/**
* 使指定用户的所有会话失效
*
* @param userId 用户ID
*/
default void invalidateUserSessions(Long userId) {
// 默认空实现,由具体 TokenManager 决定是否支持按用户下线
}
}