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

@@ -0,0 +1,76 @@
package com.youlai.boot.security.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
/**
* 角色数据权限信息
* <p>
* 用于存储单个角色的数据权限范围信息,支持多角色数据权限合并(并集策略)
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RoleDataScope implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 角色编码
*/
private String roleCode;
/**
* 数据权限范围值
* 1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据
*/
private Integer dataScope;
/**
* 自定义部门ID列表仅当 dataScope=5 时有效)
*/
private List<Long> customDeptIds;
/**
* 创建"全部数据"权限
*/
public static RoleDataScope all(String roleCode) {
return new RoleDataScope(roleCode, 1, null);
}
/**
* 创建"部门及子部门"权限
*/
public static RoleDataScope deptAndSub(String roleCode) {
return new RoleDataScope(roleCode, 2, null);
}
/**
* 创建"本部门"权限
*/
public static RoleDataScope dept(String roleCode) {
return new RoleDataScope(roleCode, 3, null);
}
/**
* 创建"本人"权限
*/
public static RoleDataScope self(String roleCode) {
return new RoleDataScope(roleCode, 4, null);
}
/**
* 创建"自定义部门"权限
*/
public static RoleDataScope custom(String roleCode, List<Long> deptIds) {
return new RoleDataScope(roleCode, 5, deptIds);
}
}

View File

@@ -8,59 +8,71 @@ import java.util.Collection;
/**
* 短信验证码认证 Token
* <p>
* 用于短信验证码登录场景,遵循 Spring Security 认证模型:
* <ul>
* <li>未认证状态principal 为手机号credentials 为验证码</li>
* <li>已认证状态principal 为用户详情credentials 为 null</li>
* </ul>
*
* @author Ray.Hao
* @since 2.20.0
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 621L;
/**
* 认证信息 (手机号)
* 认证信息
* <ul>
* <li>未认证时:手机号</li>
* <li>已认证时SysUserDetails 用户详情</li>
* </ul>
*/
private final Object principal;
/**
* 凭证信息 (短信验证码)
* 凭证信息
* <ul>
* <li>未认证时:短信验证码</li>
* <li>已认证时null</li>
* </ul>
*/
private final Object credentials;
/**
* 短信验证码认证 Token (未认证)
* 创建未认证 Token
*
* @param principal 微信用户信息
* @param mobile 手机号
* @param verifyCode 短信验证码
*/
public SmsAuthenticationToken(Object principal, Object credentials) {
// 没有授权信息时,设置为 null
super((Collection<? extends GrantedAuthority>) null);
this.principal = principal;
this.credentials = credentials;
// 默认未认证
this.setAuthenticated(false);
public SmsAuthenticationToken(String mobile, String verifyCode) {
super(null);
this.principal = mobile;
this.credentials = verifyCode;
setAuthenticated(false);
}
/**
* 短信验证码认证 Token (已认证)
* 创建已认证 Token
*
* @param principal 用户信息
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
*/
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = null;
// 认证通过
super.setAuthenticated(true);
}
/**
* 认证通过
* 创建已认证的 Token静态工厂方法
*
* @param principal 用户信息
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
* @return SmsAuthenticationToken
* @return 已认证的 SmsAuthenticationToken
*/
public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
return new SmsAuthenticationToken(principal, authorities);

View File

