refactor: 优化 redis-token 会话管理和单一会话控制
This commit is contained in:
@@ -26,10 +26,16 @@ public interface RedisConstants {
|
||||
* 认证模块
|
||||
*/
|
||||
interface Auth {
|
||||
|
||||
String ACCESS_TOKEN = "auth:token:access:{}"; // 访问Token
|
||||
String REFRESH_TOKEN = "auth:token:refresh:{}"; // 刷新Token
|
||||
String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; // 黑名单Token
|
||||
// 存储访问令牌对应的用户信息(accessToken -> OnlineUser)
|
||||
String ACCESS_TOKEN_USER = "auth:token:access:{}";
|
||||
// 存储刷新令牌对应的用户信息(refreshToken -> OnlineUser)
|
||||
String REFRESH_TOKEN_USER = "auth:token:refresh:{}";
|
||||
// 用户与访问令牌的映射(userId -> accessToken)
|
||||
String USER_ACCESS_TOKEN = "auth:user:access:{}";
|
||||
// 用户与刷新令牌的映射(userId -> refreshToken
|
||||
String USER_REFRESH_TOKEN = "auth:user:refresh:{}";
|
||||
// 黑名单 Token(用于退出登录或注销)
|
||||
String BLACKLIST_TOKEN = "auth:token:blacklist:{}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.youlai.boot.common.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* @Description TODO
|
||||
* @Author wangtao
|
||||
* @Date 2025/2/27 14:48
|
||||
*/
|
||||
@Getter
|
||||
public enum TokenKeyEnum {
|
||||
ACCESS_TOKEN_KEY("access_token:"),
|
||||
REFRESH_TOKEN_KEY ("refresh_token:");
|
||||
|
||||
private final String value;
|
||||
|
||||
TokenKeyEnum(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import com.youlai.boot.core.security.extension.sms.SmsAuthenticationProvider;
|
||||
import com.youlai.boot.core.security.extension.wechat.WechatAuthenticationProvider;
|
||||
import com.youlai.boot.core.security.filter.CaptchaValidationFilter;
|
||||
import com.youlai.boot.core.security.filter.TokenAuthenticationFilter;
|
||||
import com.youlai.boot.core.security.manager.TokenManager;
|
||||
import com.youlai.boot.core.security.token.TokenManager;
|
||||
import com.youlai.boot.core.security.service.SysUserDetailsService;
|
||||
import com.youlai.boot.system.service.ConfigService;
|
||||
import com.youlai.boot.system.service.UserService;
|
||||
|
||||
@@ -1,108 +1,111 @@
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 安全配置属性
|
||||
* 安全模块配置属性类
|
||||
*
|
||||
* <p>映射 application.yml 中 security 前缀的安全相关配置</p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/4/18
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "security")
|
||||
public class SecurityProperties {
|
||||
|
||||
/**
|
||||
* 免认证请求路径白名单
|
||||
* 会话管理配置
|
||||
*/
|
||||
private List<String> ignoreUrls = new ArrayList<>();
|
||||
private SessionConfig session;
|
||||
|
||||
/**
|
||||
* 静态资源路径(不经过安全过滤器)
|
||||
* 安全白名单路径(完全绕过安全过滤器)
|
||||
* <p>示例值:/api/v1/auth/login/**, /ws/**
|
||||
*/
|
||||
private List<String> unsecuredUrls = new ArrayList<>();
|
||||
@NotEmpty
|
||||
private String[] ignoreUrls;
|
||||
|
||||
/**
|
||||
* 认证核心配置
|
||||
* 非安全端点路径(允许匿名访问的API)
|
||||
* <p>示例值:/doc.html, /v3/api-docs/**
|
||||
*/
|
||||
private Auth auth = new Auth();
|
||||
@NotEmpty
|
||||
private String[] unsecuredUrls;
|
||||
|
||||
/**
|
||||
* 会话配置嵌套类
|
||||
*/
|
||||
@Data
|
||||
public static class Auth {
|
||||
public static class SessionConfig {
|
||||
/**
|
||||
* 认证策略类型
|
||||
* <ul>
|
||||
* <li>jwt - 基于JWT的无状态认证</li>
|
||||
* <li>redis-token - 基于Redis的有状态认证</li>
|
||||
* </ul>
|
||||
*/
|
||||
@NotNull
|
||||
private AuthType type = AuthType.JWT;
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 访问令牌有效期(秒)
|
||||
* 访问令牌有效期(单位:秒)
|
||||
* <p>默认值:3600(1小时)</p>
|
||||
* <p>-1 表示永不过期</p>
|
||||
*/
|
||||
@Min(-1)
|
||||
private int accessTokenTtl = 3600;
|
||||
private Integer accessTokenTimeToLive = 3600;
|
||||
|
||||
/**
|
||||
* 刷新令牌有效期(秒)
|
||||
* 刷新令牌有效期(单位:秒)
|
||||
* <p>默认值:604800(7天)</p>
|
||||
* <p>-1 表示永不过期</p>
|
||||
*/
|
||||
@Min(-1)
|
||||
private int refreshTokenTtl = 604800;
|
||||
private Integer refreshTokenTimeToLive = 604800;
|
||||
|
||||
/**
|
||||
* JWT 配置
|
||||
* JWT 配置项
|
||||
*/
|
||||
private JwtConfig jwtConfig = new JwtConfig();
|
||||
private JwtConfig jwt;
|
||||
|
||||
/**
|
||||
* Redis Token 配置
|
||||
* Redis令牌配置项
|
||||
*/
|
||||
private RedisTokenConfig redisTokenConfig = new RedisTokenConfig();
|
||||
|
||||
@Data
|
||||
public static class JwtConfig {
|
||||
/**
|
||||
* JWT 密钥
|
||||
*/
|
||||
@NotBlank
|
||||
@Size(min = 32, message = "HS256算法密钥至少需要32字符")
|
||||
private String key;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RedisTokenConfig {
|
||||
/**
|
||||
* 最大并发会话数
|
||||
*/
|
||||
@Min(-1)
|
||||
private int maxSessions = 1;
|
||||
|
||||
/**
|
||||
* 会话超限处理策略
|
||||
*/
|
||||
private SessionControlStrategy sessionControl = SessionControlStrategy.REVOKE_OLDEST;
|
||||
}
|
||||
private RedisTokenConfig redisToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证策略类型枚举
|
||||
* JWT 配置嵌套类
|
||||
*/
|
||||
public enum AuthType {
|
||||
JWT, REDIS_TOKEN
|
||||
@Data
|
||||
public static class JwtConfig {
|
||||
/**
|
||||
* JWT签名密钥
|
||||
* <p>HS256算法要求至少32个字符</p>
|
||||
* <p>示例:SecretKey012345678901234567890123456789</p>
|
||||
*/
|
||||
@NotNull
|
||||
private String secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话控制策略枚举
|
||||
* Redis令牌配置嵌套类
|
||||
*/
|
||||
public enum SessionControlStrategy {
|
||||
REVOKE_OLDEST, DENY_NEW
|
||||
@Data
|
||||
public static class RedisTokenConfig {
|
||||
/**
|
||||
* 是否允许多设备同时登录
|
||||
* <p>true - 允许同一账户多设备登录(默认)</p>
|
||||
* <p>false - 新登录会使旧令牌失效</p>
|
||||
*/
|
||||
private Boolean allowMultiLogin = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.common.util.ResponseUtils;
|
||||
import com.youlai.boot.core.security.manager.TokenManager;
|
||||
import com.youlai.boot.core.security.token.TokenManager;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
package com.youlai.boot.core.security.manager;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import com.youlai.boot.common.enums.TokenKeyEnum;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.core.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.core.security.model.OnlineUser;
|
||||
import com.youlai.boot.core.security.model.SysUserDetails;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Redis Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 JWT Token
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
*/
|
||||
@ConditionalOnProperty(value = "security.auth.type", havingValue = "redis-token")
|
||||
@Service
|
||||
public class RedisTokenManager implements TokenManager {
|
||||
|
||||
// 常量定义
|
||||
private static final String USER_SESSION_MAP = "user_sessions:%s"; // %s=token_type
|
||||
private static final String SESSION_KEY = "session:%s:%s"; // %s=token_type,token
|
||||
private static final String SESSION_QUEUE = "session_queue:%s"; // %s=user_id
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
public RedisTokenManager(
|
||||
SecurityProperties securityProperties,
|
||||
RedisTemplate<String, Object> redisTemplate
|
||||
) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成令牌
|
||||
*
|
||||
* @param authentication 用户认证信息
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken generateToken(Authentication authentication) {
|
||||
int accessTokenTtl = securityProperties.getAuth().getAccessTokenTtl();
|
||||
|
||||
// 创建新会话
|
||||
String accessToken = createNewSession(authentication, );
|
||||
|
||||
// 创建刷新令牌(独立控制)
|
||||
String refreshToken = createNewSession(authentication, TokenType.REFRESH,
|
||||
config.getRefreshTokenTtl(), 1); // 刷新令牌强制单设备
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.tokenType("Bearer")
|
||||
.expiresIn(accessTokenTtl)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String createNewSession(Authentication authentication,
|
||||
TokenType tokenType,
|
||||
int ttl,
|
||||
int maxSessions) {
|
||||
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
|
||||
Long userId = userDetails.getUserId();
|
||||
String token = UUID.randomUUID().toString();
|
||||
|
||||
// 会话存储
|
||||
String sessionKey = keyGenerator.getSessionKey(tokenType, token);
|
||||
redisTemplate.opsForValue().set(sessionKey, buildOnlineUser(userDetails), ttl, TimeUnit.SECONDS);
|
||||
|
||||
// 用户-会话映射
|
||||
String userMapKey = keyGenerator.getUserSessionMapKey(tokenType);
|
||||
redisTemplate.opsForHash().put(userMapKey, userId.toString(), token);
|
||||
redisTemplate.expire(userMapKey, ttl, TimeUnit.SECONDS);
|
||||
|
||||
// 多设备控制
|
||||
enforceMaxSessions(userId, token, tokenType, maxSessions);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private void enforceMaxSessions(Long userId, String currentToken, TokenType tokenType, int maxSessions) {
|
||||
if (maxSessions <= 0) return;
|
||||
|
||||
String sessionQueueKey = keyGenerator.getSessionQueueKey(userId);
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
// 使用ZSet维护会话队列
|
||||
redisTemplate.opsForZSet().add(sessionQueueKey, currentToken, now);
|
||||
redisTemplate.expire(sessionQueueKey, 7, TimeUnit.DAYS);
|
||||
|
||||
// 移除超出数量的旧会话
|
||||
long excess = redisTemplate.opsForZSet().size(sessionQueueKey) - maxSessions;
|
||||
if (excess > 0) {
|
||||
Set<String> oldTokens = redisTemplate.opsForZSet().range(sessionQueueKey, 0, excess - 1);
|
||||
redisTemplate.opsForZSet().removeRange(sessionQueueKey, 0, excess - 1);
|
||||
|
||||
// 吊销旧令牌
|
||||
oldTokens.forEach(oldToken -> {
|
||||
redisTemplate.delete(keyGenerator.getSessionKey(tokenType, oldToken));
|
||||
redisTemplate.opsForHash().delete(
|
||||
keyGenerator.getUserSessionMapKey(tokenType),
|
||||
userId.toString()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析令牌
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Authentication parseToken(String token) {
|
||||
String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token;
|
||||
OnlineUser user = (OnlineUser) redisTemplate.opsForValue().get(accessTokenKey);
|
||||
Set<SimpleGrantedAuthority> authorities = user.getAuthorities()
|
||||
.stream()
|
||||
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
|
||||
.collect(Collectors.toSet());
|
||||
SysUserDetails userDetails = new SysUserDetails();
|
||||
userDetails.setUserId(user.getId());
|
||||
userDetails.setUsername(user.getUsername());
|
||||
userDetails.setDeptId(user.getDeptId());
|
||||
userDetails.setDataScope(user.getDataScope());
|
||||
userDetails.setAuthorities(authorities);
|
||||
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证令牌
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public boolean validateToken(String token) {
|
||||
String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token;
|
||||
return redisTemplate.hasKey(accessTokenKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
*
|
||||
* @param token 刷新令牌
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken refreshToken(String token) {
|
||||
String refreshTokenKey = TokenKeyEnum.REFRESH_TOKEN_KEY.getValue() + token;
|
||||
Authentication authentication = (Authentication) redisTemplate.opsForValue().get(refreshTokenKey);
|
||||
if (authentication == null) {
|
||||
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
int accessTokenExpiration = securityProperties.getAuth().getRefreshTokenTtl();
|
||||
// 生成新的访问令牌
|
||||
String newAccessToken = generateToken(authentication, TokenKeyEnum.ACCESS_TOKEN_KEY, accessTokenExpiration, true);
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
.accessToken(newAccessToken)
|
||||
.refreshToken(token)
|
||||
.expiresIn(accessTokenExpiration)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建令牌
|
||||
*
|
||||
* @param authentication 认证信息
|
||||
* @param tokenKeyEnum 令牌类型
|
||||
* @param ttl 有效期
|
||||
* @param multiLogin 是否允许多点登录
|
||||
* @return
|
||||
*/
|
||||
private String generateToken(Authentication authentication, TokenKeyEnum tokenKeyEnum, int ttl, boolean multiLogin) {
|
||||
// 获取用户信息
|
||||
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
|
||||
String token = UUID.randomUUID().toString();
|
||||
String tokenKey = tokenKeyEnum.getValue() + token;
|
||||
// 不允许多点登录,使用hashmap存储在线用户id和token
|
||||
if (!multiLogin) {
|
||||
// 查找当前用户id是否有token,有的话,说明已经登录了,就删除旧的token
|
||||
String oldToken = (String) redisTemplate.opsForHash().get("userId-token:" + tokenKeyEnum.getValue(), userDetails.getUserId().toString());
|
||||
if (StringUtils.isNotBlank(oldToken)) {
|
||||
redisTemplate.opsForHash().delete("userId-token:" + tokenKeyEnum.getValue(), userDetails.getUserId().toString());
|
||||
redisTemplate.delete(tokenKeyEnum.getValue() + oldToken);
|
||||
}
|
||||
redisTemplate.opsForHash().put("userId-token:" + tokenKeyEnum.getValue(), userDetails.getUserId().toString(), token);
|
||||
// 设置userId-token的过期时间
|
||||
redisTemplate.opsForHash().getOperations().expire("userId-token:" + tokenKeyEnum.getValue(), ttl, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// 存储用户信息
|
||||
OnlineUser user = new OnlineUser();
|
||||
user.setId(userDetails.getUserId());
|
||||
user.setUsername(userDetails.getUsername());
|
||||
user.setDeptId(userDetails.getDeptId());
|
||||
user.setDataScope(userDetails.getDataScope());
|
||||
|
||||
// claims 中添加角色信息
|
||||
Set<String> roles = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.toSet());
|
||||
user.setAuthorities(roles);
|
||||
redisTemplate.opsForValue().set(tokenKey, user, ttl, TimeUnit.SECONDS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除redis中的用户信息
|
||||
*
|
||||
* @param token Redis Token
|
||||
*/
|
||||
@Override
|
||||
public void blacklistToken(String token) {
|
||||
/**
|
||||
* 根据token,删除当前用户的accessToken和refreshToken,以及 userId-token 的hashmap
|
||||
*/
|
||||
OnlineUser user = (OnlineUser) redisTemplate.opsForValue().get(TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token);
|
||||
if (!Objects.isNull(user)) {
|
||||
Long userId = user.getId();
|
||||
String refreshToken = (String) redisTemplate.opsForHash().get("userId-token:" + TokenKeyEnum.REFRESH_TOKEN_KEY.getValue(), user.getId().toString());
|
||||
|
||||
redisTemplate.delete(TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token);
|
||||
redisTemplate.delete(TokenKeyEnum.REFRESH_TOKEN_KEY.getValue() + refreshToken);
|
||||
// 删除 userId-token 的hashmap
|
||||
redisTemplate.opsForHash().delete("userId-token:" + TokenKeyEnum.ACCESS_TOKEN_KEY.getValue(), userId.toString());
|
||||
redisTemplate.opsForHash().delete("userId-token:" + TokenKeyEnum.REFRESH_TOKEN_KEY.getValue(), userId.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,45 @@
|
||||
package com.youlai.boot.core.security.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 在线用户信息对象
|
||||
*
|
||||
* @author wangtao
|
||||
* @since 2025/2/27 10:31
|
||||
*/
|
||||
@Data
|
||||
public class OnlineUser{
|
||||
private Long id;
|
||||
private Long deptId;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class OnlineUser {
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 部门ID
|
||||
*/
|
||||
private Long deptId;
|
||||
|
||||
/**
|
||||
* 数据权限范围
|
||||
* <p>定义用户可访问的数据范围,如全部、本部门或自定义范围</p>
|
||||
*/
|
||||
private Integer dataScope;
|
||||
|
||||
/**
|
||||
* 角色权限集合
|
||||
*/
|
||||
private Set<String> authorities;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.core.security.manager;
|
||||
package com.youlai.boot.core.security.token;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
@@ -38,7 +38,7 @@ import java.util.stream.Collectors;
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
*/
|
||||
@ConditionalOnProperty(value = "security.auth.type", havingValue = "jwt")
|
||||
@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt")
|
||||
@Service
|
||||
public class JwtTokenManager implements TokenManager {
|
||||
|
||||
@@ -49,7 +49,7 @@ public class JwtTokenManager implements TokenManager {
|
||||
public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate<String, Object> redisTemplate) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.secretKey = securityProperties.getAuth().getJwtConfig().getKey().getBytes();
|
||||
this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +60,8 @@ public class JwtTokenManager implements TokenManager {
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken generateToken(Authentication authentication) {
|
||||
int accessTokenTimeToLive = securityProperties.getAuth().getAccessTokenTtl();
|
||||
int refreshTokenTimeToLive = securityProperties.getAuth().getRefreshTokenTtl();
|
||||
int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive();
|
||||
int refreshTokenTimeToLive = securityProperties.getSession().getRefreshTokenTimeToLive();
|
||||
|
||||
String accessToken = generateToken(authentication, accessTokenTimeToLive);
|
||||
String refreshToken = generateToken(authentication, refreshTokenTimeToLive);
|
||||
@@ -175,7 +175,7 @@ public class JwtTokenManager implements TokenManager {
|
||||
}
|
||||
|
||||
Authentication authentication = parseToken(refreshToken);
|
||||
int accessTokenExpiration = securityProperties.getAuth().getRefreshTokenTtl();
|
||||
int accessTokenExpiration = securityProperties.getSession().getRefreshTokenTimeToLive();
|
||||
String newAccessToken = generateToken(authentication, accessTokenExpiration);
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.youlai.boot.core.security.token;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.core.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.core.security.model.OnlineUser;
|
||||
import com.youlai.boot.core.security.model.SysUserDetails;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Redis Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 JWT Token
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
*/
|
||||
@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token")
|
||||
@Service
|
||||
public class RedisTokenManager implements TokenManager {
|
||||
|
||||
// 安全配置属性
|
||||
private final SecurityProperties securityProperties;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
public RedisTokenManager(SecurityProperties securityProperties,
|
||||
RedisTemplate<String, Object> redisTemplate) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationToken generateToken(Authentication authentication) {
|
||||
SysUserDetails user = (SysUserDetails) authentication.getPrincipal();
|
||||
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
|
||||
int refreshTtl = securityProperties.getSession().getRefreshTokenTimeToLive();
|
||||
|
||||
// 生成随机令牌
|
||||
String accessToken = IdUtil.fastSimpleUUID();
|
||||
String refreshToken = IdUtil.fastSimpleUUID();
|
||||
|
||||
// 构建用户在线信息(不包含密码)
|
||||
OnlineUser onlineUser = buildOnlineUser(user);
|
||||
|
||||
// 将访问令牌与刷新令牌与用户信息分别存入 Redis,并设置过期时间
|
||||
redisTemplate.opsForValue().set(
|
||||
StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, accessToken),
|
||||
onlineUser,
|
||||
accessTtl,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
redisTemplate.opsForValue().set(
|
||||
StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken),
|
||||
onlineUser,
|
||||
refreshTtl,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
|
||||
// 单设备登录控制,若不允许多设备登录,则通过用户ID映射保存当前最新的访问令牌
|
||||
Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin();
|
||||
if (!allowMultiLogin) {
|
||||
Long userId = user.getUserId();
|
||||
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
|
||||
// 获取当前用户已有的访问令牌
|
||||
String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey);
|
||||
if (oldAccessToken != null) {
|
||||
// 删除旧的访问令牌对应的用户信息缓存
|
||||
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, oldAccessToken));
|
||||
}
|
||||
// 更新用户与访问令牌的映射
|
||||
redisTemplate.opsForValue().set(userAccessKey, accessToken, accessTtl, TimeUnit.SECONDS);
|
||||
}
|
||||
// 同时存储用户与刷新令牌的映射,便于后续刷新和踢出旧会话
|
||||
String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, user.getUserId());
|
||||
redisTemplate.opsForValue().set(userRefreshKey, refreshToken, refreshTtl, TimeUnit.SECONDS);
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.expiresIn(accessTtl)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 token 解析用户信息
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Authentication parseToken(String token) {
|
||||
// 根据访问令牌从 Redis 中获取在线用户信息
|
||||
String tokenUserCacheKey = StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token);
|
||||
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(tokenUserCacheKey);
|
||||
|
||||
if (onlineUser == null) return null;
|
||||
|
||||
// 构建用户权限集合
|
||||
Set<SimpleGrantedAuthority> authorities = onlineUser.getAuthorities().stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 构建用户详情对象
|
||||
SysUserDetails userDetails = new SysUserDetails();
|
||||
userDetails.setUserId(onlineUser.getUserId());
|
||||
userDetails.setUsername(onlineUser.getUsername());
|
||||
userDetails.setDeptId(onlineUser.getDeptId());
|
||||
userDetails.setDataScope(onlineUser.getDataScope());
|
||||
userDetails.setAuthorities(authorities);
|
||||
|
||||
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Token 是否有效
|
||||
*
|
||||
* @param token 访问令牌
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public boolean validateToken(String token) {
|
||||
String tokenKey = StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token);
|
||||
return redisTemplate.hasKey(tokenKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken refreshToken(String refreshToken) {
|
||||
// 根据刷新令牌获取在线用户信息
|
||||
String refreshKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
|
||||
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(refreshKey);
|
||||
|
||||
if (onlineUser == null) {
|
||||
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
// 获取当前用户的旧访问令牌(如果存在)
|
||||
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId());
|
||||
String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey);
|
||||
if (oldAccessToken != null) {
|
||||
// 删除旧的访问令牌记录
|
||||
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, oldAccessToken));
|
||||
}
|
||||
|
||||
// 生成新访问令牌
|
||||
String newAccessToken = IdUtil.fastSimpleUUID();
|
||||
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
|
||||
redisTemplate.opsForValue().set(
|
||||
StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken),
|
||||
onlineUser,
|
||||
accessTtl,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
|
||||
// 更新用户与访问令牌的映射(若单设备登录,则更新映射以踢出旧会话)
|
||||
redisTemplate.opsForValue().set(userAccessKey, newAccessToken, accessTtl, TimeUnit.SECONDS);
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
.accessToken(newAccessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.expiresIn(accessTtl)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将 Token 加入黑名单
|
||||
*
|
||||
* @param token 访问令牌
|
||||
*/
|
||||
@Override
|
||||
public void blacklistToken(String token) {
|
||||
// 删除访问令牌对应的在线用户信息缓存
|
||||
String accessKey = StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token);
|
||||
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(accessKey);
|
||||
|
||||
if (onlineUser != null) {
|
||||
Long userId = onlineUser.getUserId();
|
||||
|
||||
// 删除访问令牌缓存和用户与访问令牌的映射
|
||||
redisTemplate.delete(accessKey);
|
||||
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId));
|
||||
|
||||
// 删除用户与刷新令牌的映射,以及刷新令牌对应的缓存
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 OnlineUser 对象
|
||||
*/
|
||||
private OnlineUser buildOnlineUser(SysUserDetails user) {
|
||||
Long userId = user.getUserId();
|
||||
Set<String> roles = user.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.toSet());
|
||||
return new OnlineUser(
|
||||
userId,
|
||||
user.getUsername(),
|
||||
user.getDeptId(),
|
||||
user.getDataScope(),
|
||||
roles
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.core.security.manager;
|
||||
package com.youlai.boot.core.security.token;
|
||||
|
||||
|
||||
import com.youlai.boot.core.security.model.AuthenticationToken;
|
||||
@@ -25,7 +25,7 @@ public interface TokenManager {
|
||||
/**
|
||||
* 解析 Token 获取认证信息
|
||||
*
|
||||
* @param token JWT Token
|
||||
* @param token Token
|
||||
* @return 用户认证信息
|
||||
*/
|
||||
Authentication parseToken(String token);
|
||||
@@ -17,7 +17,7 @@ import com.youlai.boot.shared.auth.enums.CaptchaTypeEnum;
|
||||
import com.youlai.boot.core.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.shared.auth.model.CaptchaInfo;
|
||||
import com.youlai.boot.shared.auth.service.AuthService;
|
||||
import com.youlai.boot.core.security.manager.TokenManager;
|
||||
import com.youlai.boot.core.security.token.TokenManager;
|
||||
import com.youlai.boot.shared.sms.enums.SmsTypeEnum;
|
||||
import com.youlai.boot.shared.sms.service.SmsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -12,7 +12,7 @@ import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.constant.SystemConstants;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.core.security.manager.TokenManager;
|
||||
import com.youlai.boot.core.security.token.TokenManager;
|
||||
import com.youlai.boot.core.security.service.PermissionService;
|
||||
import com.youlai.boot.core.security.util.SecurityUtils;
|
||||
import com.youlai.boot.shared.mail.service.MailService;
|
||||
|
||||
Reference in New Issue
Block a user