feat: 实现完整的 RBAC 权限管理系统与基础设施增强
在初始认证基础上,新增完整的 RBAC 权限模型(角色、权限、菜单三级管理), 集成审计日志、接口限流、登录失败锁定、Refresh Token 机制、Redis 分布式缓存与锁、 RocketMQ 消息队列,并引入 Flyway 数据库版本管理,同时补充项目文档与使用示例
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user