@@ -3,15 +3,13 @@ package com.youlai.boot.security.model;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.security.model.UserAuthInfo;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
import java.util.*;
import java.util.stream.Collectors;
/**
@@ -53,9 +51,11 @@ public class SysUserDetails implements UserDetails {
private Long deptId;
/**
* 数据权限范围
* 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
*/
private Integer dataScope;
private List<RoleDataScope> dataScopes;
/**
* 用户角色权限集合
@@ -73,7 +73,7 @@ public class SysUserDetails implements UserDetails {
this.password = user.getPassword();
this.enabled = ObjectUtil.equal(user.getStatus(), 1);
this.deptId = user.getDeptId();
this.dataScope = user.getDataScope();
this.dataScopes = user.getDataScopes();
// 初始化角色权限集合
this.authorities = CollectionUtil.isNotEmpty(user.getRoles())
@@ -104,4 +104,26 @@ public class SysUserDetails implements UserDetails {
public boolean isEnabled() {
return this.enabled;
}
/**
* 判断是否包含"全部数据"权限
*
* @return 是否有全部数据权限
*/
public boolean hasAllDataScope() {
if (CollectionUtil.isEmpty(dataScopes)) {
return false;
}
return dataScopes.stream()
.anyMatch(scope -> scope.getDataScope() == 1);
}
/**
* 获取数据权限列表
*
* @return 数据权限列表永不为null
*/
public List<RoleDataScope> getDataScopes() {
return dataScopes != null ? dataScopes : Collections.emptyList();
}
}

View File

@@ -2,6 +2,7 @@ package com.youlai.boot.security.model;
import lombok.Data;
import java.util.List;
import java.util.Set;
/**
@@ -52,7 +53,9 @@ public class UserAuthInfo {
private Set<String> roles;
/**
* 数据权限范围
* 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
*/
private Integer dataScope;
private List<RoleDataScope> dataScopes;
}

View File

@@ -4,10 +4,14 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Set;
/**
* 在线用户信息对象
* 用户会话信息
* <p>
* 存储在Token中的用户会话快照包含用户身份数据权限和角色权限信息
* 用于Redis-Token模式下的会话管理支持在线用户查询和会话控制
*
* @author wangtao
* @since 2025/2/27 10:31
@@ -15,7 +19,7 @@ import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OnlineUser {
public class UserSession {
/**
* 用户ID
@@ -33,10 +37,9 @@ public class OnlineUser {
private Long deptId;
/**
* 数据权限范围
* <p>定义用户可访问的数据范围如全部本部门或自定义范围</p>
* 数据权限列表
*/
private Integer dataScope;
private List<RoleDataScope> dataScopes;
/**
* 角色权限集合

View File

@@ -18,9 +18,22 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
/**
* 短信验证码认证 Provider
* <p>
* 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。
* <p>
* 认证流程:
* <ol>
* <li>根据手机号查询用户信息</li>
* <li>校验用户状态(是否禁用)</li>
* <li>校验短信验证码(与 Redis 缓存比对)</li>
* <li>验证成功后删除验证码,防止重复使用</li>
* <li>返回已认证的 Authentication</li>
* </ol>
*
* @author Ray.Hao
* @since 2.17.0
* @see SmsAuthenticationToken
* @see AuthenticationProvider
*/
@Slf4j
public class SmsAuthenticationProvider implements AuthenticationProvider {
@@ -29,58 +42,79 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
private final RedisTemplate<String, Object> redisTemplate;
public SmsAuthenticationProvider(UserService userService, RedisTemplate<String, Object> redisTemplate) {
this.userService = userService;
this.redisTemplate = redisTemplate;
}
/**
* 短信验证码认证逻辑,参考 Spring Security 认证密码校验流程
* 执行短信验证码认证
*
* @param authentication 认证对象
* @return 认证的 Authentication 对象
* @throws AuthenticationException 认证异常
* @see org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate(Authentication)
* @param authentication 认证的 {@link SmsAuthenticationToken}
* @return 认证的 {@link SmsAuthenticationToken}
* @throws AuthenticationException 认证失败异常
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String mobile = (String) authentication.getPrincipal();
String inputVerifyCode = (String) authentication.getCredentials();
// 参数校验
if (StrUtil.isBlank(mobile)) {
log.warn("短信验证码登录失败:手机号为空");
throw new CaptchaValidationException("手机号不能为空");
}
if (StrUtil.isBlank(inputVerifyCode)) {
log.warn("短信验证码登录失败:验证码为空,手机号={}", mobile);
throw new CaptchaValidationException("验证码不能为空");
}
// 根据手机号获取用户信息
UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
if (userAuthInfo == null) {
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
throw new UsernameNotFoundException("用户不存在");
}
// 检查用户状态是否有效
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername());
throw new DisabledException("用户已被禁用");
}
// 校验发送短信验证码的手机号是否与当前登录用户一致
// 校验短信验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
throw new CaptchaValidationException("验证码错误");
} else {
// 验证成功后删除验证码
redisTemplate.delete(cacheKey);
if (cachedVerifyCode == null) {
log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile);
throw new CaptchaValidationException("验证码已过期,请重新获取");
}
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile);
throw new CaptchaValidationException("验证码错误");
}
// 验证成功后删除验证码,防止重复使用
redisTemplate.delete(cacheKey);
// 构建认证后的用户详情信息
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile);
// 创建已认证的 SmsAuthenticationToken
return SmsAuthenticationToken.authenticated(
userDetails,
userDetails.getAuthorities()
);
return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
}
/**
* 支持的认证类型
*
* @param authentication 认证类型
* @return 是否支持该认证类型
*/
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);

