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 8f6e2d4f..c0a84a94 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -35,4 +35,9 @@ public interface JwtClaimConstants { */ String AUTHORITIES = "authorities"; + /** + * 安全版本号,用于按用户失效历史令牌 + */ + String SECURITY_VERSION = "securityVersion"; + } 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 717a7614..195f14af 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -36,6 +36,8 @@ public interface RedisConstants { String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; // 黑名单 Token(用于退出登录或注销) String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; + // 用户安全版本号(用于按用户失效历史 JWT) + String USER_SECURITY_VERSION = "auth:user:security_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 627a533d..bba10d7c 100644 --- a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java @@ -110,7 +110,7 @@ public class JwtTokenManager implements TokenManager { */ @Override public boolean validateToken(String token) { - return validateToken(token,false); + return validateToken(token, false); } /** @@ -121,7 +121,7 @@ public class JwtTokenManager implements TokenManager { */ @Override public boolean validateRefreshToken(String refreshToken) { - return validateToken(refreshToken,true); + return validateToken(refreshToken, true); } /** @@ -138,17 +138,34 @@ public class JwtTokenManager implements TokenManager { boolean isValid = jwt.setKey(secretKey).validate(0); if (isValid) { - // 检查 Token 是否已被加入黑名单(注销、修改密码等场景) JSONObject payloads = jwt.getPayloads(); + // 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用) String jti = payloads.getStr(JWTPayload.JWT_ID); - if(validateRefreshToken) { + if (validateRefreshToken) { //刷新token需要校验token类别 boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE); if (!isRefreshToken) { return false; } } - // 判断是否在黑名单中,如果在,则返回 false 标识Token无效 + // 2. 校验安全版本号(用于按用户维度失效历史 Token) + Long userId = payloads.getLong(JwtClaimConstants.USER_ID); + if (userId != null) { + // 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本 + Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION); + int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0; + + String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); + Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey); + int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0; + + // 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效 + if (tokenVersion < currentVersion) { + return false; + } + } + + // 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效 if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) { return false; } @@ -167,7 +184,7 @@ public class JwtTokenManager implements TokenManager { */ @Override public void invalidateToken(String token) { - if(StringUtils.isBlank(token)) { + if (StringUtils.isBlank(token)) { return; } @@ -186,16 +203,33 @@ public class JwtTokenManager implements TokenManager { // Token已过期,直接返回 return; } - // 计算Token剩余时间,将其加入黑名单 + // 计算Token剩余时间,将其加入黑名单(值使用简单布尔标记即可) int expirationIn = expirationAt - currentTimeSeconds; - redisTemplate.opsForValue().set(blacklistTokenKey, null, expirationIn, TimeUnit.SECONDS); + redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); } else { // 永不过期的Token永久加入黑名单 - redisTemplate.opsForValue().set(blacklistTokenKey, null); + redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE); } ; } + /** + * 失效指定用户的所有会话 + *

+ * 通过提升用户的安全版本号,使携带旧版本号的 Token 在后续校验时全部失效 + */ + @Override + public void invalidateUserSessions(Long userId) { + if (userId == null) { + return; + } + + String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); + // 递增版本号 + redisTemplate.opsForValue().increment(versionKey); + + } + /** * 刷新令牌 * @@ -259,6 +293,12 @@ public class JwtTokenManager implements TokenManager { payload.put(JwtClaimConstants.TOKEN_TYPE, true); } + // 设置安全版本号:不存在时默认为 0 + String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userDetails.getUserId()); + Integer currentVersion = (Integer) redisTemplate.opsForValue().get(versionKey); + int securityVersion = currentVersion != null ? currentVersion : 0; + payload.put(JwtClaimConstants.SECURITY_VERSION, securityVersion); + // 设置过期时间 -1 表示永不过期 if (ttl != -1) { Date expiresAt = DateUtil.offsetSecond(now, ttl); diff --git a/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java index a2dff3a5..7efefaae 100644 --- a/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java @@ -18,6 +18,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; +import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -134,17 +135,16 @@ public class RedisTokenManager implements TokenManager { */ @Override public AuthenticationToken refreshToken(String refreshToken) { - OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); + OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue() + .get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); if (onlineUser == null) { throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); } - - String oldAccessToken = (String) redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId())); - + Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId())); // 删除旧的访问令牌记录 - if (oldAccessToken != null) { - redisTemplate.delete(formatTokenKey(oldAccessToken)); - } + Optional.of(oldAccessTokenValue) + .map(String.class::cast) + .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken))); // 生成新访问令牌并存储 String newAccessToken = IdUtil.fastSimpleUUID(); @@ -168,24 +168,42 @@ public class RedisTokenManager implements TokenManager { OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token)); if (onlineUser != null) { Long userId = onlineUser.getUserId(); - // 1. 删除访问令牌相关 - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); - String accessToken = (String) redisTemplate.opsForValue().get(userAccessKey); - if (accessToken != null) { - redisTemplate.delete(formatTokenKey(accessToken)); - redisTemplate.delete(userAccessKey); - } - - // 2. 删除刷新令牌相关 - 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); - } + invalidateUserSessions(userId); } } + /** + * 使指定用户的所有会话失效 + * + * @param userId 用户ID + */ + @Override + public void invalidateUserSessions(Long userId) { + if (userId == null) { + return; + } + + // 1. 删除访问令牌相关 + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); + Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey); + Optional.of(accessTokenValue) + .map(String.class::cast) + .ifPresent(accessToken -> redisTemplate.delete(formatTokenKey(accessToken))); + // 无论是否存在访问令牌映射,都尝试删除 userAccessKey + redisTemplate.delete(userAccessKey); + + // 2. 删除刷新令牌相关 + String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId); + Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey); + Optional.of(refreshTokenValue) + .map(String.class::cast) + .ifPresent(refreshToken -> + redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)) + ); + // 同样清理 userRefreshKey 本身 + redisTemplate.delete(userRefreshKey); + } + /** * 将访问令牌和刷新令牌存储至 Redis * @@ -218,10 +236,10 @@ public class RedisTokenManager implements TokenManager { String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); // 单设备登录控制,删除旧的访问令牌 if (!allowMultiLogin) { - String oldAccessToken = (String) redisTemplate.opsForValue().get(userAccessKey); - if (oldAccessToken != null) { - redisTemplate.delete(formatTokenKey(oldAccessToken)); - } + Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey); + Optional.of(oldAccessTokenValue) + .map(String.class::cast) + .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken))); } // 存储访问令牌映射(用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌 setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive()); diff --git a/src/main/java/com/youlai/boot/security/token/TokenManager.java b/src/main/java/com/youlai/boot/security/token/TokenManager.java index 3c051dc4..9cadd3bb 100644 --- a/src/main/java/com/youlai/boot/security/token/TokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/TokenManager.java @@ -64,5 +64,13 @@ public interface TokenManager { // throw new UnsupportedOperationException("Not implemented"); } + /** + * 使指定用户的所有会话失效 + * + * @param userId 用户ID + */ + default void invalidateUserSessions(Long userId) { + // 默认空实现,由具体 TokenManager 决定是否支持按用户下线 + } } diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java index 7b7d98ec..644777df 100644 --- a/src/main/java/com/youlai/boot/system/controller/UserController.java +++ b/src/main/java/com/youlai/boot/system/controller/UserController.java @@ -195,24 +195,24 @@ public class UserController { return Result.judge(result); } - @Operation(summary = "重置用户密码") + @Operation(summary = "重置指定用户密码") @PutMapping(value = "/{userId}/password/reset") @PreAuthorize("@ss.hasPerm('sys:user:reset-password')") - public Result resetPassword( + public Result resetUserPassword( @Parameter(description = "用户ID") @PathVariable Long userId, @RequestParam String password ) { - boolean result = userService.resetPassword(userId, password); + boolean result = userService.resetUserPassword(userId, password); return Result.judge(result); } - @Operation(summary = "修改密码") + @Operation(summary = "当前用户修改密码") @PutMapping(value = "/password") - public Result changePassword( + public Result changeCurrentUserPassword( @RequestBody PasswordUpdateForm data ) { Long currUserId = SecurityUtils.getUserId(); - boolean result = userService.changePassword(currUserId, data); + boolean result = userService.changeUserPassword(currUserId, data); return Result.judge(result); } diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java index 9b8660ba..2ee3ca09 100644 --- a/src/main/java/com/youlai/boot/system/service/UserService.java +++ b/src/main/java/com/youlai/boot/system/service/UserService.java @@ -107,22 +107,22 @@ public interface UserService extends IService { boolean updateUserProfile(UserProfileForm formData); /** - * 修改用户密码 + * 修改指定用户密码 * * @param userId 用户ID * @param data 修改密码表单数据 * @return {@link Boolean} 是否修改成功 */ - boolean changePassword(Long userId, PasswordUpdateForm data); + boolean changeUserPassword(Long userId, PasswordUpdateForm data); /** - * 重置用户密码 + * 重置指定用户密码 * * @param userId 用户ID * @param password 重置后的密码 * @return {@link Boolean} 是否重置成功 */ - boolean resetPassword(Long userId, String password); + boolean resetUserPassword(Long userId, String password); /** * 发送短信验证码(绑定或更换手机号) diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java index 148ea570..60a4fc2b 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java @@ -72,11 +72,9 @@ public class UserRoleServiceImpl extends ServiceImpl i .in(UserRole::getRoleId, removedRoles)); } - // 当权限变更时清除登录态 + // 当权限变更时清除被修改用户的登录态 if (rolesChanged) { - // 获取用户所有有效token(根据实际token存储实现) - String accessToken = SecurityUtils.getTokenFromRequest(); - tokenManager.invalidateToken(accessToken); + tokenManager.invalidateUserSessions(userId); } } 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 827779c4..749cfba1 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 @@ -485,14 +485,14 @@ public class UserServiceImpl extends ServiceImpl implements Us } /** - * 修改用户密码 + * 修改指定用户密码 * * @param userId 用户ID * @param data 密码修改表单数据 * @return true|false */ @Override - public boolean changePassword(Long userId, PasswordUpdateForm data) { + public boolean changeUserPassword(Long userId, PasswordUpdateForm data) { User user = this.getById(userId); if (user == null) { @@ -522,26 +522,30 @@ public class UserServiceImpl extends ServiceImpl implements Us ); if (result) { - // 加入黑名单,重新登录 - String accessToken = SecurityUtils.getTokenFromRequest(); - tokenManager.invalidateToken(accessToken); + // 密码变更后,使当前用户的所有会话失效,强制重新登录 + tokenManager.invalidateUserSessions(userId); } return result; } /** - * 重置密码 + * 重置指定用户密码 * * @param userId 用户ID * @param password 密码重置表单数据 * @return true|false */ @Override - public boolean resetPassword(Long userId, String password) { - return this.update(new LambdaUpdateWrapper() + public boolean resetUserPassword(Long userId, String password) { + boolean result = this.update(new LambdaUpdateWrapper() .eq(User::getId, userId) .set(User::getPassword, passwordEncoder.encode(password)) ); + if (result) { + // 管理员重置用户密码后,使该用户的所有会话失效 + tokenManager.invalidateUserSessions(userId); + } + return result; } /**