wip: 临时提交

This commit is contained in:
Ray.Hao
2025-03-07 21:39:51 +08:00
parent a84f2b9988
commit e01b784a97
13 changed files with 288 additions and 175 deletions

View File

@@ -14,16 +14,21 @@ public interface RedisConstants {
interface RateLimiter { interface RateLimiter {
String IP = "rate_limiter:ip:{}"; // IP限流示例rate_limiter:ip:192.168.1.1 String IP = "rate_limiter:ip:{}"; // IP限流示例rate_limiter:ip:192.168.1.1
} }
/** /**
* 分布式锁相关键 * 分布式锁相关键
*/ */
interface Lock { interface Lock {
String RESUBMIT = "lock:resubmit:{}:{}"; // 防重复提交示例lock:resubmit:methodName:md5Hash String RESUBMIT = "lock:resubmit:{}:{}"; // 防重复提交示例lock:resubmit:userIdentifier:requestIdentifier
} }
/** /**
* 认证模块 * 认证模块
*/ */
interface Auth { interface Auth {
String ACCESS_TOKEN = "auth:token:access:{}"; // 访问Token
String REFRESH_TOKEN = "auth:token:refresh:{}"; // 刷新Token
String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; // 黑名单Token String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; // 黑名单Token
} }

View File

@@ -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.sms.SmsAuthenticationProvider;
import com.youlai.boot.core.security.extension.wechat.WechatAuthenticationProvider; import com.youlai.boot.core.security.extension.wechat.WechatAuthenticationProvider;
import com.youlai.boot.core.security.filter.CaptchaValidationFilter; 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.manager.TokenManager;
import com.youlai.boot.core.security.service.SysUserDetailsService; import com.youlai.boot.core.security.service.SysUserDetailsService;
import com.youlai.boot.system.service.ConfigService; import com.youlai.boot.system.service.ConfigService;
@@ -94,7 +94,7 @@ public class SecurityConfig {
// 验证码校验过滤器 // 验证码校验过滤器
.addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class)
// 验证和解析过滤器 // 验证和解析过滤器
.addFilterBefore(new TokenFilter(tokenManager), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
.build(); .build();
} }

View File

@@ -1,88 +1,108 @@
package com.youlai.boot.config.property; 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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* 安全配置属性 * 安全配置属性
* *
* @author haoxr * @author Ray.Hao
* @since 2024/4/18 * @since 2024/4/18
*/ */
@Data @Data
@Validated
@ConfigurationProperties(prefix = "security") @ConfigurationProperties(prefix = "security")
public class SecurityProperties { public class SecurityProperties {
/** /**
* 会话方式 * 免认证请求路径白名单
*/ */
private SessionProperty session; private List<String> ignoreUrls = new ArrayList<>();
/**
* 静态资源路径(不经过安全过滤器)
*/
private List<String> unsecuredUrls = new ArrayList<>();
/**
* 认证核心配置
*/
private Auth auth = new Auth();
@Data
public static class Auth {
/**
* 认证策略类型
*/
@NotNull
private AuthType type = AuthType.JWT;
/**
* 访问令牌有效期(秒)
*/
@Min(-1)
private int accessTokenTtl = 3600;
/**
* 刷新令牌有效期(秒)
*/
@Min(-1)
private int refreshTokenTtl = 604800;
/** /**
* JWT 配置 * JWT 配置
*/ */
private JwtProperty jwt; private JwtConfig jwtConfig = new JwtConfig();
/** /**
* Redis-Token 配置 * Redis Token 配置
*/ */
private RedisTokenProperty redisToken; private RedisTokenConfig redisTokenConfig = new RedisTokenConfig();
/**
* 白名单 URL 集合
*/
private String[] ignoreUrls;
private String[] unsecuredUrls;
/**
* 会话属性
*/
@Data @Data
public static class SessionProperty { public static class JwtConfig {
private String type;
}
/**
* JWT 配置
*/
@Data
public static class JwtProperty {
/** /**
* JWT 密钥 * JWT 密钥
*/ */
@NotBlank
@Size(min = 32, message = "HS256算法密钥至少需要32字符")
private String key; private String key;
/**
* 访问令牌有效期(单位:秒)
*/
private Integer accessTokenTimeToLive;
/**
* 刷新令牌有效期(单位:秒)
*/
private Integer refreshTokenTimeToLive;
} }
@Data @Data
public static class RedisTokenProperty { public static class RedisTokenConfig {
/** /**
* 是否允许多点登录 * 最大并发会话数
*/ */
private Boolean multiLogin; @Min(-1)
private int maxSessions = 1;
/** /**
* 访问令牌有效期(单位:秒) * 会话超限处理策略
*/ */
private Integer accessTokenTimeToLive; private SessionControlStrategy sessionControl = SessionControlStrategy.REVOKE_OLDEST;
}
}
/** /**
* 刷新令牌有效期(单位:秒) * 认证策略类型枚举
*/ */
private Integer refreshTokenTimeToLive; public enum AuthType {
JWT, REDIS_TOKEN
}
/**
* 会话控制策略枚举
*/
public enum SessionControlStrategy {
REVOKE_OLDEST, DENY_NEW
} }
} }

