feat: 使用双token逻辑

This commit is contained in:
2026-06-16 16:02:09 +08:00
parent fe1ffd73b2
commit 05ce76ad7a
10 changed files with 237 additions and 15 deletions

View File

@@ -21,7 +21,15 @@ public interface AccountService {
* 注册
*
* @param request 注册请求
* @return 注册结果(含 token注册后自动登录
* @return 注册结果(含 token注册后自动登录
*/
LoginResponse register(RegisterRequest request);
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 新的双 tokenaccessToken + refreshToken
*/
LoginResponse refresh(String refreshToken);
}

View File

@@ -0,0 +1,40 @@
package com.webgame.webgamebackend.service;
/**
* 刷新令牌服务接口,管理 refresh token 的生命周期
*/
public interface RefreshTokenService {
/**
* 为用户创建新的刷新令牌,存入 RedisTTL 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);
}

View File

@@ -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);
}
/**

View File

@@ -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);
}
}