From 940f1d7baca39ee00d2a48789c463ad18040a1e0 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Wed, 6 Aug 2025 01:31:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E5=AE=9E=E7=8E=B0=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=8C=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E7=99=BB=E5=BD=95=E3=80=82=E8=85=BE=E8=AE=AF?= =?UTF-8?q?=E4=BA=91=E7=9F=AD=E4=BF=A1=E5=90=8E=E5=8F=B0=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=88=90=E5=8A=9F=EF=BC=8C=E4=BD=86=E6=98=AF=E6=94=B6=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=EF=BC=8C=E4=B9=9F=E6=B2=A1=E6=9C=89=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 20 ++- .../controller/LoginController.java | 170 ++++++++++++------ .../controller/TencentSmsController.java | 2 +- .../onekeycall/videotablet/dto/TokenPair.java | 40 +++-- .../onekeycall/videotablet/entity/User.java | 21 +++ .../repository/UserRepository.java | 1 + .../videotablet/service/UserService.java | 48 +++-- .../onekeycall/videotablet/utils/JwtUtil.java | 101 ++++++++++- 8 files changed, 314 insertions(+), 89 deletions(-) diff --git a/pom.xml b/pom.xml index 2698129..84953e0 100644 --- a/pom.xml +++ b/pom.xml @@ -54,13 +54,31 @@ springdoc-openapi-starter-webmvc-ui 2.1.0 + + org.springframework.boot + spring-boot-starter-data-jpa + org.springframework.boot spring-boot-starter-security org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.security.oauth + spring-security-oauth2 + 2.5.2.RELEASE diff --git a/src/main/java/com/onekeycall/videotablet/controller/LoginController.java b/src/main/java/com/onekeycall/videotablet/controller/LoginController.java index fe3a9f1..c0b06ee 100644 --- a/src/main/java/com/onekeycall/videotablet/controller/LoginController.java +++ b/src/main/java/com/onekeycall/videotablet/controller/LoginController.java @@ -2,22 +2,25 @@ package com.onekeycall.videotablet.controller; import com.onekeycall.videotablet.dto.TokenPair; import com.onekeycall.videotablet.entity.User; +import com.onekeycall.videotablet.result.Result; import com.onekeycall.videotablet.service.UserService; import com.onekeycall.videotablet.utils.JwtUtil; -import com.onekeycall.videotablet.utils.TextUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Date; -import java.util.Map; -import java.util.Objects; +import java.util.*; @RestController public class LoginController { @@ -47,9 +50,21 @@ public class LoginController { } @PostMapping("/public/login") - public ResponseEntity login() { - // 登录逻辑由Spring Security自动处理 - return ResponseEntity.ok("Login successful"); + public ResponseEntity login( + @RequestParam(value = "user_id") String userId, @RequestParam String password, + @RequestParam(value = "device_id", required = false) String deviceId) { + // 1. 创建认证令牌 + Authentication authenticationToken = new UsernamePasswordAuthenticationToken(userId, password); + + // 2. 使用 AuthenticationManager 进行认证(核心步骤) + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 3. 认证成功后生成 JWT + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + TokenPair tokenPair = jwtUtil.generateTokenPair(userDetails.getUsername(), deviceId); + + // 4. 返回 Token + return ResponseEntity.ok(Collections.singletonMap("token", tokenPair.toMap())); } // 注册请求参数类 @@ -77,84 +92,123 @@ public class LoginController { } @PostMapping("/public/registerByPhone") - public ResponseEntity registerByPhone( + public Result registerByPhone( @RequestParam String phone, @RequestParam String code, @RequestParam(value = "verify_key") String verifyKey, @RequestParam(value = "device_id") String deviceId) { - - if (TextUtils.isEmpty(verifyKey)) { - return new ResponseEntity<>("verify key is empty", HttpStatus.BAD_REQUEST); - } +// +// if (TextUtils.isEmpty(verifyKey)) { +// return Result.error().message("verify key is empty", HttpStatus.BAD_REQUEST); +// } Map map = (Map) redisTemplate.opsForValue().get(phone); if (map != null) { String redisVerifyKey = (String) map.get("verifyKey"); if (!Objects.equals(redisVerifyKey, verifyKey)) { - return new ResponseEntity<>("verify key is not same", HttpStatus.BAD_REQUEST); + return Result.error().message("verify key is not same"); } String redisCode = map.get("code").toString(); if (!Objects.equals(redisCode, code)) { - return new ResponseEntity<>("code is not same", HttpStatus.BAD_REQUEST); + return Result.error().message("code is not same"); } try { User user = userService.registerByPhone(phone, code, deviceId, new Date()); TokenPair tokenPair = jwtUtil.generateTokenPair(user.getUserId(), deviceId); //返回给app保存,access_token用来加入header请求接口,refresh_token用来更换access_token - return new ResponseEntity<>(tokenPair, HttpStatus.CREATED); + Map tokenMap = new HashMap<>(); + tokenMap.put("new_user", user.isNewUser()); + tokenMap.put("user_id", user.getUserId()); + tokenMap.put("has_password", user.isHasPassword()); + tokenMap.put("token", tokenPair.toMap()); + return Result.ok().data(tokenMap); } catch (RuntimeException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + return Result.error().message(e.getMessage()); } finally { redisTemplate.delete(phone); } } else { - return new ResponseEntity<>("verify key is expired", HttpStatus.BAD_REQUEST); + return Result.error().message("verify key is expired"); } } @PostMapping("/public/loginByPhone") - public ResponseEntity loginByPhone(@RequestBody PhoneRequest request) { - String requestVerifyKey = request.getVerifyKey(); - if (TextUtils.isEmpty(requestVerifyKey)) { - return new ResponseEntity<>("verify key is empty", HttpStatus.BAD_REQUEST); - } - try { - User user = userService.loginByPhone(request.getPhone(), request.getCode()); - // 生成并返回JWT令牌(实际项目中需要实现JWT逻辑) - - return ResponseEntity.ok("Login successful: " + user.getUsername()); - } catch (RuntimeException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + public Result loginByPhone( + @RequestParam String phone, @RequestParam String code, + @RequestParam(value = "verify_key") String verifyKey, @RequestParam(value = "device_id") String deviceId) { + Map map = (Map) redisTemplate.opsForValue().get(phone); + if (map != null) { + String redisVerifyKey = (String) map.get("verifyKey"); + if (!Objects.equals(redisVerifyKey, verifyKey)) { + return Result.error().message("verify key is not same"); + } + String redisCode = map.get("code").toString(); + if (!Objects.equals(redisCode, code)) { + return Result.error().message("code is not same"); + } + try { + User user = userService.loginByPhone(phone, code); + // 生成并返回JWT令牌(实际项目中需要实现JWT逻辑) + TokenPair tokenPair = jwtUtil.generateTokenPair(user.getUserId(), deviceId); + Map tokenMap = new HashMap<>(); + tokenMap.put("new_user", user.isNewUser()); + tokenMap.put("user_id", user.getUserId()); + tokenMap.put("has_password", user.isHasPassword()); + tokenMap.put("token", tokenPair.toMap()); + return Result.ok().data(tokenMap); + } catch (RuntimeException e) { + return Result.error().message(e.getMessage()); + } + } else { + return Result.error().message("verify key is expired"); } } - - public static class PhoneRequest { - private String phone; - private String code; - private String verifyKey; - - // Getters and Setters - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getVerifyKey() { - return verifyKey; - } - - public void setVerifyKey(String verifyKey) { - this.verifyKey = verifyKey; + @PostMapping("/public/setPasswordByPhone") + public Result setPasswordByPhone( + HttpServletRequest request, @RequestParam(value = "user_id") String userId, + @RequestParam String password, @RequestParam(value = "verify_password") String verifyPassword, + @RequestParam(value = "device_id") String deviceId) { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); // 提取真正的Token + if (StringUtils.equals(password, verifyPassword)) { + if (jwtUtil.validateAccessToken(userId, token, deviceId)) { + userService.setPasswordByUserId(userId, password); + return Result.ok().message("set password success"); + } else { + return Result.error().message("token is not same"); + } + } else { + return Result.error().message("password is not same"); + } + } else { + return Result.error().message("Authorization header is incorrect"); } } + @PostMapping("/public/changePassword") + public Result changePassword( + HttpServletRequest request, + @RequestParam(value = "user_id") String userId, + @RequestParam(value = "old_password") String oldPassword, + @RequestParam String password, @RequestParam(value = "verify_password") String verifyPassword, + @RequestParam(value = "device_id") String deviceId) { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + if (StringUtils.equals(password, verifyPassword)) { + if (!oldPassword.equals(password)) { + if (jwtUtil.validateAccessToken(userId, token, deviceId)) { + return userService.changePassword(userId, oldPassword, password); + } else { + return Result.error().message("token is not same"); + } + } else { + return Result.error().message("The old password and the new password are the same"); + } + } else { + return Result.error().message("password is not same"); + } + } else { + return Result.error().message("Authorization header is incorrect"); + } + } } \ No newline at end of file diff --git a/src/main/java/com/onekeycall/videotablet/controller/TencentSmsController.java b/src/main/java/com/onekeycall/videotablet/controller/TencentSmsController.java index 81f158e..586f6e0 100644 --- a/src/main/java/com/onekeycall/videotablet/controller/TencentSmsController.java +++ b/src/main/java/com/onekeycall/videotablet/controller/TencentSmsController.java @@ -167,7 +167,7 @@ public class TencentSmsController { map.put("sentAt", now); map.put("expireAt", expireAt); Map codeMap = new HashMap<>(map); - codeMap.put("code", randomString); + codeMap.put("code", code); if (!sent) { //4.保存验证码到Redis,并且设置有效期5分钟 redisTemplate.opsForValue().set(phone, codeMap, Duration.ofMinutes(5)); diff --git a/src/main/java/com/onekeycall/videotablet/dto/TokenPair.java b/src/main/java/com/onekeycall/videotablet/dto/TokenPair.java index d85a0bf..0bd126c 100644 --- a/src/main/java/com/onekeycall/videotablet/dto/TokenPair.java +++ b/src/main/java/com/onekeycall/videotablet/dto/TokenPair.java @@ -2,6 +2,9 @@ package com.onekeycall.videotablet.dto; import lombok.Data; +import java.util.HashMap; +import java.util.Map; + /** * 双Token认证令牌对(AccessToken + RefreshToken) * 设计要点: @@ -20,31 +23,33 @@ public class TokenPair { // RefreshToken过期时间戳(毫秒) private long refresh_expires; // 关联设备指纹(防御中间人攻击)[1](@ref) - private String deviceId; + private String device_id; /** * 全参数构造器(安全增强版) - * @param access_token JWT格式访问令牌 - * @param refresh_token JWT格式刷新令牌 - * @param accessExpireMs AccessToken有效期(毫秒) + * + * @param access_token JWT格式访问令牌 + * @param refresh_token JWT格式刷新令牌 + * @param accessExpireMs AccessToken有效期(毫秒) * @param refreshExpireMs RefreshToken有效期(毫秒) - * @param deviceId 客户端设备指纹 + * @param device_id 客户端设备指纹 */ public TokenPair(String access_token, String refresh_token, long accessExpireMs, long refreshExpireMs, - String deviceId) { + String device_id) { this.access_token = access_token; this.refresh_token = refresh_token; this.access_expires = System.currentTimeMillis() + accessExpireMs; this.refresh_expires = System.currentTimeMillis() + refreshExpireMs; - this.deviceId = deviceId; + this.device_id = device_id; } /** * 快速创建方法(推荐) - * @param accessToken 访问令牌 + * + * @param accessToken 访问令牌 * @param refreshToken 刷新令牌 - * @param deviceId 设备指纹 + * @param deviceId 设备指纹 * @return 初始化过期时间的TokenPair */ public static TokenPair create(String accessToken, String refreshToken, String deviceId) { @@ -59,6 +64,7 @@ public class TokenPair { /** * 检查AccessToken是否过期 + * * @return true=已过期,false=有效 */ public boolean isAccessExpired() { @@ -67,6 +73,7 @@ public class TokenPair { /** * 检查RefreshToken是否过期 + * * @return true=已过期,false=有效 */ public boolean isRefreshExpired() { @@ -75,7 +82,8 @@ public class TokenPair { /** * 安全刷新令牌(生成新TokenPair) - * @param newAccessToken 新访问令牌 + * + * @param newAccessToken 新访问令牌 * @param newRefreshToken 新刷新令牌 * @return 更新后的TokenPair(保留原设备ID) */ @@ -85,7 +93,17 @@ public class TokenPair { newRefreshToken, this.access_expires - System.currentTimeMillis(), // 剩余时间延续 this.refresh_expires - System.currentTimeMillis(), - this.deviceId // 保持设备一致性 + this.device_id // 保持设备一致性 ); } + + public Map toMap() { + Map map = new HashMap<>(); + map.put("access_token", access_token); + map.put("refresh_token", refresh_token); + map.put("access_expires", String.valueOf(access_expires)); + map.put("refresh_expires", String.valueOf(refresh_expires)); + map.put("device_id", device_id); + return map; + } } diff --git a/src/main/java/com/onekeycall/videotablet/entity/User.java b/src/main/java/com/onekeycall/videotablet/entity/User.java index 357e312..a407caf 100644 --- a/src/main/java/com/onekeycall/videotablet/entity/User.java +++ b/src/main/java/com/onekeycall/videotablet/entity/User.java @@ -12,6 +12,11 @@ import java.util.Date; @Table(name = "users") public class User implements UserDetails { + @Transient // 关键注解:声明此字段不映射到数据库 + private boolean newUser; + @Transient // 关键注解:声明此字段不映射到数据库 + private boolean hasPassword; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") @@ -66,6 +71,22 @@ public class User implements UserDetails { return true; } + public boolean isNewUser() { + return newUser; + } + + public void setNewUser(boolean newUser) { + this.newUser = newUser; + } + + public boolean isHasPassword() { + return hasPassword; + } + + public void setHasPassword(boolean hasPassword) { + this.hasPassword = hasPassword; + } + // Getters and Setters for id, username, password, email public Long getId() { return id; diff --git a/src/main/java/com/onekeycall/videotablet/repository/UserRepository.java b/src/main/java/com/onekeycall/videotablet/repository/UserRepository.java index 618a493..8e7ee4d 100644 --- a/src/main/java/com/onekeycall/videotablet/repository/UserRepository.java +++ b/src/main/java/com/onekeycall/videotablet/repository/UserRepository.java @@ -9,4 +9,5 @@ public interface UserRepository extends JpaRepository { boolean existsByPhone(String phone); Optional findByUserId(String userId); boolean existsByUserId(String userId); + boolean existsPasswordByUserId(String userId); } \ No newline at end of file diff --git a/src/main/java/com/onekeycall/videotablet/service/UserService.java b/src/main/java/com/onekeycall/videotablet/service/UserService.java index 65e9248..3cf87ca 100644 --- a/src/main/java/com/onekeycall/videotablet/service/UserService.java +++ b/src/main/java/com/onekeycall/videotablet/service/UserService.java @@ -2,6 +2,7 @@ package com.onekeycall.videotablet.service; import com.onekeycall.videotablet.entity.User; import com.onekeycall.videotablet.repository.UserRepository; +import com.onekeycall.videotablet.result.Result; import com.onekeycall.videotablet.utils.SecureIdGenerator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; @@ -49,19 +50,23 @@ public class UserService implements UserDetailsService { // 2. 检查手机号是否已注册 if (userRepository.existsByPhone(phone)) { - throw new RuntimeException("Phone number already registered"); - } + User user = userRepository.findByPhone(phone).get(); + user.setNewUser(false); + return user; + } else { + // 3. 创建新用户 + User user = new User(); + user.setNewUser(true); + user.setPhone(phone); + user.setCreatTime(createTime); + user.setLastLoginTime(createTime); + user.setUpdateTime(createTime); + user.setUserId(SecureIdGenerator.generateSecureId(12)); + user.setUsername(SecureIdGenerator.generateSecureUserName(8)); + user.setDeviceId(deviceId); - // 3. 创建新用户 - User user = new User(); - user.setPhone(phone); - user.setCreatTime(createTime); - user.setLastLoginTime(createTime); - user.setUpdateTime(createTime); - user.setUserId(SecureIdGenerator.generateSecureId(12)); - user.setUsername(SecureIdGenerator.generateSecureUserName(8)); - user.setDeviceId(deviceId); - return userRepository.save(user); + return userRepository.save(user); + } } public User loginByPhone(String phone, String code) { @@ -81,4 +86,23 @@ public class UserService implements UserDetailsService { return userRepository.findByUserId(userId) .orElseThrow(() -> new UsernameNotFoundException("User not found with userId: " + userId)); } + + public void setPasswordByUserId(String userId, String password) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new UsernameNotFoundException("User not found with userId: " + userId)); + user.setPassword(passwordEncoder.encode(password)); + userRepository.save(user); + } + + public Result changePassword(String userId, String oldPassword, String newPassword) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new UsernameNotFoundException("User not found with userId: " + userId)); + if (!passwordEncoder.matches(oldPassword, user.getPassword())) { + return Result.error().message("Old password is incorrect"); + } else { + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + return Result.ok().message("change password success"); + } + } } \ No newline at end of file diff --git a/src/main/java/com/onekeycall/videotablet/utils/JwtUtil.java b/src/main/java/com/onekeycall/videotablet/utils/JwtUtil.java index f4aae9b..dba9a4a 100644 --- a/src/main/java/com/onekeycall/videotablet/utils/JwtUtil.java +++ b/src/main/java/com/onekeycall/videotablet/utils/JwtUtil.java @@ -1,12 +1,15 @@ package com.onekeycall.videotablet.utils; import com.onekeycall.videotablet.dto.TokenPair; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; +import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; import java.util.Date; import java.util.UUID; @@ -25,10 +28,15 @@ public class JwtUtil { @Value("${jwt.refresh-expire}") private Long refreshExpire; + /** + * @param userId 统一使用user_id + * @param deviceId + * @return + */ // 生成双Token(关联设备指纹) - public TokenPair generateTokenPair(String username, String deviceId) { + public TokenPair generateTokenPair(String userId, String deviceId) { String accessToken = Jwts.builder() - .subject(username) + .subject(userId) .claim("type", "ACCESS") .claim("deviceId", deviceId) // 绑定设备 .issuedAt(new Date()) @@ -38,16 +46,16 @@ public class JwtUtil { String refreshId = UUID.randomUUID().toString(); String refreshToken = Jwts.builder() - .subject(username) + .subject(userId) .claim("type", "REFRESH") .claim("refreshId", refreshId) .expiration(new Date(System.currentTimeMillis() + refreshExpire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes())) .compact(); - // Redis存储Refresh Token(key: user:refresh:, value: refreshId) + // Redis存储Refresh Token(key: user:refresh:, value: refreshId) redisTemplate.opsForValue().set( - "user:refresh:" + username, + "user:refresh:" + userId, refreshId, refreshExpire, TimeUnit.MILLISECONDS @@ -56,5 +64,86 @@ public class JwtUtil { return new TokenPair(accessToken, refreshToken, accessExpire, refreshExpire, deviceId); } - // Token解析与校验(略) + // 解析Token并返回Claims + public Claims parseToken(String token) { + try { + return Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(secret.getBytes())) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException("Token解析失败: " + e.getMessage()); + } + } + + // 校验Access Token有效性(签名+过期时间+设备绑定) + public boolean validateAccessToken(String userId, String accessToken, String deviceId) { + Claims claims = parseToken(accessToken); + + // 0. 验证用户名一致性(新增) + String tokenUserId = claims.getSubject(); + if (!userId.equals(tokenUserId)) { + throw new SecurityException("用户身份不匹配"); + } + + // 1. 验证Token类型 + if (!"ACCESS".equals(claims.get("type", String.class))) { + throw new InvalidTokenException("非Access Token类型"); + } + + // 2. 验证设备ID一致性 + String tokenDeviceId = claims.get("deviceId", String.class); + if (!deviceId.equals(tokenDeviceId)) { + throw new SecurityException("设备ID不匹配"); + } + + // 3. 验证过期时间(JWT解析时自动校验) + return true; // 通过所有校验 + } + + // 校验Refresh Token有效性(签名+Redis一致性) + public boolean validateRefreshToken(String refreshToken, String username) { + Claims claims = parseToken(refreshToken); + + // 1. 验证Token类型 + if (!"REFRESH".equals(claims.get("type", String.class))) { + throw new InvalidTokenException("非Refresh Token类型"); + } + + // 2. 从Redis获取存储的refreshId + String redisKey = "user:refresh:" + username; + String storedRefreshId = redisTemplate.opsForValue().get(redisKey); + if (storedRefreshId == null) { + throw new InvalidTokenException("Refresh Token已吊销"); + } + + // 3. 比对refreshId一致性 + String tokenRefreshId = claims.get("refreshId", String.class); + if (!storedRefreshId.equals(tokenRefreshId)) { + throw new SecurityException("Refresh Token无效"); + } + + return true; // 通过所有校验 + } + + // 使用Refresh Token刷新Access Token + public TokenPair refreshAccessToken(String refreshToken, String deviceId) { + Claims claims = parseToken(refreshToken); + String userId = claims.getSubject(); + + // 1. 校验Refresh Token有效性 + validateRefreshToken(refreshToken, userId); + + // 2. 生成新Token(复用原设备ID) + return generateTokenPair(userId, deviceId); + } + + // 登出时吊销Refresh Token + public void revokeRefreshToken(String username) { + String redisKey = "user:refresh:" + username; + redisTemplate.delete(redisKey); + } + + } \ No newline at end of file