diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 0571d068..b753deaf 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -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 } diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java index 0b01d2fa..395436a0 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -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(); } diff --git a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java index 2c0411b8..cadd6acc 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java @@ -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 ignoreUrls = new ArrayList<>(); /** - * JWT 配置 + * 静态资源路径(不经过安全过滤器) */ - private JwtProperty jwt; + private List 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 } } diff --git a/src/main/java/com/youlai/boot/core/aspect/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/core/aspect/RepeatSubmitAspect.java index 7aba3174..7ac704a9 100644 --- a/src/main/java/com/youlai/boot/core/aspect/RepeatSubmitAspect.java +++ b/src/main/java/com/youlai/boot/core/aspect/RepeatSubmitAspect.java @@ -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; + } + + } + diff --git a/src/main/java/com/youlai/boot/core/security/extension/sms/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/core/security/extension/sms/SmsAuthenticationProvider.java index 6720cf08..70058226 100644 --- a/src/main/java/com/youlai/boot/core/security/extension/sms/SmsAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/core/security/extension/sms/SmsAuthenticationProvider.java @@ -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); } // 构建认证后的用户详情信息 diff --git a/src/main/java/com/youlai/boot/core/security/filter/TokenFilter.java b/src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java similarity index 56% rename from src/main/java/com/youlai/boot/core/security/filter/TokenFilter.java rename to src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java index 9b6a7f75..037fe7ee 100644 --- a/src/main/java/com/youlai/boot/core/security/filter/TokenFilter.java +++ b/src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java @@ -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); } } diff --git a/src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java b/src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java index 1397edac..6a1b4af5 100644 --- a/src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java @@ -31,12 +31,14 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * JWT 令牌服务实现 + * JWT Token 管理器 + *

+ * 用于生成、解析、校验、刷新 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 redisTemplate; private final byte[] secretKey; - public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate 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 * diff --git a/src/main/java/com/youlai/boot/core/security/manager/RedisTokenManager.java b/src/main/java/com/youlai/boot/core/security/manager/RedisTokenManager.java index 0b6026e9..80dce8f6 100644 --- a/src/main/java/com/youlai/boot/core/security/manager/RedisTokenManager.java +++ b/src/main/java/com/youlai/boot/core/security/manager/RedisTokenManager.java @@ -24,19 +24,29 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * JWT 令牌服务实现 + * Redis Token 管理器 + *

+ * 用于生成、解析、校验、刷新 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 redisTemplate; - public RedisTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { + public RedisTokenManager( + SecurityProperties securityProperties, + RedisTemplate 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 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()); } } } diff --git a/src/main/java/com/youlai/boot/core/security/manager/TokenManager.java b/src/main/java/com/youlai/boot/core/security/manager/TokenManager.java index 63466bd7..0fd979f8 100644 --- a/src/main/java/com/youlai/boot/core/security/manager/TokenManager.java +++ b/src/main/java/com/youlai/boot/core/security/manager/TokenManager.java @@ -5,9 +5,11 @@ import com.youlai.boot.core.security.model.AuthenticationToken; import org.springframework.security.core.Authentication; /** - * 令牌接口 + * Token 管理器 + *

+ * 用于生成、解析、校验、刷新 Token * - * @author Ray + * @author Ray.Hao * @since 2.16.0 */ public interface TokenManager { @@ -57,5 +59,4 @@ public interface TokenManager { } - } diff --git a/src/main/java/com/youlai/boot/core/security/model/SysUserDetails.java b/src/main/java/com/youlai/boot/core/security/model/SysUserDetails.java index 6005214f..a0db5a9d 100644 --- a/src/main/java/com/youlai/boot/core/security/model/SysUserDetails.java +++ b/src/main/java/com/youlai/boot/core/security/model/SysUserDetails.java @@ -43,7 +43,7 @@ public class SysUserDetails implements UserDetails { private String password; /** - * 账号是否启用(true:启用,false:禁用) + * 账号是否启用(true:启用 false:禁用) */ private Boolean enabled; diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index a965395e..781f79da 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -422,7 +422,7 @@ public class UserServiceImpl extends ServiceImpl 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 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 implements Us throw new BusinessException("验证码错误"); } // 验证完成删除验证码 - redisTemplate.delete(redisCacheKey); + redisTemplate.delete(cacheKey); // 更新手机号码 return this.update( @@ -482,7 +483,7 @@ public class UserServiceImpl extends ServiceImpl 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); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3d9242be..78318d0d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -72,24 +72,19 @@ mybatis-plus: # 安全配置 security: - session: - # 会话方式,支持 jwt、redis-token - type: jwt - jwt: - # JWT 秘钥 - key: SecretKey012345678901234567890123456789012345678901234567890123456789 - # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 - access-token-time-to-live: 3600 - # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 - refresh-token-time-to-live: 604800 - # 无需认证的请求路径 - redis-token: - # 是否允许多点登录,true:允许 false:不允许 - multi-login: false - # 访问令牌有效期(单位:秒),默认 1 小时 - access-token-time-to-live: 3600 - # 刷新令牌有效期(单位:秒),默认 7 天 - refresh-token-time-to-live: 604800 + # 认证策略核心配置 + auth: + type: jwt # 认证策略 [jwt|redis-token] + access-token-ttl: 3600 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 + refresh-token-ttl: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 + # 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: - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录) - /api/v1/auth/captcha # 验证码获取接口 @@ -103,7 +98,6 @@ security: - /v3/api-docs/** - /webjars/** - # 文件存储配置 oss: # OSS 类型 (目前支持aliyun、minio、local) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 0be80dd4..834d55c6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -70,16 +70,22 @@ mybatis-plus: # 安全配置 security: - session: - # 会话方式,支持 jwt、redis-token + # 认证策略核心配置 + auth: + # 认证策略 [jwt|redis-token] type: jwt - jwt: - # JWT 秘钥 - key: SecretKey012345678901234567890123456789012345678901234567890123456789 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 - access-token-time-to-live: 3600 + access-token-ttl: 3600 # 刷新令牌有效期(单位:秒),默认 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: - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录)