From e77d110b38002d755a457a99036cda1e9b4589a5 Mon Sep 17 00:00:00 2001 From: wangtaocs Date: Thu, 6 Mar 2025 17:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81redis-token=E5=92=8C?= =?UTF-8?q?=E5=8D=95=E8=AE=BE=E5=A4=87=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../boot/common/enums/TokenKeyEnum.java | 20 +++ .../youlai/boot/config/SecurityConfig.java | 10 +- .../config/property/SecurityProperties.java | 23 +++ .../boot/core/aspect/RepeatSubmitAspect.java | 22 +-- ...enticationFilter.java => TokenFilter.java} | 30 ++-- .../security/manager/RedisTokenManager.java | 141 +++++++++++++++++- .../boot/core/security/model/OnlineUser.java | 18 +++ ...itional-spring-configuration-metadata.json | 18 ++- src/main/resources/application-dev.yml | 7 + 9 files changed, 247 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java rename src/main/java/com/youlai/boot/core/security/filter/{JwtAuthenticationFilter.java => TokenFilter.java} (70%) create mode 100644 src/main/java/com/youlai/boot/core/security/model/OnlineUser.java diff --git a/src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java b/src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java new file mode 100644 index 00000000..d4aa10e6 --- /dev/null +++ b/src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java @@ -0,0 +1,20 @@ +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; + } +} diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java index 61d7d84c..0b01d2fa 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -10,9 +10,9 @@ 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.JwtAuthenticationFilter; +import com.youlai.boot.core.security.filter.TokenFilter; +import com.youlai.boot.core.security.manager.TokenManager; import com.youlai.boot.core.security.service.SysUserDetailsService; -import com.youlai.boot.core.security.manager.JwtTokenManager; import com.youlai.boot.system.service.ConfigService; import com.youlai.boot.system.service.UserService; import lombok.RequiredArgsConstructor; @@ -48,7 +48,7 @@ public class SecurityConfig { private final RedisTemplate redisTemplate; private final PasswordEncoder passwordEncoder; - private final JwtTokenManager jwtTokenService; + private final TokenManager tokenManager; private final WxMaService wxMaService; private final UserService userService; private final SysUserDetailsService userDetailsService; @@ -93,8 +93,8 @@ public class SecurityConfig { .addFilterBefore(new RateLimiterFilter(redisTemplate, configService), UsernamePasswordAuthenticationFilter.class) // 验证码校验过滤器 .addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class) - // JWT 验证和解析过滤器 - .addFilterBefore(new JwtAuthenticationFilter(jwtTokenService), UsernamePasswordAuthenticationFilter.class) + // 验证和解析过滤器 + .addFilterBefore(new TokenFilter(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 969098a5..2c0411b8 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java @@ -25,6 +25,11 @@ public class SecurityProperties { */ private JwtProperty jwt; + /** + * Redis-Token 配置 + */ + private RedisTokenProperty redisToken; + /** * 白名单 URL 集合 */ @@ -62,4 +67,22 @@ public class SecurityProperties { private Integer refreshTokenTimeToLive; } + + @Data + public static class RedisTokenProperty { + /** + * 是否允许多点登录 + */ + private Boolean multiLogin; + + /** + * 访问令牌有效期(单位:秒) + */ + private Integer accessTokenTimeToLive; + + /** + * 刷新令牌有效期(单位:秒) + */ + private Integer refreshTokenTimeToLive; + } } 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 b7ca28a5..db10336d 100644 --- a/src/main/java/com/youlai/boot/core/aspect/RepeatSubmitAspect.java +++ b/src/main/java/com/youlai/boot/core/aspect/RepeatSubmitAspect.java @@ -8,6 +8,7 @@ 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 jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,6 +38,7 @@ import java.util.concurrent.TimeUnit; public class RepeatSubmitAspect { private final RedissonClient redissonClient; + private final SecurityProperties securityProperties; /** * 防重复提交切点 @@ -86,17 +88,17 @@ public class RepeatSubmitAspect { return null; } - // 解析 JWT Token 获取 jti token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); - String jti = (String) JWTUtil.parseToken(token).getPayload(RegisteredPayload.JWT_ID); - - if (StrUtil.isBlank(jti)) { - log.warn("JWT Token 中未找到 jti"); - return null; + // 如果会话方式是jwt,解析 JWT Token 获取 jti + if (securityProperties.getSession().getType().equals("jwt")) { + String jti = (String) JWTUtil.parseToken(token).getPayload(RegisteredPayload.JWT_ID); + if (StrUtil.isBlank(jti)) { + log.warn("JWT Token 中未找到 jti"); + return null; + } } - - // 生成锁的 key:前缀 + jti + 请求方法 + 请求路径 - return RedisConstants.RESUBMIT_LOCK_PREFIX + jti + ":" + request.getMethod() + "-" + request.getRequestURI(); + // 否则会话方式为redis-token,直接使用token + token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); + return RedisConstants.RESUBMIT_LOCK_PREFIX + token + ":" + request.getMethod() + "-" + request.getRequestURI(); } - } diff --git a/src/main/java/com/youlai/boot/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/youlai/boot/core/security/filter/TokenFilter.java similarity index 70% rename from src/main/java/com/youlai/boot/core/security/filter/JwtAuthenticationFilter.java rename to src/main/java/com/youlai/boot/core/security/filter/TokenFilter.java index a102b8c7..36a6922d 100644 --- a/src/main/java/com/youlai/boot/core/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/youlai/boot/core/security/filter/TokenFilter.java @@ -4,7 +4,8 @@ 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.JwtTokenManager; +import com.youlai.boot.core.security.manager.RedisTokenManager; +import com.youlai.boot.core.security.manager.TokenManager; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -17,27 +18,16 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; /** - * JWT Token 验证和解析过滤器 - * - * @author Ray.Hao - * @since 2023/9/13 + * @author wangtao + * @since 2025/3/6 16:50 */ -public class JwtAuthenticationFilter extends OncePerRequestFilter { +public class TokenFilter extends OncePerRequestFilter { - private final JwtTokenManager jwtTokenService; + private final TokenManager tokenManager; - - public JwtAuthenticationFilter(JwtTokenManager jwtTokenService) { - this.jwtTokenService = jwtTokenService; + public TokenFilter(TokenManager tokenManager) { + this.tokenManager = tokenManager; } - - - /** - * 从请求中获取 JWT Token,校验 JWT Token 是否合法 - *

- * 如果合法则将 Authentication 设置到 Spring Security Context 上下文中 - * 如果不合法则清空 Spring Security Context 上下文,并直接返回响应 - */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader(HttpHeaders.AUTHORIZATION); @@ -46,13 +36,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { // 去除 Bearer 前缀 token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length()); // 校验 JWT Token ,包括验签和是否过期 - boolean isValidate = jwtTokenService.validateToken(token); + boolean isValidate = tokenManager.validateToken(token); if (!isValidate) { ResponseUtils.writeErrMsg(response, ResultCode.ACCESS_TOKEN_INVALID); return; } // 将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中 - Authentication authentication = jwtTokenService.parseToken(token); + Authentication authentication = tokenManager.parseToken(token); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { 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 872b0bec..0b6026e9 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 @@ -1,10 +1,28 @@ 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; + /** * JWT 令牌服务实现 * @@ -15,6 +33,14 @@ import org.springframework.stereotype.Service; @Service public class RedisTokenManager implements TokenManager { + private final SecurityProperties securityProperties; + private final RedisTemplate redisTemplate; + + public RedisTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { + this.securityProperties = securityProperties; + this.redisTemplate = redisTemplate; + } + /** * 生成令牌 * @@ -23,7 +49,19 @@ public class RedisTokenManager implements TokenManager { */ @Override public AuthenticationToken generateToken(Authentication authentication) { - return null; + int accessTokenTimeToLive = securityProperties.getRedisToken().getAccessTokenTimeToLive(); + 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); + + return AuthenticationToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(accessTokenTimeToLive) + .build(); } /** @@ -34,7 +72,20 @@ public class RedisTokenManager implements TokenManager { */ @Override public Authentication parseToken(String token) { - return null; + String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token; + OnlineUser user = (OnlineUser) redisTemplate.opsForValue().get(accessTokenKey); + Set 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); } /** @@ -45,7 +96,9 @@ public class RedisTokenManager implements TokenManager { */ @Override public boolean validateToken(String token) { - return false; + String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token; + Boolean hasKey = redisTemplate.hasKey(accessTokenKey); + return hasKey != null && hasKey; } /** @@ -56,6 +109,86 @@ public class RedisTokenManager implements TokenManager { */ @Override public AuthenticationToken refreshToken(String token) { - return null; + 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.getRedisToken().getRefreshTokenTimeToLive(); + // 生成新的访问令牌 + 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 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()); + } } } diff --git a/src/main/java/com/youlai/boot/core/security/model/OnlineUser.java b/src/main/java/com/youlai/boot/core/security/model/OnlineUser.java new file mode 100644 index 00000000..8fe99b2c --- /dev/null +++ b/src/main/java/com/youlai/boot/core/security/model/OnlineUser.java @@ -0,0 +1,18 @@ +package com.youlai.boot.core.security.model; + +import lombok.Data; + +import java.util.Set; + +/** + * @author wangtao + * @since 2025/2/27 10:31 + */ +@Data +public class OnlineUser{ + private Long id; + private Long deptId; + private String username; + private Integer dataScope; + private Set authorities; +} diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 434a19c2..18c6c5d1 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -50,8 +50,6 @@ "type": "java.lang.String", "description": "阿里云 OSS 存储桶名称" }, - - { "name": "xxl.job.enabled", "type": "java.lang.Boolean", @@ -101,7 +99,21 @@ "name": "spring.cache.enabled", "type": "java.lang.Boolean", "description": "缓存开关" + }, + { + "name": "security.redis-token.multi-login", + "type": "java.lang.String", + "description": "是否允许多点登录" + }, + { + "name": "security.redis-token.access-token-time-to-live", + "type": "java.lang.String", + "description": "访问令牌有效期" + }, + { + "name": "security.redis-token.refresh-token-time-to-live", + "type": "java.lang.String", + "description": "刷新令牌有效期" } - ] } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 0a9722ae..37a03c92 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -86,6 +86,13 @@ security: # 刷新令牌有效期(单位:秒),默认 7 天 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 ignore-urls: - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录) - /api/v1/auth/captcha # 验证码获取接口