feat: 支持redis-token和单设备登录

This commit is contained in:
wangtaocs
2025-03-06 17:30:16 +08:00
parent cc78cb8b21
commit e77d110b38
9 changed files with 247 additions and 42 deletions

View File

@@ -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;
}
}

View File

@@ -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<String, Object> 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();
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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 是否合法
* <p>
* 如果合法则将 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) {

View File

@@ -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<String, Object> redisTemplate;
public RedisTokenManager(SecurityProperties securityProperties, RedisTemplate<String, Object> 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<SimpleGrantedAuthority> authorities = user.getAuthorities()
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(user.getId());
userDetails.setUsername(user.getUsername());
userDetails.setDeptId(user.getDeptId());
userDetails.setDataScope(user.getDataScope());
userDetails.setAuthorities(authorities);
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
/**
@@ -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<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
user.setAuthorities(roles);
redisTemplate.opsForValue().set(tokenKey, user, ttl, TimeUnit.SECONDS);
return token;
}
/**
* 清除redis中的用户信息
*
* @param token Redis Token
*/
@Override
public void blacklistToken(String token) {
/**
* 根据token删除当前用户的accessToken和refreshToken以及 userId-token 的hashmap
*/
OnlineUser user = (OnlineUser) redisTemplate.opsForValue().get(TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token);
if (!Objects.isNull(user)) {
Long userId = user.getId();
String refreshToken = (String) redisTemplate.opsForHash().get("userId-token:"+TokenKeyEnum.REFRESH_TOKEN_KEY.getValue(), user.getId().toString());
redisTemplate.delete(TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token);
redisTemplate.delete(TokenKeyEnum.REFRESH_TOKEN_KEY.getValue() + refreshToken);
// 删除 userId-token 的hashmap
redisTemplate.opsForHash().delete("userId-token:"+TokenKeyEnum.ACCESS_TOKEN_KEY.getValue(), userId.toString());
redisTemplate.opsForHash().delete("userId-token:"+TokenKeyEnum.REFRESH_TOKEN_KEY.getValue(), userId.toString());
}
}
}

View File

@@ -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<String> authorities;
}