diff --git a/sql/mysql/youlai_admin.sql b/sql/mysql/youlai_admin.sql index b143fecc..7acd325b 100644 --- a/sql/mysql/youlai_admin.sql +++ b/sql/mysql/youlai_admin.sql @@ -299,7 +299,7 @@ CREATE TABLE `sys_role_dept` ( `role_id` bigint NOT NULL COMMENT '角色ID', `dept_id` bigint NOT NULL COMMENT '部门ID', UNIQUE INDEX `uk_roleid_deptid`(`role_id` ASC, `dept_id` ASC) USING BTREE COMMENT '角色部门唯一索引' -) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色部门关联表(用于自定义数据权限)'; +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色部门关联表'; -- ============================================ -- 系统管理员角色菜单权限(role_id=2) diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java index 8fbaa3be..9aaadcda 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -37,4 +37,12 @@ public interface JwtClaimConstants { */ String AUTHORITIES = "authorities"; + /** + * Token 版本号 + *

+ * 用于用户级会话失效,当用户修改密码、被禁用、强制下线时递增版本号, + * 使该用户之前签发的所有 Token 失效。 + */ + String TOKEN_VERSION = "tokenVersion"; + } 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 da943764..b54c0455 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -37,8 +37,8 @@ public interface RedisConstants { // 已撤销 Token 的 JTI(单端退出/会话注销):如果 jti 在撤销列表中,则 Token 立即无效 String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; String REVOKED_JTI = BLACKLIST_TOKEN; - // 用户 Token 生效起点(用于按用户失效历史 JWT):token.iat < tokenValidAfter => token 无效 - String USER_TOKEN_VALID_AFTER = "auth:user:token_valid_after:{}"; + // 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效 + String USER_TOKEN_VERSION = "auth:user:token_version:{}"; } /** diff --git a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java index 9de12107..02468bf7 100644 --- a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java @@ -29,7 +29,6 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import java.util.*; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -39,7 +38,7 @@ import java.util.stream.Collectors; *

* @@ -50,9 +49,6 @@ import java.util.stream.Collectors; @Service public class JwtTokenManager implements TokenManager { - /** tokenValidAfter 默认过期时间(7天),避免Redis内存泄漏 */ - private static final long TOKEN_VALID_AFTER_TTL_SECONDS = 7 * 24 * 60 * 60; - private final SecurityProperties securityProperties; private final RedisTemplate redisTemplate; private final byte[] secretKey; @@ -158,7 +154,7 @@ public class JwtTokenManager implements TokenManager { *
    *
  1. 签名验证 + 过期时间检查
  2. *
  3. 刷新令牌类型校验(仅刷新场景)
  4. - *
  5. tokenValidAfter 校验(用户级会话失效)
  6. + *
  7. tokenVersion 校验(用户级会话失效)
  8. *
  9. jti 黑名单校验(单Token撤销)
  10. *
* @@ -182,21 +178,19 @@ public class JwtTokenManager implements TokenManager { return false; } } - // 2. 校验 tokenValidAfter(用于按用户维度失效历史 Token) - // 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,更新 tokenValidAfter,早于该时间签发的 Token 全部失效 + // 2. 校验 tokenVersion(用于按用户维度失效历史 Token) + // 场景示例:用户修改密码、被管理员强制下线、手动"踢所有端"后,递增 tokenVersion, + // 之前签发的 Token 因版本号不匹配而失效 Long userId = payloads.getLong(JwtClaimConstants.USER_ID); if (userId != null) { - Object issuedAtObj = payloads.get(JWTPayload.ISSUED_AT); - long issuedAtSeconds = 0; - if (issuedAtObj instanceof Date issuedAtDate) { - issuedAtSeconds = issuedAtDate.getTime() / 1000; - } + Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION); + + String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); + Object currentVersionObj = redisTemplate.opsForValue().get(versionKey); + int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0; - String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId); - Object validAfterObj = redisTemplate.opsForValue().get(validAfterKey); - long validAfterSeconds = validAfterObj != null ? Convert.toLong(validAfterObj) : 0; - - if (issuedAtSeconds < validAfterSeconds) { + // 版本号不匹配则 Token 无效(新签发的 Token 版本号必须 >= Redis 中的版本号) + if (tokenVersion == null || tokenVersion < currentVersion) { return false; } } @@ -273,13 +267,14 @@ public class JwtTokenManager implements TokenManager { /** * 失效指定用户的所有会话 *

- * 通过更新用户 tokenValidAfter 时间戳,使早于该时间签发的 Token 全部失效。 + * 通过递增用户 tokenVersion,使该用户之前签发的所有 Token 因版本号不匹配而失效。 *

* 适用场景: *

* * @param userId 用户ID @@ -290,10 +285,9 @@ public class JwtTokenManager implements TokenManager { return; } - String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId); - long nowSeconds = System.currentTimeMillis() / 1000; - // 设置过期时间,避免Redis内存泄漏 - redisTemplate.opsForValue().set(validAfterKey, nowSeconds, TOKEN_VALID_AFTER_TTL_SECONDS, TimeUnit.SECONDS); + String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); + // 递增版本号,无需设置 TTL(版本号永久有效,避免 TTL 过期导致的安全问题) + redisTemplate.opsForValue().increment(versionKey); } /** @@ -341,6 +335,7 @@ public class JwtTokenManager implements TokenManager { *
  • dataScopes - 数据权限列表
  • *
  • authorities - 角色权限集合
  • *
  • tokenType - 是否为刷新令牌
  • + *
  • tokenVersion - Token版本号(用于会话失效控制)
  • *
  • iat/exp - 签发/过期时间
  • *
  • jti - Token唯一标识(用于撤销)
  • * @@ -377,6 +372,16 @@ public class JwtTokenManager implements TokenManager { .collect(Collectors.toSet()); payload.put(JwtClaimConstants.AUTHORITIES, roles); + // 获取当前用户的 Token 版本号,用于会话失效控制 + Long userId = userDetails.getUserId(); + int tokenVersion = 0; + if (userId != null) { + String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); + Object versionObj = redisTemplate.opsForValue().get(versionKey); + tokenVersion = versionObj != null ? Convert.toInt(versionObj) : 0; + } + payload.put(JwtClaimConstants.TOKEN_VERSION, tokenVersion); + Date now = new Date(); payload.put(JWTPayload.ISSUED_AT, now); payload.put(JwtClaimConstants.TOKEN_TYPE, false);