View File

@@ -2,18 +2,21 @@ package com.youlai.boot.security.service;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.security.util.SecurityUtils;
import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.PatternMatchUtils;
import java.util.*;
import java.util.Set;
/**
* SpringSecurity 权限校验
* Spring Security 权限校验组件
* <p>
* 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:add')")
* <p>
* 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存)
*
* @author Ray.Hao
* @since 0.0.1
@@ -23,19 +26,21 @@ import java.util.*;
@Slf4j
public class PermissionService {
private final RedisTemplate<String, Object> redisTemplate;
private final RoleMenuService roleMenuService;
/**
* 判断当前登录用户是否拥有操作权限
* <p>
* 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:add"、"sys:user:delete" 等
*
* @param requiredPerm 所需权限
* @return 是否有权限
*/
public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) {
return false;
}
// 超级管理员放行
if (SecurityUtils.isRoot()) {
return true;
@@ -47,52 +52,21 @@ public class PermissionService {
return false;
}
// 获取当前登录用户的所有角色的权限列表
Set<String> rolePerms = this.getRolePermsFormCache(roleCodes);
// 获取当前登录用户的所有角色的权限列表(从缓存读取)
Set<String> rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes);
if (CollectionUtil.isEmpty(rolePerms)) {
return false;
}
// 判断当前登录用户的所有角色的权限列表中是否包含所需权限
// 判断权限列表中是否包含所需权限(支持通配符)
boolean hasPermission = rolePerms.stream()
.anyMatch(rolePerm ->
// 匹配权限,支持通配符(* 等)
PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)
);
.anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm));
if (!hasPermission) {
log.error("用户无操作权限:{}",requiredPerm);
log.warn("用户无操作权限:userId={}, username={}, requiredPerm={}",
SecurityUtils.getUserId(), SecurityUtils.getUsername(), requiredPerm);
}
return hasPermission;
}
/**
* 从缓存中获取角色权限列表
*
* @param roleCodes 角色编码集合
* @return 角色权限列表
*/
public Set<String> getRolePermsFormCache(Set<String> roleCodes) {
if (CollectionUtil.isEmpty(roleCodes)) {
return Collections.emptySet();
}
// 构建缓存Key
String cacheKey = RedisConstants.System.ROLE_PERMS;
Set<String> perms = new HashSet<>();
Collection<Object> roleCodesAsObjects = new ArrayList<>(roleCodes);
List<Object> rolePermsList = redisTemplate.opsForHash().multiGet(cacheKey, roleCodesAsObjects);
for (Object rolePermsObj : rolePermsList) {
if (rolePermsObj instanceof Set) {
@SuppressWarnings("unchecked")
Set<String> rolePerms = (Set<String>) rolePermsObj;
perms.addAll(rolePerms);
}
}
return perms;
}
}

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;
}

View File

@@ -70,16 +70,6 @@ public class SecurityUtils {
return getUser().map(SysUserDetails::getDeptId).orElse(null);
}
/**
* 获取数据权限范围
*
* @return Integer
*/
public static Integer getDataScope() {
return getUser().map(SysUserDetails::getDataScope).orElse(null);
}
/**
* 获取角色集合
*