View File

@@ -2,14 +2,12 @@ package com.youlai.boot.core.aspect;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; 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.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.annotation.RepeatSubmit; 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 jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -39,59 +37,66 @@ import java.util.concurrent.TimeUnit;
public class RepeatSubmitAspect { public class RepeatSubmitAspect {
private final RedissonClient redissonClient; private final RedissonClient redissonClient;
private final SecurityProperties securityProperties;
/** /**
* 防重复提交切点 * 防重复提交切点
*/ */
@Pointcut("@annotation(repeatSubmit)") @Pointcut("@annotation(repeatSubmit)")
public void repeatSubmitPointCut(RepeatSubmit 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 { public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable {
String lockKey = buildLockKey(); 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); RLock lock = redissonClient.getLock(lockKey);
boolean locked = lock.tryLock(0, expire, TimeUnit.SECONDS); boolean locked = lock.tryLock(0, expire, TimeUnit.SECONDS);
log.info("获取防重复提交锁key{},是否成功:{}", lockKey, locked);
if (!locked) { if (!locked) {
log.warn("重复提交请求,锁 key{}", lockKey);
throw new BusinessException(ResultCode.USER_DUPLICATE_REQUEST); throw new BusinessException(ResultCode.USER_DUPLICATE_REQUEST);
} }
return pjp.proceed(); return pjp.proceed();
} }
/**
* 生成防重复提交锁的 key
* @return 锁的 key
*/
private String buildLockKey() { private String buildLockKey() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// 用户唯一标识
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); String tokenHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(tokenHeader) && tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
// 统一校验 Token 格式 String rawToken = tokenHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); // 去掉 Bearer 后的 Token
if (StrUtil.isBlank(tokenHeader) || !tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) { userIdentifier = DigestUtil.sha256Hex(rawToken); // 使用 SHA-256 加密 Token 作为用户唯一标识
log.warn("请求头中未找到有效的 Token"); } else {
return null; userIdentifier = IPUtils.getIpAddr(request); // 使用 IP 作为用户唯一标识
}
return userIdentifier;
} }
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();
}
}

View File

@@ -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)) { if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
throw new CaptchaValidationException("验证码错误"); throw new CaptchaValidationException("验证码错误");
} else { } else {
// 验证成功后删除验证码 // 验证成功后删除验证码
redisTemplate.delete(RedisConstants.Captcha.SMS_LOGIN_CODE + mobile); redisTemplate.delete(cacheKey);
} }
// 构建认证后的用户详情信息 // 构建认证后的用户详情信息

View File

