feat: 使用双token逻辑
This commit is contained in:
@@ -2,15 +2,39 @@ package com.webgame.webgamebackend.common.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 配置类:序列化方式 + 消息监听容器
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfigurer {
|
||||
|
||||
/**
|
||||
* 配置 RedisTemplate,使用 String 序列化器替代 Java 原生序列化
|
||||
* <p>
|
||||
* 不配置此项的话,Spring Boot 默认使用 JdkSerializationRedisSerializer,
|
||||
* 存入 Redis 的数据会带有 Java 序列化魔数前缀(\xAC\xED\x00\x05),
|
||||
* 导致人类不可读且无法被其他语言客户端读取。
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(redisConnectionFactory);
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setValueSerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
template.setHashValueSerializer(stringSerializer);
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并配置 Redis 消息监听容器
|
||||
* 用于实现 Redis 的发布/订阅功能,支持监听特定频道的消息
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.webgame.webgamebackend.common.dto.account;
|
||||
|
||||
/**
|
||||
* 登录/注册响应
|
||||
* 登录/注册/刷新响应,包含 accessToken 和 refreshToken
|
||||
*/
|
||||
public record LoginResponse(String token) {
|
||||
public record LoginResponse(String accessToken, String refreshToken) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.webgame.webgamebackend.common.dto.account;
|
||||
|
||||
import com.webgame.webgamebackend.common.dto.base.BaseVo;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 刷新令牌请求
|
||||
*/
|
||||
public record RefreshTokenRequest(
|
||||
@NotNull(message = "刷新令牌不能为null")
|
||||
@NotBlank(message = "刷新令牌不能为空")
|
||||
String refreshToken
|
||||
) implements BaseVo {
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public enum ErrorCode {
|
||||
// ========== 账号模块 ==========
|
||||
USERNAME_EXISTS(1001, "用户名已存在"),
|
||||
BAD_CREDENTIALS(1002, "用户名或密码错误"),
|
||||
REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期"),
|
||||
REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌"),
|
||||
|
||||
// ========== 用户模块 ==========
|
||||
USER_NOT_FOUND(2001, "用户不存在"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
|
||||
import com.webgame.webgamebackend.common.dto.account.RefreshTokenRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
|
||||
import com.webgame.webgamebackend.service.AccountService;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -44,4 +45,14 @@ public class AccountController {
|
||||
LoginResponse result = accountService.register(request);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌,用 refresh token 换取新的 access token 和 refresh token
|
||||
*/
|
||||
@SaIgnore
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
LoginResponse result = accountService.refresh(request.refreshToken());
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,15 @@ public interface AccountService {
|
||||
* 注册
|
||||
*
|
||||
* @param request 注册请求
|
||||
* @return 注册结果(含 token,注册后自动登录)
|
||||
* @return 注册结果(含双 token,注册后自动登录)
|
||||
*/
|
||||
LoginResponse register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return 新的双 token(accessToken + refreshToken)
|
||||
*/
|
||||
LoginResponse refresh(String refreshToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.webgame.webgamebackend.service;
|
||||
|
||||
/**
|
||||
* 刷新令牌服务接口,管理 refresh token 的生命周期
|
||||
*/
|
||||
public interface RefreshTokenService {
|
||||
|
||||
/**
|
||||
* 为用户创建新的刷新令牌,存入 Redis,TTL 30 天
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 生成的 refresh token 字符串
|
||||
*/
|
||||
String create(String userId);
|
||||
|
||||
/**
|
||||
* 校验刷新令牌是否有效
|
||||
*
|
||||
* @param token 刷新令牌
|
||||
* @return 对应的用户ID
|
||||
* @throws com.webgame.webgamebackend.common.exception.BusinessException 令牌无效时抛出
|
||||
*/
|
||||
String validate(String token);
|
||||
|
||||
/**
|
||||
* 轮换刷新令牌:删除旧 token,生成新 token(防重放)
|
||||
*
|
||||
* @param oldToken 旧的刷新令牌
|
||||
* @return 新的刷新令牌
|
||||
* @throws com.webgame.webgamebackend.common.exception.BusinessException 旧令牌无效时抛出
|
||||
*/
|
||||
String rotate(String oldToken);
|
||||
|
||||
/**
|
||||
* 撤销刷新令牌(登出时调用)
|
||||
*
|
||||
* @param token 要撤销的刷新令牌
|
||||
*/
|
||||
void revoke(String token);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.webgame.webgamebackend.entities.UserEntity;
|
||||
import com.webgame.webgamebackend.repository.AccountRepository;
|
||||
import com.webgame.webgamebackend.repository.UserRepository;
|
||||
import com.webgame.webgamebackend.service.AccountService;
|
||||
import com.webgame.webgamebackend.service.RefreshTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@@ -30,6 +31,7 @@ public class AccountServiceImpl implements AccountService {
|
||||
private final AccountRepository accountRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
|
||||
@Override
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
@@ -38,11 +40,13 @@ public class AccountServiceImpl implements AccountService {
|
||||
if (!passwordEncoder.matches(request.password(), account.getPassword())) {
|
||||
throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
|
||||
}
|
||||
// Sa-Token 登录
|
||||
// Sa-Token 登录,生成 access token
|
||||
StpUtil.login(account.getId());
|
||||
String token = StpUtil.getTokenValue();
|
||||
String accessToken = StpUtil.getTokenValue();
|
||||
// 生成 refresh token
|
||||
String refreshToken = refreshTokenService.create(account.getId());
|
||||
log.info("用户登录成功: username={}", request.username());
|
||||
return new LoginResponse(token);
|
||||
return new LoginResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -67,10 +71,22 @@ public class AccountServiceImpl implements AccountService {
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("用户注册成功: username={}", request.username());
|
||||
// 注册后自动登录
|
||||
// 注册后自动登录,生成 access token 和 refresh token
|
||||
StpUtil.login(account.getId());
|
||||
String token = StpUtil.getTokenValue();
|
||||
return new LoginResponse(token);
|
||||
String accessToken = StpUtil.getTokenValue();
|
||||
String refreshToken = refreshTokenService.create(account.getId());
|
||||
return new LoginResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse refresh(String refreshToken) {
|
||||
// 校验 refresh token,有效则用它签发新的 access token
|
||||
String userId = refreshTokenService.validate(refreshToken);
|
||||
StpUtil.login(userId);
|
||||
String accessToken = StpUtil.getTokenValue();
|
||||
log.info("令牌刷新成功: userId={}", userId);
|
||||
// refresh token 保持不变,只返回新的 access token
|
||||
return new LoginResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.webgame.webgamebackend.service.impl;
|
||||
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.common.utils.RedisCacheUtil;
|
||||
import com.webgame.webgamebackend.service.RefreshTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* 刷新令牌服务实现,基于 Redis 存储
|
||||
*
|
||||
* <p>数据结构:</p>
|
||||
* <ul>
|
||||
* <li>xzg:refresh:{token} → userId(正向映射,用于校验)</li>
|
||||
* <li>xzg:refresh:user:{userId} → token(反向映射,用于登录时清除旧 token)</li>
|
||||
* </ul>
|
||||
* <p>TTL: 30 天,由 Redis 自动过期</p>
|
||||
* <p>一个用户同一时间只保留一个有效的 refresh token,新登录会使旧 token 失效</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||
|
||||
/** Redis key 前缀 */
|
||||
private static final String PREFIX = "refresh";
|
||||
|
||||
/** 用户→token 反向映射 key 前缀 */
|
||||
private static final String USER_PREFIX = "refresh:user";
|
||||
|
||||
/** 刷新令牌有效期:30 天(毫秒) */
|
||||
private static final long EXPIRE_MS = 2_592_000_000L;
|
||||
|
||||
/** 安全随机数生成器 */
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final RedisCacheUtil redisCacheUtil;
|
||||
|
||||
@Override
|
||||
public String create(String userId) {
|
||||
// 先清除该用户之前的 refresh token,确保一个用户只有一个有效 token
|
||||
String oldToken = redisCacheUtil.getCache(USER_PREFIX, userId, String.class, EXPIRE_MS);
|
||||
if (oldToken != null) {
|
||||
redisCacheUtil.removeCache(PREFIX, oldToken);
|
||||
log.debug("已清除旧的刷新令牌: userId={}", userId);
|
||||
}
|
||||
// 创建新 token
|
||||
String token = generateToken();
|
||||
boolean ok = redisCacheUtil.cacheValue(PREFIX, token, userId, EXPIRE_MS);
|
||||
if (!ok) {
|
||||
log.error("刷新令牌写入 Redis 失败: userId={}", userId);
|
||||
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌生成失败");
|
||||
}
|
||||
// 维护反向映射
|
||||
redisCacheUtil.cacheValue(USER_PREFIX, userId, token, EXPIRE_MS);
|
||||
log.debug("刷新令牌已创建: userId={}", userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String validate(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.REFRESH_TOKEN_MISSING);
|
||||
}
|
||||
String userId = redisCacheUtil.getCache(PREFIX, token, String.class, EXPIRE_MS);
|
||||
if (userId == null) {
|
||||
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String rotate(String oldToken) {
|
||||
String userId = validate(oldToken);
|
||||
redisCacheUtil.removeCache(PREFIX, oldToken);
|
||||
redisCacheUtil.removeCache(USER_PREFIX, userId);
|
||||
log.debug("旧刷新令牌已删除: userId={}", userId);
|
||||
return create(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revoke(String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
String userId = redisCacheUtil.getCache(PREFIX, token, String.class, EXPIRE_MS);
|
||||
if (userId != null) {
|
||||
redisCacheUtil.removeCache(USER_PREFIX, userId);
|
||||
}
|
||||
redisCacheUtil.removeCache(PREFIX, token);
|
||||
log.debug("刷新令牌已撤销");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 64 位十六进制随机字符串作为刷新令牌
|
||||
*/
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,8 @@ logging:
|
||||
sa-token:
|
||||
# token 名称(同时也是 cookie 名称)
|
||||
token-name: saToken
|
||||
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
|
||||
timeout: 2592000
|
||||
# token 有效期(单位:秒)access token 30分钟,refresh token 由应用层管理
|
||||
timeout: 1800
|
||||
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
|
||||
active-timeout: -1
|
||||
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
|
||||
|
||||
Reference in New Issue
Block a user