wip: 临时提交
This commit is contained in:
@@ -14,16 +14,21 @@ public interface RedisConstants {
|
||||
interface RateLimiter {
|
||||
String IP = "rate_limiter:ip:{}"; // IP限流(示例:rate_limiter:ip:192.168.1.1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分布式锁相关键
|
||||
*/
|
||||
interface Lock {
|
||||
String RESUBMIT = "lock:resubmit:{}:{}"; // 防重复提交(示例:lock:resubmit:methodName:md5Hash)
|
||||
String RESUBMIT = "lock:resubmit:{}:{}"; // 防重复提交(示例:lock:resubmit:userIdentifier:requestIdentifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证模块
|
||||
*/
|
||||
interface Auth {
|
||||
|
||||
String ACCESS_TOKEN = "auth:token:access:{}"; // 访问Token
|
||||
String REFRESH_TOKEN = "auth:token:refresh:{}"; // 刷新Token
|
||||
String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; // 黑名单Token
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.youlai.boot.core.security.exception.MyAuthenticationEntryPoint;
|
||||
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.TokenFilter;
|
||||
import com.youlai.boot.core.security.filter.TokenAuthenticationFilter;
|
||||
import com.youlai.boot.core.security.manager.TokenManager;
|
||||
import com.youlai.boot.core.security.service.SysUserDetailsService;
|
||||
import com.youlai.boot.system.service.ConfigService;
|
||||
@@ -94,7 +94,7 @@ public class SecurityConfig {
|
||||
// 验证码校验过滤器
|
||||
.addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class)
|
||||
// 验证和解析过滤器
|
||||
.addFilterBefore(new TokenFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +1,108 @@
|
||||
package com.youlai.boot.config.property;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 安全配置属性
|
||||
*
|
||||
* @author haoxr
|
||||
* @author Ray.Hao
|
||||
* @since 2024/4/18
|
||||
*/
|
||||
@Data
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "security")
|
||||
public class SecurityProperties {
|
||||
|
||||
/**
|
||||
* 会话方式
|
||||
* 免认证请求路径白名单
|
||||
*/
|
||||
private SessionProperty session;
|
||||
private List<String> ignoreUrls = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* JWT 配置
|
||||
* 静态资源路径(不经过安全过滤器)
|
||||
*/
|
||||
private JwtProperty jwt;
|
||||
private List<String> unsecuredUrls = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Redis-Token 配置
|
||||
* 认证核心配置
|
||||
*/
|
||||
private RedisTokenProperty redisToken;
|
||||
private Auth auth = new Auth();
|
||||
|
||||
/**
|
||||
* 白名单 URL 集合
|
||||
*/
|
||||
private String[] ignoreUrls;
|
||||
|
||||
private String[] unsecuredUrls;
|
||||
|
||||
/**
|
||||
* 会话属性
|
||||
*/
|
||||
@Data
|
||||
public static class SessionProperty {
|
||||
private String type;
|
||||
public static class Auth {
|
||||
/**
|
||||
* 认证策略类型
|
||||
*/
|
||||
@NotNull
|
||||
private AuthType type = AuthType.JWT;
|
||||
|
||||
/**
|
||||
* 访问令牌有效期(秒)
|
||||
*/
|
||||
@Min(-1)
|
||||
private int accessTokenTtl = 3600;
|
||||
|
||||
/**
|
||||
* 刷新令牌有效期(秒)
|
||||
*/
|
||||
@Min(-1)
|
||||
private int refreshTokenTtl = 604800;
|
||||
|
||||
/**
|
||||
* JWT 配置
|
||||
*/
|
||||
private JwtConfig jwtConfig = new JwtConfig();
|
||||
|
||||
/**
|
||||
* Redis Token 配置
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 配置
|
||||
* 认证策略类型枚举
|
||||
*/
|
||||
@Data
|
||||
public static class JwtProperty {
|
||||
|
||||
/**
|
||||
* JWT 密钥
|
||||
*/
|
||||
private String key;
|
||||
|
||||
/**
|
||||
* 访问令牌有效期(单位:秒)
|
||||
*/
|
||||
private Integer accessTokenTimeToLive;
|
||||
|
||||
/**
|
||||
* 刷新令牌有效期(单位:秒)
|
||||
*/
|
||||
private Integer refreshTokenTimeToLive;
|
||||
|
||||
public enum AuthType {
|
||||
JWT, REDIS_TOKEN
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RedisTokenProperty {
|
||||
/**
|
||||
* 是否允许多点登录
|
||||
*/
|
||||
private Boolean multiLogin;
|
||||
|
||||
/**
|
||||
* 访问令牌有效期(单位:秒)
|
||||
*/
|
||||
private Integer accessTokenTimeToLive;
|
||||
|
||||
/**
|
||||
* 刷新令牌有效期(单位:秒)
|
||||
*/
|
||||
private Integer refreshTokenTimeToLive;
|
||||
/**
|
||||
* 会话控制策略枚举
|
||||
*/
|
||||
public enum SessionControlStrategy {
|
||||
REVOKE_OLDEST, DENY_NEW
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package com.youlai.boot.core.aspect;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.jwt.JWTUtil;
|
||||
import cn.hutool.jwt.RegisteredPayload;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.annotation.RepeatSubmit;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.common.util.IPUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -39,59 +37,66 @@ import java.util.concurrent.TimeUnit;
|
||||
public class RepeatSubmitAspect {
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
private final SecurityProperties securityProperties;
|
||||
|
||||
/**
|
||||
* 防重复提交切点
|
||||
*/
|
||||
@Pointcut("@annotation(repeatSubmit)")
|
||||
public void repeatSubmitPointCut(RepeatSubmit repeatSubmit) {
|
||||
log.debug("定义防重复提交切点,注解:{}", repeatSubmit);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 环绕通知:处理防重复提交逻辑
|
||||
*/
|
||||
@Around("repeatSubmitPointCut(repeatSubmit)")
|
||||
@Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit")
|
||||
public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable {
|
||||
String lockKey = buildLockKey();
|
||||
if (lockKey == null) {
|
||||
log.warn("无法生成防重复提交锁的 key,跳过防重复提交逻辑");
|
||||
return pjp.proceed();
|
||||
}
|
||||
|
||||
int expire = repeatSubmit.expire(); // 防重提交锁过期时间
|
||||
int expire = repeatSubmit.expire();
|
||||
RLock lock = redissonClient.getLock(lockKey);
|
||||
|
||||
boolean locked = lock.tryLock(0, expire, TimeUnit.SECONDS);
|
||||
log.info("获取防重复提交锁,key:{},是否成功:{}", lockKey, locked);
|
||||
if (!locked) {
|
||||
log.warn("重复提交请求,锁 key:{}", lockKey);
|
||||
throw new BusinessException(ResultCode.USER_DUPLICATE_REQUEST);
|
||||
}
|
||||
|
||||
return pjp.proceed();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成防重复提交锁的 key
|
||||
* @return 锁的 key
|
||||
*/
|
||||
private String buildLockKey() {
|
||||
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
||||
String tokenHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
// 统一校验 Token 格式
|
||||
if (StrUtil.isBlank(tokenHeader) || !tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) {
|
||||
log.warn("请求头中未找到有效的 Token");
|
||||
return null;
|
||||
}
|
||||
|
||||
String rawToken = tokenHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX .length());
|
||||
|
||||
String tokenHash = DigestUtil.sha256Hex(rawToken); // 建议替换为 SHA256 更安全
|
||||
|
||||
return RedisConstants.RESUBMIT_LOCK_PREFIX
|
||||
+ tokenHash + ":"
|
||||
+ request.getMethod() + "-"
|
||||
+ request.getRequestURI();
|
||||
// 用户唯一标识
|
||||
String userIdentifier = getUserIdentifier(request);
|
||||
// 请求唯一标识 = 请求方法 + 请求路径 + 请求参数(严谨的做法)
|
||||
String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI());
|
||||
return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户唯一标识
|
||||
* 1. 从请求头中获取 Token,使用 SHA-256 加密 Token 作为用户唯一标识
|
||||
* 2. 如果 Token 为空,使用 IP 作为用户唯一标识
|
||||
*
|
||||
* @param request 请求对象
|
||||
* @return 用户唯一标识
|
||||
*/
|
||||
private String getUserIdentifier(HttpServletRequest request) {
|
||||
// 用户身份唯一标识
|
||||
String userIdentifier;
|
||||
// 从请求头中获取 Token
|
||||
String tokenHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (StrUtil.isNotBlank(tokenHeader) && tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
|
||||
String rawToken = tokenHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); // 去掉 Bearer 后的 Token
|
||||
userIdentifier = DigestUtil.sha256Hex(rawToken); // 使用 SHA-256 加密 Token 作为用户唯一标识
|
||||
} else {
|
||||
userIdentifier = IPUtils.getIpAddr(request); // 使用 IP 作为用户唯一标识
|
||||
}
|
||||
return userIdentifier;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,14 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
|
||||
}
|
||||
|
||||
// 校验发送短信验证码的手机号是否与当前登录用户一致
|
||||
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(RedisConstants.Captcha.SMS_LOGIN_CODE + mobile);
|
||||
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(RedisConstants.Captcha.SMS_LOGIN_CODE + mobile);
|
||||
redisTemplate.delete(cacheKey);
|
||||
}
|
||||
|
||||
// 构建认证后的用户详情信息
|
||||
|
||||
@@ -4,7 +4,6 @@ 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.RedisTokenManager;
|
||||
import com.youlai.boot.core.security.manager.TokenManager;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -18,39 +17,57 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Token 认证校验过滤器
|
||||
*
|
||||
* @author wangtao
|
||||
* @since 2025/3/6 16:50
|
||||
*/
|
||||
public class TokenFilter extends OncePerRequestFilter {
|
||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
/**
|
||||
* Token 管理器
|
||||
*/
|
||||
private final TokenManager tokenManager;
|
||||
|
||||
public TokenFilter(TokenManager tokenManager) {
|
||||
public TokenAuthenticationFilter(TokenManager tokenManager) {
|
||||
this.tokenManager = tokenManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Token ,包括验签和是否过期
|
||||
* 如果 Token 有效,将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
try {
|
||||
if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) {
|
||||
// 去除 Bearer 前缀
|
||||
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX .length());
|
||||
// 校验 JWT Token ,包括验签和是否过期
|
||||
boolean isValidate = tokenManager.validateToken(token);
|
||||
if (!isValidate) {
|
||||
if (StrUtil.isNotBlank(authorizationHeader)
|
||||
&& authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
|
||||
|
||||
// 剥离Bearer前缀获取原始令牌
|
||||
String rawToken = authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
|
||||
|
||||
// 执行令牌有效性检查(包含密码学验签和过期时间验证)
|
||||
boolean isValidToken = tokenManager.validateToken(rawToken);
|
||||
if (!isValidToken) {
|
||||
ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID);
|
||||
return;
|
||||
}
|
||||
// 将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中
|
||||
Authentication authentication = tokenManager.parseToken(token);
|
||||
|
||||
// 将令牌解析为Spring Security认证对象
|
||||
Authentication authentication = tokenManager.parseToken(rawToken);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
} catch (Exception ex) {
|
||||
// 安全上下文清除保障(防止上下文残留)
|
||||
SecurityContextHolder.clearContext();
|
||||
ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID);
|
||||
return;
|
||||
}
|
||||
// Token有效或无Token时继续执行过滤链
|
||||
|
||||
// 继续后续过滤器链执行
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,14 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT 令牌服务实现
|
||||
* JWT Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 JWT Token
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
*/
|
||||
@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt")
|
||||
@ConditionalOnProperty(value = "security.auth.type", havingValue = "jwt")
|
||||
@Service
|
||||
public class JwtTokenManager implements TokenManager {
|
||||
|
||||
@@ -44,11 +46,10 @@ public class JwtTokenManager implements TokenManager {
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final byte[] secretKey;
|
||||
|
||||
|
||||
public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate<String, Object> redisTemplate) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.secretKey = securityProperties.getJwt().getKey().getBytes();
|
||||
this.secretKey = securityProperties.getAuth().getJwtConfig().getKey().getBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,8 +60,8 @@ public class JwtTokenManager implements TokenManager {
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken generateToken(Authentication authentication) {
|
||||
int accessTokenTimeToLive = securityProperties.getJwt().getAccessTokenTimeToLive();
|
||||
int refreshTokenTimeToLive = securityProperties.getJwt().getRefreshTokenTimeToLive();
|
||||
int accessTokenTimeToLive = securityProperties.getAuth().getAccessTokenTtl();
|
||||
int refreshTokenTimeToLive = securityProperties.getAuth().getRefreshTokenTtl();
|
||||
|
||||
String accessToken = generateToken(authentication, accessTokenTimeToLive);
|
||||
String refreshToken = generateToken(authentication, refreshTokenTimeToLive);
|
||||
@@ -131,14 +132,18 @@ public class JwtTokenManager implements TokenManager {
|
||||
*/
|
||||
@Override
|
||||
public void blacklistToken(String token) {
|
||||
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) {
|
||||
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX .length());
|
||||
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
|
||||
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
|
||||
}
|
||||
|
||||
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 = RedisConstants.Auth.BLACKLIST_TOKEN + payloads.getStr(JWTPayload.JWT_ID);
|
||||
|
||||
if (expirationAt != null) {
|
||||
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
|
||||
if (expirationAt < currentTimeSeconds) {
|
||||
@@ -147,22 +152,20 @@ public class JwtTokenManager implements TokenManager {
|
||||
}
|
||||
// 计算Token剩余时间,将其加入黑名单
|
||||
int expirationIn = expirationAt - currentTimeSeconds;
|
||||
redisTemplate.opsForValue().set(RedisConstants.Auth.BLACKLIST_TOKEN + jti, null, expirationIn, TimeUnit.SECONDS);
|
||||
redisTemplate.opsForValue().set(blacklistTokenKey, null, expirationIn, TimeUnit.SECONDS);
|
||||
} else {
|
||||
// 永不过期的Token永久加入黑名单
|
||||
redisTemplate.opsForValue().set(RedisConstants.Auth.BLACKLIST_TOKEN + jti, null);
|
||||
redisTemplate.opsForValue().set(blacklistTokenKey, null);
|
||||
}
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return 令牌响应对象
|
||||
*/
|
||||
|
||||
@Override
|
||||
public AuthenticationToken refreshToken(String refreshToken) {
|
||||
|
||||
@@ -172,7 +175,7 @@ public class JwtTokenManager implements TokenManager {
|
||||
}
|
||||
|
||||
Authentication authentication = parseToken(refreshToken);
|
||||
int accessTokenExpiration = securityProperties.getJwt().getRefreshTokenTimeToLive();
|
||||
int accessTokenExpiration = securityProperties.getAuth().getRefreshTokenTtl();
|
||||
String newAccessToken = generateToken(authentication, accessTokenExpiration);
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
@@ -183,7 +186,6 @@ public class JwtTokenManager implements TokenManager {
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成 JWT Token
|
||||
*
|
||||
|
||||
@@ -24,19 +24,29 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT 令牌服务实现
|
||||
* Redis Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 JWT Token
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
*/
|
||||
@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token")
|
||||
@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) {
|
||||
public RedisTokenManager(
|
||||
SecurityProperties securityProperties,
|
||||
RedisTemplate<String, Object> redisTemplate
|
||||
) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
@@ -49,21 +59,74 @@ public class RedisTokenManager implements TokenManager {
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken generateToken(Authentication authentication) {
|
||||
int accessTokenTimeToLive = securityProperties.getRedisToken().getAccessTokenTimeToLive();
|
||||
int refreshTokenTimeToLive = securityProperties.getRedisToken().getRefreshTokenTimeToLive();
|
||||
Boolean multiLogin = securityProperties.getRedisToken().getMultiLogin();
|
||||
int accessTokenTtl = securityProperties.getAuth().getAccessTokenTtl();
|
||||
|
||||
String accessToken = generateToken(authentication, TokenKeyEnum.ACCESS_TOKEN_KEY, accessTokenTimeToLive, multiLogin);
|
||||
String refreshToken = generateToken(authentication, TokenKeyEnum.REFRESH_TOKEN_KEY, refreshTokenTimeToLive, multiLogin);
|
||||
// 创建新会话
|
||||
String accessToken = createNewSession(authentication, );
|
||||
|
||||
// 创建刷新令牌(独立控制)
|
||||
String refreshToken = createNewSession(authentication, TokenType.REFRESH,
|
||||
config.getRefreshTokenTtl(), 1); // 刷新令牌强制单设备
|
||||
|
||||
return AuthenticationToken.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.tokenType("Bearer")
|
||||
.expiresIn(accessTokenTimeToLive)
|
||||
.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()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析令牌
|
||||
*
|
||||
@@ -84,7 +147,6 @@ public class RedisTokenManager implements TokenManager {
|
||||
userDetails.setDeptId(user.getDeptId());
|
||||
userDetails.setDataScope(user.getDataScope());
|
||||
userDetails.setAuthorities(authorities);
|
||||
|
||||
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
|
||||
}
|
||||
|
||||
@@ -97,8 +159,7 @@ public class RedisTokenManager implements TokenManager {
|
||||
@Override
|
||||
public boolean validateToken(String token) {
|
||||
String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token;
|
||||
Boolean hasKey = redisTemplate.hasKey(accessTokenKey);
|
||||
return hasKey != null && hasKey;
|
||||
return redisTemplate.hasKey(accessTokenKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +176,7 @@ public class RedisTokenManager implements TokenManager {
|
||||
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
int accessTokenExpiration = securityProperties.getRedisToken().getRefreshTokenTimeToLive();
|
||||
int accessTokenExpiration = securityProperties.getAuth().getRefreshTokenTtl();
|
||||
// 生成新的访问令牌
|
||||
String newAccessToken = generateToken(authentication, TokenKeyEnum.ACCESS_TOKEN_KEY, accessTokenExpiration, true);
|
||||
|
||||
@@ -143,14 +204,14 @@ public class RedisTokenManager implements TokenManager {
|
||||
// 不允许多点登录,使用hashmap存储在线用户id和token
|
||||
if (!multiLogin) {
|
||||
// 查找当前用户id是否有token,有的话,说明已经登录了,就删除旧的token
|
||||
String oldToken = (String) redisTemplate.opsForHash().get("userId-token:"+tokenKeyEnum.getValue(), userDetails.getUserId().toString());
|
||||
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.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);
|
||||
redisTemplate.opsForHash().put("userId-token:" + tokenKeyEnum.getValue(), userDetails.getUserId().toString(), token);
|
||||
// 设置userId-token的过期时间
|
||||
redisTemplate.opsForHash().getOperations().expire("userId-token:"+tokenKeyEnum.getValue(), ttl, TimeUnit.SECONDS);
|
||||
redisTemplate.opsForHash().getOperations().expire("userId-token:" + tokenKeyEnum.getValue(), ttl, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// 存储用户信息
|
||||
@@ -182,13 +243,13 @@ public class RedisTokenManager implements TokenManager {
|
||||
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());
|
||||
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());
|
||||
redisTemplate.opsForHash().delete("userId-token:" + TokenKeyEnum.ACCESS_TOKEN_KEY.getValue(), userId.toString());
|
||||
redisTemplate.opsForHash().delete("userId-token:" + TokenKeyEnum.REFRESH_TOKEN_KEY.getValue(), userId.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import com.youlai.boot.core.security.model.AuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
/**
|
||||
* 令牌接口
|
||||
* Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 Token
|
||||
*
|
||||
* @author Ray
|
||||
* @author Ray.Hao
|
||||
* @since 2.16.0
|
||||
*/
|
||||
public interface TokenManager {
|
||||
@@ -57,5 +59,4 @@ public interface TokenManager {
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public class SysUserDetails implements UserDetails {
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 账号是否启用(true:启用,false:禁用)
|
||||
* 账号是否启用(true:启用 false:禁用)
|
||||
*/
|
||||
private Boolean enabled;
|
||||
|
||||
|
||||
@@ -422,7 +422,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
boolean result = smsService.sendSms(mobile, SmsTypeEnum.CHANGE_MOBILE, templateParams);
|
||||
if (result) {
|
||||
// 缓存验证码,5分钟有效,用于更换手机号校验
|
||||
String redisCacheKey =StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
|
||||
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
|
||||
redisTemplate.opsForValue().set(redisCacheKey, code, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
return result;
|
||||
@@ -448,8 +448,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
String inputVerifyCode = form.getCode();
|
||||
String mobile = form.getMobile();
|
||||
|
||||
String redisCacheKey = RedisConstants.Captcha.MOBILE_CODE + mobile;
|
||||
String cachedVerifyCode = redisTemplate.opsForValue().get(redisCacheKey);
|
||||
String cacheKey = StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
|
||||
|
||||
String cachedVerifyCode = redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (StrUtil.isBlank(cachedVerifyCode)) {
|
||||
throw new BusinessException("验证码已过期");
|
||||
@@ -458,7 +459,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
throw new BusinessException("验证码错误");
|
||||
}
|
||||
// 验证完成删除验证码
|
||||
redisTemplate.delete(redisCacheKey);
|
||||
redisTemplate.delete(cacheKey);
|
||||
|
||||
// 更新手机号码
|
||||
return this.update(
|
||||
@@ -482,7 +483,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
|
||||
mailService.sendMail(email, "邮箱验证码", "您的验证码为:" + code + ",请在5分钟内使用");
|
||||
// 缓存验证码,5分钟有效,用于更换邮箱校验
|
||||
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.EMAIL_CODE, email);
|
||||
String redisCacheKey = StrUtil.format(RedisConstants.Captcha.EMAIL_CODE, email);
|
||||
redisTemplate.opsForValue().set(redisCacheKey, code, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user