feat: 支持redis-token和单设备登录
This commit is contained in:
20
src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java
Normal file
20
src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user