@@ -4,7 +4,6 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.ResponseUtils; import com.youlai.boot.common.util.ResponseUtils;
import com.youlai.boot.core.security.manager.RedisTokenManager;
import com.youlai.boot.core.security.manager.TokenManager; import com.youlai.boot.core.security.manager.TokenManager;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -18,39 +17,57 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
/** /**
* Token 认证校验过滤器
*
* @author wangtao * @author wangtao
* @since 2025/3/6 16:50 * @since 2025/3/6 16:50
*/ */
public class TokenFilter extends OncePerRequestFilter { public class TokenAuthenticationFilter extends OncePerRequestFilter {
/**
* Token 管理器
*/
private final TokenManager tokenManager; private final TokenManager tokenManager;
public TokenFilter(TokenManager tokenManager) { public TokenAuthenticationFilter(TokenManager tokenManager) {
this.tokenManager = tokenManager; this.tokenManager = tokenManager;
} }
/**
* 校验 Token 包括验签和是否过期
* 如果 Token 有效 Token 解析为 Authentication 对象并设置到 Spring Security 上下文中
*/
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { 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 { try {
if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) { if (StrUtil.isNotBlank(authorizationHeader)
// 去除 Bearer 前缀 && authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX .length());
// 校验 JWT Token 包括验签和是否过期 // 剥离Bearer前缀获取原始令牌
boolean isValidate = tokenManager.validateToken(token); String rawToken = authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
if (!isValidate) {
// 执行令牌有效性检查包含密码学验签和过期时间验证
boolean isValidToken = tokenManager.validateToken(rawToken);
if (!isValidToken) {
ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID); ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID);
return; return;
} }
// Token 解析为 Authentication 对象并设置到 Spring Security 上下文中
Authentication authentication = tokenManager.parseToken(token); // 将令牌解析为Spring Security认证对象
Authentication authentication = tokenManager.parseToken(rawToken);
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
} }
} catch (Exception e) { } catch (Exception ex) {
// 安全上下文清除保障防止上下文残留
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID); ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID);
return; return;
} }
// Token有效或无Token时继续执行过滤链
// 继续后续过滤器链执行
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }

View File

