refactor: 会话失效、数据权限和实时推送重构

This commit is contained in:
Ray.Hao
2026-02-12 17:19:42 +08:00
parent 3a35b24476
commit faf6754bf4
44 changed files with 2145 additions and 515 deletions

View File

@@ -4,7 +4,9 @@ import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil;
@@ -15,6 +17,7 @@ import com.youlai.boot.core.exception.BusinessException;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.security.model.AuthenticationToken;
import com.youlai.boot.security.model.RoleDataScope;
import org.apache.commons.lang3.StringUtils;
import com.youlai.boot.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -25,17 +28,20 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* JWT Token 管理器
* <p>
* 用于生成、解析、校验、刷新 JWT Token
* 实现基于JWT的无状态认证支持
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>Token 撤销jti黑名单</li>
* <li>用户级会话失效tokenValidAfter</li>
* <li>多角色数据权限存储</li>
* </ul>
*
* @author Ray.Hao
* @since 2024/11/15
@@ -44,6 +50,9 @@ import java.util.stream.Collectors;
@Service
public class JwtTokenManager implements TokenManager {
/** tokenValidAfter 默认过期时间7天避免Redis内存泄漏 */
private static final long TOKEN_VALID_AFTER_TTL_SECONDS = 7 * 24 * 60 * 60;
private final SecurityProperties securityProperties;
private final RedisTemplate<String, Object> redisTemplate;
private final byte[] secretKey;
@@ -90,7 +99,25 @@ public class JwtTokenManager implements TokenManager {
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
// 解析数据权限列表
JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES);
if (dataScopesArray != null && !dataScopesArray.isEmpty()) {
List<RoleDataScope> dataScopes = dataScopesArray.stream()
.map(obj -> {
JSONObject item = (JSONObject) obj;
String roleCode = item.getStr("roleCode");
Integer dataScope = item.getInt("dataScope");
JSONArray deptIdsArray = item.getJSONArray("customDeptIds");
List<Long> customDeptIds = null;
if (deptIdsArray != null) {
customDeptIds = deptIdsArray.toList(Long.class);
}
return new RoleDataScope(roleCode, dataScope, customDeptIds);
})
.collect(Collectors.toList());
userDetails.setDataScopes(dataScopes);
}
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
@@ -126,9 +153,17 @@ public class JwtTokenManager implements TokenManager {
/**
* 校验令牌
* <p>
* 校验流程(按顺序执行):
* <ol>
* <li>签名验证 + 过期时间检查</li>
* <li>刷新令牌类型校验(仅刷新场景)</li>
* <li>tokenValidAfter 校验(用户级会话失效)</li>
* <li>jti 黑名单校验单Token撤销</li>
* </ol>
*
* @param token JWT Token
* @param validateRefreshToken 是否校验刷新令牌
* @param validateRefreshToken 是否校验刷新令牌类型
* @return 是否有效
*/
private boolean validateToken(String token, boolean validateRefreshToken) {
@@ -147,27 +182,28 @@ public class JwtTokenManager implements TokenManager {
return false;
}
}
// 2. 校验安全版本号(用于按用户维度失效历史 Token
// 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,将用户安全版本号 +1旧版本 Token 全部失效
// 2. 校验 tokenValidAfter(用于按用户维度失效历史 Token
// 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,更新 tokenValidAfter早于该时间签发的 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;
Object issuedAtObj = payloads.get(JWTPayload.ISSUED_AT);
long issuedAtSeconds = 0;
if (issuedAtObj instanceof Date issuedAtDate) {
issuedAtSeconds = issuedAtDate.getTime() / 1000;
}
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey);
int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0;
String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId);
Object validAfterObj = redisTemplate.opsForValue().get(validAfterKey);
long validAfterSeconds = validAfterObj != null ? Convert.toLong(validAfterObj) : 0;
// 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效
if (tokenVersion < currentVersion) {
if (issuedAtSeconds < validAfterSeconds) {
return false;
}
}
// 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效
// 3. 判断 Token 是否已被撤销(单端退出/会话注销)
// 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) {
if (isTokenRevoked(jti)) {
return false;
}
}
@@ -190,29 +226,63 @@ public class JwtTokenManager implements TokenManager {
}
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT);
// 黑名单Token Key
String blacklistTokenKey = StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, payloads.getStr(JWTPayload.JWT_ID));
revokeTokenByJti(jti, expirationAt);
}
/**
* 检查Token是否已被撤销
*
* @param jti Token唯一标识
* @return true-已撤销false-未撤销
*/
private boolean isTokenRevoked(String jti) {
if (StringUtils.isBlank(jti)) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
}
/**
* 将Token加入撤销黑名单
* <p>
* 黑名单有效期与Token剩余有效期一致避免永久存储
*
* @param jti Token唯一标识
* @param expirationAt Token过期时间戳
*/
private void revokeTokenByJti(String jti, Integer expirationAt) {
if (StringUtils.isBlank(jti)) {
return;
}
String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
// Token已过期直接返回
return;
}
// 计算Token剩余时间将其加入黑名单值使用简单布尔标记即可
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
} else {
// 永不过期的Token永久加入黑名单
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE);
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
}
}
/**
* 失效指定用户的所有会话
* <p>
* 通过提升用户的安全版本号,使携带旧版本号的 Token 在后续校验时全部失效
* 通过更新用户 tokenValidAfter 时间戳,使早于该时间签发的 Token 全部失效
* <p>
* 适用场景:
* <ul>
* <li>用户修改密码</li>
* <li>管理员强制下线用户</li>
* <li>用户主动踢出所有设备</li>
* </ul>
*
* @param userId 用户ID
*/
@Override
public void invalidateUserSessions(Long userId) {
@@ -220,10 +290,10 @@ public class JwtTokenManager implements TokenManager {
return;
}
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
// 递增版本号
redisTemplate.opsForValue().increment(versionKey);
String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId);
long nowSeconds = System.currentTimeMillis() / 1000;
// 设置过期时间避免Redis内存泄漏
redisTemplate.opsForValue().set(validAfterKey, nowSeconds, TOKEN_VALID_AFTER_TTL_SECONDS, TimeUnit.SECONDS);
}
/**
@@ -253,8 +323,8 @@ public class JwtTokenManager implements TokenManager {
* 生成 JWT Token
*
* @param authentication 认证信息
* @param ttl 过期时间
* @return JWT Token
* @param ttl 过期时间(秒),-1表示永不过期
* @return JWT Token字符串
*/
private String generateToken(Authentication authentication, int ttl) {
return generateToken(authentication, ttl, false);
@@ -263,18 +333,43 @@ public class JwtTokenManager implements TokenManager {
/**
* 生成 JWT Token
* <p>
* Payload包含
* <ul>
* <li>userId - 用户ID</li>
* <li>deptId - 部门ID</li>
* <li>dataScopes - 数据权限列表</li>
* <li>authorities - 角色权限集合</li>
* <li>tokenType - 是否为刷新令牌</li>
* <li>iat/exp - 签发/过期时间</li>
* <li>jti - Token唯一标识用于撤销</li>
* </ul>
*
* @param authentication 认证信息
* @param ttl 过期时间
* @param isRefreshToken 类型是否为刷新token
* @return JWT Token
* @param authentication 认证信息
* @param ttl 过期时间(秒)
* @param isRefreshToken 是否为刷新令牌
* @return JWT Token字符串
*/
private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
// 存储数据权限列表
List<RoleDataScope> dataScopes = userDetails.getDataScopes();
if (dataScopes != null && !dataScopes.isEmpty()) {
List<Map<String, Object>> scopesList = dataScopes.stream()
.map(scope -> {
Map<String, Object> scopeMap = new HashMap<>();
scopeMap.put("roleCode", scope.getRoleCode());
scopeMap.put("dataScope", scope.getDataScope());
scopeMap.put("customDeptIds", scope.getCustomDeptIds());
return scopeMap;
})
.collect(Collectors.toList());
payload.put(JwtClaimConstants.DATA_SCOPES, scopesList);
}
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()
@@ -289,12 +384,6 @@ 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

@@ -8,7 +8,8 @@ import com.youlai.boot.core.exception.BusinessException;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.security.model.AuthenticationToken;
import com.youlai.boot.security.model.OnlineUser;
import com.youlai.boot.security.model.UserSession;
import com.youlai.boot.security.model.RoleDataScope;
import com.youlai.boot.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
@@ -26,7 +27,15 @@ import java.util.stream.Collectors;
/**
* Redis Token 管理器
* <p>
* 用于生成、解析、校验、刷新 Redis Token
* 实现基于Redis的有状态认证支持
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>单设备/多设备登录控制</li>
* <li>用户级会话失效</li>
* <li>在线用户管理</li>
* </ul>
* <p>
* 与JWT模式相比Redis模式支持主动踢人、在线用户查询等功能
*
* @author Ray.Hao
* @since 2024/11/15
@@ -55,19 +64,19 @@ public class RedisTokenManager implements TokenManager {
String accessToken = IdUtil.fastSimpleUUID();
String refreshToken = IdUtil.fastSimpleUUID();
// 构建用户在线信息
OnlineUser onlineUser = new OnlineUser(
// 构建用户会话信息
UserSession userSession = new UserSession(
user.getUserId(),
user.getUsername(),
user.getDeptId(),
user.getDataScope(),
user.getDataScopes(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())
);
// 存储访问令牌、刷新令牌和刷新令牌映射
storeTokensInRedis(accessToken, refreshToken, onlineUser);
storeTokensInRedis(accessToken, refreshToken, userSession);
// 单设备登录控制
handleSingleDeviceLogin(user.getUserId(), accessToken);
@@ -87,13 +96,13 @@ public class RedisTokenManager implements TokenManager {
*/
@Override
public Authentication parseToken(String token) {
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token));
if (onlineUser == null) return null;
UserSession userSession = (UserSession) redisTemplate.opsForValue().get(formatTokenKey(token));
if (userSession == null) return null;
// 构建用户权限集合
Set<SimpleGrantedAuthority> authorities = null;
Set<String> roles = onlineUser.getRoles();
Set<String> roles = userSession.getRoles();
if (CollectionUtil.isNotEmpty(roles)) {
authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
@@ -101,7 +110,7 @@ public class RedisTokenManager implements TokenManager {
}
// 构建用户详情对象
SysUserDetails userDetails = buildUserDetails(onlineUser, authorities);
SysUserDetails userDetails = buildUserDetails(userSession, authorities);
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
}
@@ -135,12 +144,12 @@ public class RedisTokenManager implements TokenManager {
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue()
UserSession userSession = (UserSession) redisTemplate.opsForValue()
.get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
if (onlineUser == null) {
if (userSession == null) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
}
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()));
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()));
// 删除旧的访问令牌记录
Optional.of(oldAccessTokenValue)
.map(String.class::cast)
@@ -148,7 +157,7 @@ public class RedisTokenManager implements TokenManager {
// 生成新访问令牌并存储
String newAccessToken = IdUtil.fastSimpleUUID();
storeAccessToken(newAccessToken, onlineUser);
storeAccessToken(newAccessToken, userSession);
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
return AuthenticationToken.builder()
@@ -166,14 +175,16 @@ public class RedisTokenManager implements TokenManager {
@Override
public void invalidateToken(String token) {
Object value = redisTemplate.opsForValue().get(formatTokenKey(token));
if (value instanceof OnlineUser onlineUser) {
Long userId = onlineUser.getUserId();
if (value instanceof UserSession userSession) {
Long userId = userSession.getUserId();
invalidateUserSessions(userId);
}
}
/**
* 使指定用户的所有会话失效
* <p>
* 适用场景:用户修改密码、管理员强制下线、账号封禁等
*
* @param userId 用户ID
*/
@@ -207,24 +218,26 @@ public class RedisTokenManager implements TokenManager {
*
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
* @param onlineUser 在线用户信息
* @param userSession 用户会话信息
*/
private void storeTokensInRedis(String accessToken, String refreshToken, OnlineUser onlineUser) {
private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) {
// 访问令牌 -> 用户信息
setRedisValue(formatTokenKey(accessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive());
setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
// 刷新令牌 -> 用户信息
String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
setRedisValue(refreshTokenKey, onlineUser, securityProperties.getSession().getRefreshTokenTimeToLive());
setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive());
// 用户ID -> 刷新令牌
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, onlineUser.getUserId()),
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()),
refreshToken,
securityProperties.getSession().getRefreshTokenTimeToLive());
}
/**
* 处理单设备登录控制
* <p>
* 当配置不允许多设备登录时新登录会使旧Token失效
*
* @param userId 用户ID
* @param accessToken 新生成的访问令牌
@@ -247,27 +260,27 @@ public class RedisTokenManager implements TokenManager {
* 存储新的访问令牌
*
* @param newAccessToken 新访问令牌
* @param onlineUser 在线用户信息
* @param userSession 用户会话信息
*/
private void storeAccessToken(String newAccessToken, OnlineUser onlineUser) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId());
private void storeAccessToken(String newAccessToken, UserSession userSession) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId());
setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
/**
* 构建用户详情对象
*
* @param onlineUser 在线用户信息
* @param userSession 用户会话信息
* @param authorities 权限集合
* @return SysUserDetails 用户详情
*/
private SysUserDetails buildUserDetails(OnlineUser onlineUser, Set<SimpleGrantedAuthority> authorities) {
private SysUserDetails buildUserDetails(UserSession userSession, Set<SimpleGrantedAuthority> authorities) {
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(onlineUser.getUserId());
userDetails.setUsername(onlineUser.getUsername());
userDetails.setDeptId(onlineUser.getDeptId());
userDetails.setDataScope(onlineUser.getDataScope());
userDetails.setUserId(userSession.getUserId());
userDetails.setUsername(userSession.getUsername());
userDetails.setDeptId(userSession.getDeptId());
userDetails.setDataScopes(userSession.getDataScopes());
userDetails.setAuthorities(authorities);
return userDetails;
}