From 05ce76ad7a8f4810abfdef97546c8d3365cc4302 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Tue, 16 Jun 2026 16:02:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E5=8F=8Ctoken?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/RedisConfigurer.java | 32 +++++- .../common/dto/account/LoginResponse.java | 4 +- .../dto/account/RefreshTokenRequest.java | 15 +++ .../common/exception/ErrorCode.java | 2 + .../controller/AccountController.java | 11 ++ .../service/AccountService.java | 10 +- .../service/RefreshTokenService.java | 40 +++++++ .../service/impl/AccountServiceImpl.java | 28 ++++- .../service/impl/RefreshTokenServiceImpl.java | 106 ++++++++++++++++++ src/main/resources/application.yml | 4 +- 10 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java create mode 100644 src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java create mode 100644 src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java diff --git a/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java b/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java index ac80bac..8788bca 100644 --- a/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java +++ b/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java @@ -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 原生序列化 + *

+ * 不配置此项的话,Spring Boot 默认使用 JdkSerializationRedisSerializer, + * 存入 Redis 的数据会带有 Java 序列化魔数前缀(\xAC\xED\x00\x05), + * 导致人类不可读且无法被其他语言客户端读取。 + */ + @Bean + @Primary + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate 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 的发布/订阅功能,支持监听特定频道的消息 diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java index 1955491..6fb8699 100644 --- a/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java +++ b/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java @@ -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) { } diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java new file mode 100644 index 0000000..e6373b8 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java @@ -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 { +} diff --git a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java index fc41a76..43a895a 100644 --- a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java +++ b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java @@ -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, "用户不存在"), diff --git a/src/main/java/com/webgame/webgamebackend/controller/AccountController.java b/src/main/java/com/webgame/webgamebackend/controller/AccountController.java index 929e364..49e526e 100644 --- a/src/main/java/com/webgame/webgamebackend/controller/AccountController.java +++ b/src/main/java/com/webgame/webgamebackend/controller/AccountController.java @@ -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 refresh(@Valid @RequestBody RefreshTokenRequest request) { + LoginResponse result = accountService.refresh(request.refreshToken()); + return ApiResponse.ok(result); + } } diff --git a/src/main/java/com/webgame/webgamebackend/service/AccountService.java b/src/main/java/com/webgame/webgamebackend/service/AccountService.java index 12abdb7..a11ca7a 100644 --- a/src/main/java/com/webgame/webgamebackend/service/AccountService.java +++ b/src/main/java/com/webgame/webgamebackend/service/AccountService.java @@ -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); } diff --git a/src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java b/src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java new file mode 100644 index 0000000..26c693b --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java @@ -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); +} diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java index ff868c1..86533a3 100644 --- a/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java +++ b/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java @@ -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); } /** diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..7d1e641 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java @@ -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 存储 + * + *

数据结构:

+ * + *

TTL: 30 天,由 Redis 自动过期

+ *

一个用户同一时间只保留一个有效的 refresh token,新登录会使旧 token 失效

+ */ +@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); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6a3c785..a29dc92 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 时新登录挤掉旧登录)