@@ -31,12 +31,14 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* JWT 令牌服务实现 * JWT Token 管理器
* <p>
* 用于生成、解析、校验、刷新 JWT Token
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2024/11/15 * @since 2024/11/15
*/ */
@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt") @ConditionalOnProperty(value = "security.auth.type", havingValue = "jwt")
@Service @Service
public class JwtTokenManager implements TokenManager { public class JwtTokenManager implements TokenManager {
@@ -44,11 +46,10 @@ public class JwtTokenManager implements TokenManager {
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final byte[] secretKey; private final byte[] secretKey;
public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate<String, Object> redisTemplate) { public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate<String, Object> redisTemplate) {
this.securityProperties = securityProperties; this.securityProperties = securityProperties;
this.redisTemplate = redisTemplate; 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 @Override
public AuthenticationToken generateToken(Authentication authentication) { public AuthenticationToken generateToken(Authentication authentication) {
int accessTokenTimeToLive = securityProperties.getJwt().getAccessTokenTimeToLive(); int accessTokenTimeToLive = securityProperties.getAuth().getAccessTokenTtl();
int refreshTokenTimeToLive = securityProperties.getJwt().getRefreshTokenTimeToLive(); int refreshTokenTimeToLive = securityProperties.getAuth().getRefreshTokenTtl();
String accessToken = generateToken(authentication, accessTokenTimeToLive); String accessToken = generateToken(authentication, accessTokenTimeToLive);
String refreshToken = generateToken(authentication, refreshTokenTimeToLive); String refreshToken = generateToken(authentication, refreshTokenTimeToLive);
@@ -134,11 +135,15 @@ public class JwtTokenManager implements TokenManager {
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
} }
JWT jwt = JWTUtil.parseToken(token); JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads(); JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT); Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT);
// 黑名单Token Key
String blacklistTokenKey = RedisConstants.Auth.BLACKLIST_TOKEN + payloads.getStr(JWTPayload.JWT_ID);
if (expirationAt != null) { if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) { if (expirationAt < currentTimeSeconds) {
@@ -147,22 +152,20 @@ public class JwtTokenManager implements TokenManager {
} }
// 计算Token剩余时间将其加入黑名单 // 计算Token剩余时间将其加入黑名单
int expirationIn = expirationAt - currentTimeSeconds; 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 { } else {
// 永不过期的Token永久加入黑名单 // 永不过期的Token永久加入黑名单
redisTemplate.opsForValue().set(RedisConstants.Auth.BLACKLIST_TOKEN + jti, null); redisTemplate.opsForValue().set(blacklistTokenKey, null);
} }
; ;
} }
/** /**
* 刷新令牌 * 刷新令牌
* *
* @param refreshToken 刷新令牌 * @param refreshToken 刷新令牌
* @return 令牌响应对象 * @return 令牌响应对象
*/ */
@Override @Override
public AuthenticationToken refreshToken(String refreshToken) { public AuthenticationToken refreshToken(String refreshToken) {
@@ -172,7 +175,7 @@ public class JwtTokenManager implements TokenManager {
} }
Authentication authentication = parseToken(refreshToken); Authentication authentication = parseToken(refreshToken);
int accessTokenExpiration = securityProperties.getJwt().getRefreshTokenTimeToLive(); int accessTokenExpiration = securityProperties.getAuth().getRefreshTokenTtl();
String newAccessToken = generateToken(authentication, accessTokenExpiration); String newAccessToken = generateToken(authentication, accessTokenExpiration);
return AuthenticationToken.builder() return AuthenticationToken.builder()
@@ -183,7 +186,6 @@ public class JwtTokenManager implements TokenManager {
.build(); .build();
} }
/** /**
* 生成 JWT Token * 生成 JWT Token
* *

View File

@@ -24,19 +24,29 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* JWT 令牌服务实现 * Redis Token 管理器
* <p>
* 用于生成、解析、校验、刷新 JWT Token
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2024/11/15 * @since 2024/11/15
*/ */
@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token") @ConditionalOnProperty(value = "security.auth.type", havingValue = "redis-token")
@Service @Service
public class RedisTokenManager implements TokenManager { 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 SecurityProperties securityProperties;
private final RedisTemplate<String, Object> redisTemplate; 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.securityProperties = securityProperties;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
} }
@@ -49,21 +59,74 @@ public class RedisTokenManager implements TokenManager {
*/ */
@Override @Override
public AuthenticationToken generateToken(Authentication authentication) { public AuthenticationToken generateToken(Authentication authentication) {
int accessTokenTimeToLive = securityProperties.getRedisToken().getAccessTokenTimeToLive(); int accessTokenTtl = securityProperties.getAuth().getAccessTokenTtl();
int refreshTokenTimeToLive = securityProperties.getRedisToken().getRefreshTokenTimeToLive();
Boolean multiLogin = securityProperties.getRedisToken().getMultiLogin();
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() return AuthenticationToken.builder()
.accessToken(accessToken) .accessToken(accessToken)
.refreshToken(refreshToken) .refreshToken(refreshToken)
.tokenType("Bearer") .tokenType("Bearer")
.expiresIn(accessTokenTimeToLive) .expiresIn(accessTokenTtl)
.build(); .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.setDeptId(user.getDeptId());
userDetails.setDataScope(user.getDataScope()); userDetails.setDataScope(user.getDataScope());
userDetails.setAuthorities(authorities); userDetails.setAuthorities(authorities);
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
} }
@@ -97,8 +159,7 @@ public class RedisTokenManager implements TokenManager {
@Override @Override
public boolean validateToken(String token) { public boolean validateToken(String token) {
String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token; String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token;
Boolean hasKey = redisTemplate.hasKey(accessTokenKey); return redisTemplate.hasKey(accessTokenKey);
return hasKey != null && hasKey;
} }
/** /**
@@ -115,7 +176,7 @@ public class RedisTokenManager implements TokenManager {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); 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); String newAccessToken = generateToken(authentication, TokenKeyEnum.ACCESS_TOKEN_KEY, accessTokenExpiration, true);

View File

@@ -5,9 +5,11 @@ import com.youlai.boot.core.security.model.AuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
/** /**
* 令牌接口 * Token 管理器
* <p>
* 用于生成、解析、校验、刷新 Token
* *
* @author Ray * @author Ray.Hao
* @since 2.16.0 * @since 2.16.0
*/ */
public interface TokenManager { public interface TokenManager {
@@ -57,5 +59,4 @@ public interface TokenManager {
} }
} }

View File

@@ -43,7 +43,7 @@ public class SysUserDetails implements UserDetails {
private String password; private String password;
/** /**
* 账号是否启用true启用false禁用 * 账号是否启用(true:启用 false:禁用)
*/ */
private Boolean enabled; private Boolean enabled;

View File

@@ -448,8 +448,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
String inputVerifyCode = form.getCode(); String inputVerifyCode = form.getCode();
String mobile = form.getMobile(); String mobile = form.getMobile();
String redisCacheKey = RedisConstants.Captcha.MOBILE_CODE + mobile; String cacheKey = StrUtil.format(RedisConstants.Captcha.MOBILE_CODE, mobile);
String cachedVerifyCode = redisTemplate.opsForValue().get(redisCacheKey);
String cachedVerifyCode = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank(cachedVerifyCode)) { if (StrUtil.isBlank(cachedVerifyCode)) {
throw new BusinessException("验证码已过期"); throw new BusinessException("验证码已过期");
@@ -458,7 +459,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
throw new BusinessException("验证码错误"); throw new BusinessException("验证码错误");
} }
// 验证完成删除验证码 // 验证完成删除验证码
redisTemplate.delete(redisCacheKey); redisTemplate.delete(cacheKey);
// 更新手机号码 // 更新手机号码
return this.update( return this.update(

View File

@@ -72,24 +72,19 @@ mybatis-plus:
# 安全配置 # 安全配置
security: security:
session: # 认证策略核心配置
# 会话方式,支持 jwt、redis-token auth:
type: jwt type: jwt # 认证策略 [jwt|redis-token]
access-token-ttl: 3600 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期
refresh-token-ttl: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期
# JWT 认证配置
jwt: jwt:
# JWT 秘钥 key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符RS256至少2048位)
key: SecretKey012345678901234567890123456789012345678901234567890123456789 # Redis Token 认证配置
# 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期
access-token-time-to-live: 3600
# 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期
refresh-token-time-to-live: 604800
# 无需认证的请求路径
redis-token: redis-token:
# 是否允许多点登录true:允许 false:不允许 max-sessions: 1 # 最大允许多个设备同时登录的数量默认为1
multi-login: false session-control: REVOKE_OLDEST # 会话超限处理策略 [REJECT|REVOKE_OLDEST|REVOKE_ALL]
# 访问令牌有效期(单位:秒),默认 1 小时 # 白名单路径
access-token-time-to-live: 3600
# 刷新令牌有效期(单位:秒),默认 7 天
refresh-token-time-to-live: 604800
ignore-urls: ignore-urls:
- /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录) - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录)
- /api/v1/auth/captcha # 验证码获取接口 - /api/v1/auth/captcha # 验证码获取接口
@@ -103,7 +98,6 @@ security:
- /v3/api-docs/** - /v3/api-docs/**
- /webjars/** - /webjars/**
# 文件存储配置 # 文件存储配置
oss: oss:
# OSS 类型 (目前支持aliyun、minio、local) # OSS 类型 (目前支持aliyun、minio、local)

View File

@@ -70,16 +70,22 @@ mybatis-plus:
# 安全配置 # 安全配置
security: security:
session: # 认证策略核心配置
# 会话方式,支持 jwt、redis-token auth:
# 认证策略 [jwt|redis-token]
type: jwt type: jwt
jwt:
# JWT 秘钥
key: SecretKey012345678901234567890123456789012345678901234567890123456789
# 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期
access-token-time-to-live: 3600 access-token-ttl: 3600
# 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期
refresh-token-time-to-live: 604800 refresh-token-ttl: 604800
# JWT 认证配置
jwt:
key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符RS256至少2048位)
# Redis Token 认证配置
redis-token:
max-sessions: 1 # 最大允许多个设备同时登录的数量默认为1
session-control: REVOKE_OLDEST # 会话超限处理策略 [REJECT|REVOKE_OLDEST|REVOKE_ALL]
# 无需认证的请求路径 # 无需认证的请求路径
ignore-urls: ignore-urls:
- /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录) - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录)