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 Token(Token 轮换机制) * 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 Token(Token 轮换) // 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 ID(JWT 唯一标识) * @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); } } }