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 b753deaf..717a7614 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -26,10 +26,16 @@ public interface RedisConstants { * 认证模块 */ interface Auth { - - String ACCESS_TOKEN = "auth:token:access:{}"; // 访问Token - String REFRESH_TOKEN = "auth:token:refresh:{}"; // 刷新Token - String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; // 黑名单Token + // 存储访问令牌对应的用户信息(accessToken -> OnlineUser) + String ACCESS_TOKEN_USER = "auth:token:access:{}"; + // 存储刷新令牌对应的用户信息(refreshToken -> OnlineUser) + String REFRESH_TOKEN_USER = "auth:token:refresh:{}"; + // 用户与访问令牌的映射(userId -> accessToken) + String USER_ACCESS_TOKEN = "auth:user:access:{}"; + // 用户与刷新令牌的映射(userId -> refreshToken + String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; + // 黑名单 Token(用于退出登录或注销) + String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; } /** diff --git a/src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java b/src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java deleted file mode 100644 index d4aa10e6..00000000 --- a/src/main/java/com/youlai/boot/common/enums/TokenKeyEnum.java +++ /dev/null @@ -1,20 +0,0 @@ -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 395436a0..523eee07 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -11,7 +11,7 @@ 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.TokenAuthenticationFilter; -import com.youlai.boot.core.security.manager.TokenManager; +import com.youlai.boot.core.security.token.TokenManager; import com.youlai.boot.core.security.service.SysUserDetailsService; import com.youlai.boot.system.service.ConfigService; import com.youlai.boot.system.service.UserService; 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 cadd6acc..665ccf0f 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java @@ -1,108 +1,111 @@ package com.youlai.boot.config.property; - import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; -import java.util.ArrayList; -import java.util.List; - /** - * 安全配置属性 + * 安全模块配置属性类 + * + *

映射 application.yml 中 security 前缀的安全相关配置

* * @author Ray.Hao * @since 2024/4/18 */ @Data +@Component @Validated @ConfigurationProperties(prefix = "security") public class SecurityProperties { /** - * 免认证请求路径白名单 + * 会话管理配置 */ - private List ignoreUrls = new ArrayList<>(); + private SessionConfig session; /** - * 静态资源路径(不经过安全过滤器) + * 安全白名单路径(完全绕过安全过滤器) + *

示例值:/api/v1/auth/login/**, /ws/** */ - private List unsecuredUrls = new ArrayList<>(); + @NotEmpty + private String[] ignoreUrls; /** - * 认证核心配置 + * 非安全端点路径(允许匿名访问的API) + *

示例值:/doc.html, /v3/api-docs/** */ - private Auth auth = new Auth(); + @NotEmpty + private String[] unsecuredUrls; + /** + * 会话配置嵌套类 + */ @Data - public static class Auth { + public static class SessionConfig { /** * 认证策略类型 + *

*/ @NotNull - private AuthType type = AuthType.JWT; + private String type; /** - * 访问令牌有效期(秒) + * 访问令牌有效期(单位:秒) + *

默认值:3600(1小时)

+ *

-1 表示永不过期

*/ @Min(-1) - private int accessTokenTtl = 3600; + private Integer accessTokenTimeToLive = 3600; /** - * 刷新令牌有效期(秒) + * 刷新令牌有效期(单位:秒) + *

默认值:604800(7天)

+ *

-1 表示永不过期

*/ @Min(-1) - private int refreshTokenTtl = 604800; + private Integer refreshTokenTimeToLive = 604800; /** - * JWT 配置 + * JWT 配置项 */ - private JwtConfig jwtConfig = new JwtConfig(); + private JwtConfig jwt; /** - * Redis Token 配置 + * Redis令牌配置项 */ - 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; - } + private RedisTokenConfig redisToken; } /** - * 认证策略类型枚举 + * JWT 配置嵌套类 */ - public enum AuthType { - JWT, REDIS_TOKEN + @Data + public static class JwtConfig { + /** + * JWT签名密钥 + *

HS256算法要求至少32个字符

+ *

示例:SecretKey012345678901234567890123456789

+ */ + @NotNull + private String secretKey; } /** - * 会话控制策略枚举 + * Redis令牌配置嵌套类 */ - public enum SessionControlStrategy { - REVOKE_OLDEST, DENY_NEW + @Data + public static class RedisTokenConfig { + /** + * 是否允许多设备同时登录 + *

true - 允许同一账户多设备登录(默认)

+ *

false - 新登录会使旧令牌失效

+ */ + private Boolean allowMultiLogin = true; } } diff --git a/src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java index 037fe7ee..9cbbd0a6 100644 --- a/src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/youlai/boot/core/security/filter/TokenAuthenticationFilter.java @@ -4,7 +4,7 @@ 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.TokenManager; +import com.youlai.boot.core.security.token.TokenManager; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; 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 deleted file mode 100644 index 80dce8f6..00000000 --- a/src/main/java/com/youlai/boot/core/security/manager/RedisTokenManager.java +++ /dev/null @@ -1,255 +0,0 @@ -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; - -/** - * Redis Token 管理器 - *

- * 用于生成、解析、校验、刷新 JWT Token - * - * @author Ray.Hao - * @since 2024/11/15 - */ -@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 - ) { - this.securityProperties = securityProperties; - this.redisTemplate = redisTemplate; - } - - /** - * 生成令牌 - * - * @param authentication 用户认证信息 - * @return - */ - @Override - public AuthenticationToken generateToken(Authentication authentication) { - int accessTokenTtl = securityProperties.getAuth().getAccessTokenTtl(); - - // 创建新会话 - String accessToken = createNewSession(authentication, ); - - // 创建刷新令牌(独立控制) - String refreshToken = createNewSession(authentication, TokenType.REFRESH, - config.getRefreshTokenTtl(), 1); // 刷新令牌强制单设备 - - return AuthenticationToken.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .tokenType("Bearer") - .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() - ); - }); - } - } - - - /** - * 解析令牌 - * - * @param token JWT Token - * @return - */ - @Override - public Authentication parseToken(String token) { - 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); - } - - /** - * 验证令牌 - * - * @param token JWT Token - * @return - */ - @Override - public boolean validateToken(String token) { - String accessTokenKey = TokenKeyEnum.ACCESS_TOKEN_KEY.getValue() + token; - return redisTemplate.hasKey(accessTokenKey); - } - - /** - * 刷新令牌 - * - * @param token 刷新令牌 - * @return - */ - @Override - public AuthenticationToken refreshToken(String token) { - 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.getAuth().getRefreshTokenTtl(); - // 生成新的访问令牌 - 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 index 8fe99b2c..d695c653 100644 --- a/src/main/java/com/youlai/boot/core/security/model/OnlineUser.java +++ b/src/main/java/com/youlai/boot/core/security/model/OnlineUser.java @@ -1,18 +1,45 @@ package com.youlai.boot.core.security.model; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.Set; /** + * 在线用户信息对象 + * * @author wangtao * @since 2025/2/27 10:31 */ @Data -public class OnlineUser{ - private Long id; - private Long deptId; +@NoArgsConstructor +@AllArgsConstructor +public class OnlineUser { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ private String username; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 数据权限范围 + *

定义用户可访问的数据范围,如全部、本部门或自定义范围

+ */ private Integer dataScope; + + /** + * 角色权限集合 + */ private Set authorities; } diff --git a/src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java b/src/main/java/com/youlai/boot/core/security/token/JwtTokenManager.java similarity index 93% rename from src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java rename to src/main/java/com/youlai/boot/core/security/token/JwtTokenManager.java index 6a1b4af5..02b3b894 100644 --- a/src/main/java/com/youlai/boot/core/security/manager/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/core/security/token/JwtTokenManager.java @@ -1,4 +1,4 @@ -package com.youlai.boot.core.security.manager; +package com.youlai.boot.core.security.token; import cn.hutool.core.convert.Convert; import cn.hutool.core.date.DateUtil; @@ -38,7 +38,7 @@ import java.util.stream.Collectors; * @author Ray.Hao * @since 2024/11/15 */ -@ConditionalOnProperty(value = "security.auth.type", havingValue = "jwt") +@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt") @Service public class JwtTokenManager implements TokenManager { @@ -49,7 +49,7 @@ public class JwtTokenManager implements TokenManager { public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { this.securityProperties = securityProperties; this.redisTemplate = redisTemplate; - this.secretKey = securityProperties.getAuth().getJwtConfig().getKey().getBytes(); + this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes(); } /** @@ -60,8 +60,8 @@ public class JwtTokenManager implements TokenManager { */ @Override public AuthenticationToken generateToken(Authentication authentication) { - int accessTokenTimeToLive = securityProperties.getAuth().getAccessTokenTtl(); - int refreshTokenTimeToLive = securityProperties.getAuth().getRefreshTokenTtl(); + int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive(); + int refreshTokenTimeToLive = securityProperties.getSession().getRefreshTokenTimeToLive(); String accessToken = generateToken(authentication, accessTokenTimeToLive); String refreshToken = generateToken(authentication, refreshTokenTimeToLive); @@ -175,7 +175,7 @@ public class JwtTokenManager implements TokenManager { } Authentication authentication = parseToken(refreshToken); - int accessTokenExpiration = securityProperties.getAuth().getRefreshTokenTtl(); + int accessTokenExpiration = securityProperties.getSession().getRefreshTokenTimeToLive(); String newAccessToken = generateToken(authentication, accessTokenExpiration); return AuthenticationToken.builder() diff --git a/src/main/java/com/youlai/boot/core/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/core/security/token/RedisTokenManager.java new file mode 100644 index 00000000..50c22c7e --- /dev/null +++ b/src/main/java/com/youlai/boot/core/security/token/RedisTokenManager.java @@ -0,0 +1,229 @@ +package com.youlai.boot.core.security.token; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.constant.RedisConstants; +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.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.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Redis Token 管理器 + *

+ * 用于生成、解析、校验、刷新 JWT Token + * + * @author Ray.Hao + * @since 2024/11/15 + */ +@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token") +@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; + } + + @Override + public AuthenticationToken generateToken(Authentication authentication) { + SysUserDetails user = (SysUserDetails) authentication.getPrincipal(); + int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); + int refreshTtl = securityProperties.getSession().getRefreshTokenTimeToLive(); + + // 生成随机令牌 + String accessToken = IdUtil.fastSimpleUUID(); + String refreshToken = IdUtil.fastSimpleUUID(); + + // 构建用户在线信息(不包含密码) + OnlineUser onlineUser = buildOnlineUser(user); + + // 将访问令牌与刷新令牌与用户信息分别存入 Redis,并设置过期时间 + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, accessToken), + onlineUser, + accessTtl, + TimeUnit.SECONDS + ); + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken), + onlineUser, + refreshTtl, + TimeUnit.SECONDS + ); + + // 单设备登录控制,若不允许多设备登录,则通过用户ID映射保存当前最新的访问令牌 + Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin(); + if (!allowMultiLogin) { + Long userId = user.getUserId(); + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); + // 获取当前用户已有的访问令牌 + String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey); + if (oldAccessToken != null) { + // 删除旧的访问令牌对应的用户信息缓存 + redisTemplate.delete(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, oldAccessToken)); + } + // 更新用户与访问令牌的映射 + redisTemplate.opsForValue().set(userAccessKey, accessToken, accessTtl, TimeUnit.SECONDS); + } + // 同时存储用户与刷新令牌的映射,便于后续刷新和踢出旧会话 + String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, user.getUserId()); + redisTemplate.opsForValue().set(userRefreshKey, refreshToken, refreshTtl, TimeUnit.SECONDS); + + return AuthenticationToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(accessTtl) + .build(); + } + + /** + * 根据 token 解析用户信息 + * + * @param token JWT Token + * @return + */ + @Override + public Authentication parseToken(String token) { + // 根据访问令牌从 Redis 中获取在线用户信息 + String tokenUserCacheKey = StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token); + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(tokenUserCacheKey); + + if (onlineUser == null) return null; + + // 构建用户权限集合 + Set authorities = onlineUser.getAuthorities().stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + + // 构建用户详情对象 + SysUserDetails userDetails = new SysUserDetails(); + userDetails.setUserId(onlineUser.getUserId()); + userDetails.setUsername(onlineUser.getUsername()); + userDetails.setDeptId(onlineUser.getDeptId()); + userDetails.setDataScope(onlineUser.getDataScope()); + userDetails.setAuthorities(authorities); + + return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + } + + /** + * 校验 Token 是否有效 + * + * @param token 访问令牌 + * @return + */ + @Override + public boolean validateToken(String token) { + String tokenKey = StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token); + return redisTemplate.hasKey(tokenKey); + } + + /** + * 刷新令牌 + * + * @param refreshToken 刷新令牌 + * @return + */ + @Override + public AuthenticationToken refreshToken(String refreshToken) { + // 根据刷新令牌获取在线用户信息 + String refreshKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(refreshKey); + + if (onlineUser == null) { + throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); + } + + // 获取当前用户的旧访问令牌(如果存在) + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()); + String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey); + if (oldAccessToken != null) { + // 删除旧的访问令牌记录 + redisTemplate.delete(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, oldAccessToken)); + } + + // 生成新访问令牌 + String newAccessToken = IdUtil.fastSimpleUUID(); + int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), + onlineUser, + accessTtl, + TimeUnit.SECONDS + ); + + // 更新用户与访问令牌的映射(若单设备登录,则更新映射以踢出旧会话) + redisTemplate.opsForValue().set(userAccessKey, newAccessToken, accessTtl, TimeUnit.SECONDS); + + return AuthenticationToken.builder() + .accessToken(newAccessToken) + .refreshToken(refreshToken) + .expiresIn(accessTtl) + .build(); + } + + + /** + * 将 Token 加入黑名单 + * + * @param token 访问令牌 + */ + @Override + public void blacklistToken(String token) { + // 删除访问令牌对应的在线用户信息缓存 + String accessKey = StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token); + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(accessKey); + + if (onlineUser != null) { + Long userId = onlineUser.getUserId(); + + // 删除访问令牌缓存和用户与访问令牌的映射 + redisTemplate.delete(accessKey); + redisTemplate.delete(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId)); + + // 删除用户与刷新令牌的映射,以及刷新令牌对应的缓存 + String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId); + String refreshToken = (String) redisTemplate.opsForValue().get(userRefreshKey); + if (refreshToken != null) { + redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); + redisTemplate.delete(userRefreshKey); + } + } + } + + /** + * 构建 OnlineUser 对象 + */ + private OnlineUser buildOnlineUser(SysUserDetails user) { + Long userId = user.getUserId(); + Set roles = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + return new OnlineUser( + userId, + user.getUsername(), + user.getDeptId(), + user.getDataScope(), + roles + ); + } +} diff --git a/src/main/java/com/youlai/boot/core/security/manager/TokenManager.java b/src/main/java/com/youlai/boot/core/security/token/TokenManager.java similarity index 94% rename from src/main/java/com/youlai/boot/core/security/manager/TokenManager.java rename to src/main/java/com/youlai/boot/core/security/token/TokenManager.java index 0fd979f8..1fc090ac 100644 --- a/src/main/java/com/youlai/boot/core/security/manager/TokenManager.java +++ b/src/main/java/com/youlai/boot/core/security/token/TokenManager.java @@ -1,4 +1,4 @@ -package com.youlai.boot.core.security.manager; +package com.youlai.boot.core.security.token; import com.youlai.boot.core.security.model.AuthenticationToken; @@ -25,7 +25,7 @@ public interface TokenManager { /** * 解析 Token 获取认证信息 * - * @param token JWT Token + * @param token Token * @return 用户认证信息 */ Authentication parseToken(String token); diff --git a/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java index 77138ce6..a2a09261 100644 --- a/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/shared/auth/service/impl/AuthServiceImpl.java @@ -17,7 +17,7 @@ import com.youlai.boot.shared.auth.enums.CaptchaTypeEnum; import com.youlai.boot.core.security.model.AuthenticationToken; import com.youlai.boot.shared.auth.model.CaptchaInfo; import com.youlai.boot.shared.auth.service.AuthService; -import com.youlai.boot.core.security.manager.TokenManager; +import com.youlai.boot.core.security.token.TokenManager; import com.youlai.boot.shared.sms.enums.SmsTypeEnum; import com.youlai.boot.shared.sms.service.SmsService; import lombok.RequiredArgsConstructor; 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 781f79da..ab32f36d 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 @@ -12,7 +12,7 @@ import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.model.Option; -import com.youlai.boot.core.security.manager.TokenManager; +import com.youlai.boot.core.security.token.TokenManager; import com.youlai.boot.core.security.service.PermissionService; import com.youlai.boot.core.security.util.SecurityUtils; import com.youlai.boot.shared.mail.service.MailService; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 78318d0d..feb82393 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -72,25 +72,21 @@ mybatis-plus: # 安全配置 security: - # 认证策略核心配置 - auth: - type: jwt # 认证策略 [jwt|redis-token] - access-token-ttl: 3600 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 - refresh-token-ttl: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 - # JWT 认证配置 + session: + type: jwt # 会话方式 [jwt|redis-token] + access-token-time-to-live: 3600 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 + refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 jwt: - key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符,RS256至少2048位) - # Redis Token 认证配置 + secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) redis-token: - max-sessions: 1 # 最大允许多个设备同时登录的数量(默认为1) - session-control: REVOKE_OLDEST # 会话超限处理策略 [REJECT|REVOKE_OLDEST|REVOKE_ALL] - # 白名单路径 + allow-multi-login: false # 是否允许多设备登录 + # 安全白名单路径(完全绕过安全过滤器) ignore-urls: - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录) - /api/v1/auth/captcha # 验证码获取接口 - /api/v1/auth/refresh-token # 刷新令牌接口 - /ws/** # WebSocket接口 - # 不走 Spring Security 过滤器链的请求路径(一般是静态资源) + # 非安全端点路径(允许匿名访问的API) unsecured-urls: - ${springdoc.swagger-ui.path} - /doc.html diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 834d55c6..a3c2e6b7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -70,29 +70,21 @@ mybatis-plus: # 安全配置 security: - # 认证策略核心配置 - auth: - # 认证策略 [jwt|redis-token] - type: jwt - # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 - access-token-ttl: 3600 - # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 - refresh-token-ttl: 604800 - # JWT 认证配置 + session: + type: jwt # 会话方式 [jwt|redis-token] + access-token-time-to-live: 3600 # 访问令牌 有效期(单位:秒),默认 1 小时,-1 表示永不过期 + refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 jwt: - key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符,RS256至少2048位) - # Redis Token 认证配置 + secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) redis-token: - max-sessions: 1 # 最大允许多个设备同时登录的数量(默认为1) - session-control: REVOKE_OLDEST # 会话超限处理策略 [REJECT|REVOKE_OLDEST|REVOKE_ALL] - - # 无需认证的请求路径 + allow-multi-login: true # 是否允许多设备登录 + # 安全白名单路径(完全绕过安全过滤器) ignore-urls: - /api/v1/auth/login/** # 登录接口(账号密码登录、手机验证码登录和微信登录) - /api/v1/auth/captcha # 验证码获取接口 - /api/v1/auth/refresh-token # 刷新令牌接口 - /ws/** # WebSocket接口 - # 不走 Spring Security 过滤器链的请求路径(一般是静态资源) + # 非安全端点路径(允许匿名访问的API) unsecured-urls: - ${springdoc.swagger-ui.path} - /doc.html