在初始认证基础上,新增完整的 RBAC 权限模型(角色、权限、菜单三级管理), 集成审计日志、接口限流、登录失败锁定、Refresh Token 机制、Redis 分布式缓存与锁、 RocketMQ 消息队列,并引入 Flyway 数据库版本管理,同时补充项目文档与使用示例
364 lines
13 KiB
Java
364 lines
13 KiB
Java
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);
|
||
}
|
||
}
|
||
}
|