Files
springboot-template/src/main/java/com/aisi/template/service/impl/TokenServiceImpl.java
shenjianZ 40c85c3c1f feat: 实现完整的 RBAC 权限管理系统与基础设施增强
在初始认证基础上,新增完整的 RBAC 权限模型(角色、权限、菜单三级管理),
  集成审计日志、接口限流、登录失败锁定、Refresh Token 机制、Redis 分布式缓存与锁、
  RocketMQ 消息队列,并引入 Flyway 数据库版本管理,同时补充项目文档与使用示例
2026-04-10 10:58:22 +08:00

364 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.aisi.template.service.impl;
import com.aisi.template.domain.entity.RefreshToken;
import com.aisi.template.domain.entity.User;
import com.aisi.template.domain.vo.LoginResponseVo;
import com.aisi.template.exception.BusinessException;
import com.aisi.template.repository.RefreshTokenRepository;
import com.aisi.template.repository.UserRepository;
import com.aisi.template.service.TokenService;
import com.aisi.template.utils.JwtUtil;
import com.aisi.template.utils.RedisUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Token 服务实现类
* 负责 Refresh Token 的生成、验证、刷新和黑名单管理
*
* 主要功能:
* 1. Refresh Token 管理:生成、存储、验证、撤销
* 2. Token 黑名单:将失效的 JWT 加入 Redis 黑名单
* 3. Token 刷新:使用 Refresh Token 获取新的 Access Token
* 4. 清理任务:清理过期的 Token 和黑名单记录
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenServiceImpl implements TokenService {
/**
* Refresh Token 数据访问接口
*/
private final RefreshTokenRepository refreshTokenRepository;
/**
* 用户数据访问接口
*/
private final UserRepository userRepository;
/**
* JWT 工具类
*/
private final JwtUtil jwtUtil;
/**
* Redis 工具类
*/
private final RedisUtils redisUtils;
/**
* Refresh Token 过期时间(秒),从配置读取,默认 7 天
*/
@Value("${jwt.refresh-token-expiration:604800}")
private long refreshTokenExpirationSeconds;
/**
* Access Token 过期时间(秒),从配置读取,默认 1 小时
*/
@Value("${jwt.access-token-expiration:3600}")
private long accessTokenExpirationSeconds;
/**
* Token 黑名单 Redis 键前缀
*/
private static final String BLACKLIST_KEY_PREFIX = "token:blacklist:";
/**
* Refresh Token Redis 键前缀
*/
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
/**
* 创建 Refresh Token
* 步骤:
* 1. 生成随机 UUID 作为 Token
* 2. 对 Token 进行 SHA-256 哈希(数据库只存储哈希值)
* 3. 计算 Token 过期时间
* 4. 保存到数据库
* 5. 同时在 Redis 中缓存一份(用于快速查找)
*
* @param userId 用户ID
* @param deviceInfo 设备信息iPhone 14 Pro
* @param ipAddress IP地址
* @return Refresh Token 原始值(未哈希)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String createRefreshToken(Long userId, String deviceInfo, String ipAddress) {
// 1. 生成随机的 Refresh Token
String refreshToken = UUID.randomUUID().toString().replace("-", "");
String tokenHash = hashToken(refreshToken);
// 2. 计算过期时间
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(refreshTokenExpirationSeconds);
// 3. 创建 Refresh Token 实体
RefreshToken tokenEntity = new RefreshToken();
tokenEntity.setUserId(userId);
tokenEntity.setTokenHash(tokenHash);
tokenEntity.setDeviceInfo(deviceInfo);
tokenEntity.setIpAddress(ipAddress);
tokenEntity.setExpiresAt(expiresAt);
tokenEntity.setRevoked(false);
// 4. 保存到数据库
refreshTokenRepository.save(tokenEntity);
// 5. 在 Redis 中缓存一份,过期时间与 Refresh Token 保持一致
// 作用:快速验证 Refresh Token减少数据库查询
redisUtils.set(REFRESH_TOKEN_PREFIX + tokenHash, String.valueOf(userId),
refreshTokenExpirationSeconds, TimeUnit.SECONDS);
log.info("Refresh Token 创建成功 - userId: {}, deviceInfo: {}", userId, deviceInfo);
return refreshToken;
}
/**
* 使用 Refresh Token 刷新 Access Token
* 步骤:
* 1. 对 Refresh Token 进行哈希
* 2. 先从 Redis 查找,找不到再查数据库
* 3. 验证 Token 是否有效(未过期、未撤销)
* 4. 获取用户信息并验证用户状态
* 5. 生成新的 Access Token
* 6. 生成新的 Refresh TokenToken 轮换机制)
* 7. 撤销旧的 Refresh Token
*
* @param refreshToken Refresh Token 原始值
* @return 登录响应(包含新的 Access Token 和 Refresh Token
* @throws BusinessException 当 Token 无效或用户状态异常时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public LoginResponseVo refreshToken(String refreshToken) {
// 1. 计算 Token 哈希值
String tokenHash = hashToken(refreshToken);
// 2. 先从 Redis 查找(快速路径)
String cachedUserId = redisUtils.get(REFRESH_TOKEN_PREFIX + tokenHash, String.class);
if (cachedUserId == null) {
// 2.1 Redis 没找到,查数据库(慢速路径)
RefreshToken tokenEntity = refreshTokenRepository.findByTokenHash(tokenHash)
.orElse(null);
if (tokenEntity == null) {
throw new BusinessException("Refresh Token 无效");
}
if (!tokenEntity.isValid()) {
throw new BusinessException("Refresh Token 已过期或已撤销");
}
}
// 3. 获取 Token 实体(从数据库)
RefreshToken tokenEntity = refreshTokenRepository.findByTokenHash(tokenHash)
.orElseThrow(() -> new BusinessException("Refresh Token 无效"));
// 4. 验证 Token 是否有效
if (!tokenEntity.isValid()) {
throw new BusinessException("Refresh Token 已过期或已撤销");
}
// 5. 获取用户信息
User user = userRepository.findById(tokenEntity.getUserId())
.orElseThrow(() -> new BusinessException("用户不存在"));
// 6. 检查用户状态
if (!user.isEnabled()) {
throw new BusinessException("用户账户已被禁用");
}
// 7. 生成新的 Access Token
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
// 8. 生成新的 Refresh TokenToken 轮换)
// Token 轮换:每次刷新都生成新的 Refresh Token旧 Token 失效
// 好处:提高安全性,一旦 Token 泄露,使用后即失效
revokeRefreshToken(tokenHash);
String newRefreshToken = createRefreshToken(
user.getId(),
tokenEntity.getDeviceInfo(),
tokenEntity.getIpAddress()
);
// 9. 构建响应
LoginResponseVo response = LoginResponseVo.builder()
.accessToken(accessToken)
.refreshToken(newRefreshToken)
.expiresIn(accessTokenExpirationSeconds)
.userInfo(LoginResponseVo.UserInfoVo.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.build())
.build();
log.info("Token 刷新成功 - userId: {}", user.getId());
return response;
}
/**
* 撤销 Refresh Token
* 步骤:
* 1. 查找 Token 实体
* 2. 标记为已撤销
* 3. 记录撤销时间
* 4. 删除 Redis 缓存
*
* @param tokenHash Token 哈希值
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeRefreshToken(String tokenHash) {
// 1. 查找 Token 实体
RefreshToken token = refreshTokenRepository.findByTokenHash(tokenHash)
.orElse(null);
if (token != null) {
// 2. 标记为已撤销
token.setRevoked(true);
token.setRevokedAt(LocalDateTime.now());
refreshTokenRepository.save(token);
// 3. 删除 Redis 缓存
redisUtils.delete(REFRESH_TOKEN_PREFIX + tokenHash);
log.info("Refresh Token 已撤销 - tokenHash: {}", tokenHash);
}
}
/**
* 撤销用户的所有 Refresh Token
* 步骤:
* 1. 将用户所有有效 Token 标记为已撤销
* 2. 删除对应的 Redis 缓存
*
* 使用场景:
* - 用户修改密码后,撤销所有 Token
* - 用户登出所有设备
* - 用户账户被禁用
*
* @param userId 用户ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeAllUserTokens(Long userId) {
// 1. 查找用户所有有效 Token用于删除 Redis 缓存
var tokens = refreshTokenRepository.findValidTokensByUserId(userId, LocalDateTime.now());
// 2. 撤销数据库中所有 Token
refreshTokenRepository.revokeAllUserTokens(userId, LocalDateTime.now());
// 3. 删除 Redis 缓存
tokens.forEach(token -> redisUtils.delete(REFRESH_TOKEN_PREFIX + token.getTokenHash()));
log.info("用户所有 Refresh Token 已撤销 - userId: {}", userId);
}
/**
* 检查 Token 是否在黑名单中
* 步骤:
* 1. 构建 Redis 黑名单键
* 2. 检查键是否存在
*
* @param jti JWT IDJWT 唯一标识)
* @return 是否在黑名单中
*/
@Override
public boolean isTokenBlacklisted(String jti) {
String key = BLACKLIST_KEY_PREFIX + jti;
return Boolean.TRUE.equals(redisUtils.hasKey(key));
}
/**
* 将 Token 加入黑名单
* 步骤:
* 1. 构建 Redis 黑名单键
* 2. 设置黑名单记录,过期时间与 Token 剩余有效期一致
*
* 使用场景:
* - 用户登出时,将当前 Token 加入黑名单
* - Token 刷新时,将旧 Token 加入黑名单
*
* @param jti JWT ID
* @param expirationSeconds 过期时间(秒)
*/
@Override
public void addTokenToBlacklist(String jti, long expirationSeconds) {
String key = BLACKLIST_KEY_PREFIX + jti;
// 设置黑名单记录,过期时间与 Token 剩余有效期一致
// 作用:自动清理过期的黑名单记录,避免 Redis 内存溢出
redisUtils.set(key, "1", expirationSeconds, TimeUnit.SECONDS);
log.info("Token 已加入黑名单 - jti: {}, expire: {}s", jti, expirationSeconds);
}
/**
* 清理过期的 Refresh Token
* 步骤:
* 1. 查找所有过期的 Token
* 2. 批量删除
*
* 建议通过定时任务定期执行(如每天凌晨)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void cleanupExpiredTokens() {
LocalDateTime now = LocalDateTime.now();
refreshTokenRepository.deleteByExpiresAtBefore(now);
log.info("过期 Refresh Token 清理完成");
}
/**
* 清理过期的黑名单记录
* 说明:
* Redis 的黑名单记录设置了过期时间,会自动删除
* 此方法仅作为日志记录,实际无需执行
*/
@Override
public void cleanupExpiredBlacklistEntries() {
log.debug("黑名单记录由 Redis 自动过期清理");
}
/**
* 对 Token 进行 SHA-256 哈希
* 步骤:
* 1. 获取 SHA-256 算法实例
* 2. 对 Token 字节进行哈希
* 3. 转换为十六进制字符串
*
* 安全说明:
* - 数据库只存储哈希值,不存储原始 Token
* - 即使数据库泄露,攻击者也无法获得有效的 Refresh Token
*
* @param token 原始 Token
* @return 哈希后的 Token十六进制字符串
*/
private String hashToken(String token) {
try {
// 1. 获取 SHA-256 算法实例
MessageDigest digest = MessageDigest.getInstance("SHA-256");
// 2. 对 Token 进行哈希
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
// 3. 转换为十六进制字符串
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
// 4. SHA-256 是 Java 标准库必须支持的算法,理论上不会抛出此异常
throw new RuntimeException("SHA-256 算法不可用", e);
}
}
}