Browse Source

fix: 修复 token 刷新机制,登录返回独立 refreshToken 并正确计算过期时间

- LoginVO 新增 refreshToken 和 tokenTimeout 字段
- 登录时生成独立 UUID refreshToken 存入 Redis(7天TTL)
- 新增 /login/refresh-token 接口实现无感刷新
- 前端使用后端返回的 refreshToken 和 tokenTimeout 替代硬编码 7 天

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 1 week ago
parent
commit
f2cb7ae587

+ 1 - 1
haha-admin-web/src/api/user.ts

@@ -44,7 +44,7 @@ export const getLogin = (data: { username: string; password: string }) => {
 };
 
 export const refreshTokenApi = (data: { refreshToken: string }) => {
-  return http.request<RefreshTokenResult>("post", "/refresh-token", { data });
+  return http.request<RefreshTokenResult>("post", "/login/refresh-token", { data });
 };
 
 export const getUserList = (params: {

+ 2 - 2
haha-admin-web/src/store/modules/user.ts

@@ -84,8 +84,8 @@ export const useUserStore = defineStore("pure-user", {
             if (res.code === 200 && res.data) {
               const tokenData = {
                 accessToken: res.data.token,
-                refreshToken: res.data.token,
-                expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+                refreshToken: res.data.refreshToken || res.data.token,
+                expires: new Date(Date.now() + (res.data.tokenTimeout || 28800) * 1000),
                 username: data.username,
                 nickname: res.data.userInfo?.nickname || "",
                 roles: res.data.userInfo?.roles || [],

+ 15 - 0
haha-admin/src/main/java/com/haha/admin/controller/AdminLoginController.java

@@ -10,6 +10,8 @@ import com.haha.common.vo.AdminInfoVO;
 import com.haha.common.vo.LoginVO;
 import com.haha.common.vo.Result;
 import lombok.RequiredArgsConstructor;
+import java.util.Map;
+
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
@@ -37,6 +39,19 @@ public class AdminLoginController {
         return adminLoginService.login(loginDTO.getUsername(), loginDTO.getPassword());
     }
     
+    /**
+     * 刷新令牌
+     * @param body 包含 refreshToken 的请求体
+     * @return 新的登录凭证
+     */
+    @SaIgnore
+    @PostMapping("/refresh-token")
+    public Result<LoginVO> refreshToken(@RequestBody Map<String, String> body) {
+        String refreshToken = body.get("refreshToken");
+        log.info("收到刷新令牌请求");
+        return adminLoginService.refreshToken(refreshToken);
+    }
+
     /**
      * 退出登录
      * @return 退出结果

+ 7 - 0
haha-admin/src/main/java/com/haha/admin/service/AdminLoginService.java

@@ -17,6 +17,13 @@ public interface AdminLoginService {
      */
     Result<LoginVO> login(String username, String password);
     
+    /**
+     * 刷新令牌
+     * @param refreshToken 刷新令牌
+     * @return 新的登录凭证
+     */
+    Result<LoginVO> refreshToken(String refreshToken);
+
     /**
      * 获取当前登录管理员信息
      * @param adminId 管理员ID

+ 92 - 40
haha-admin/src/main/java/com/haha/admin/service/impl/AdminLoginServiceImpl.java

@@ -16,13 +16,16 @@ import com.haha.service.DataPermissionService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 
+import java.time.Duration;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 import java.util.stream.Collectors;
 
 /**
@@ -40,6 +43,13 @@ public class AdminLoginServiceImpl implements AdminLoginService {
 
     @Autowired
     private DataPermissionService dataPermissionService;
+
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    private static final String REFRESH_TOKEN_PREFIX = "refreshToken:admin:";
+    private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(7);
+    private static final int TOKEN_TIMEOUT_SECONDS = 28800;
     
     private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
     
@@ -74,35 +84,19 @@ public class AdminLoginServiceImpl implements AdminLoginService {
                 
                 // 使用Sa-Token进行登录,生成token
                 StpUtil.login(admin.getId());
-                String token = StpUtil.getTokenValue();
-                
+
                 // 在Session中存储用户信息
                 StpUtil.getSession().set("username", admin.getUsername());
                 StpUtil.getSession().set("realName", admin.getRealName());
                 StpUtil.getSession().set("roleIds", admin.getRoleIds());
-                
+
                 // 获取并存储用户权限列表
                 List<String> permissions = getUserPermissions("1");
                 StpUtil.getSession().set("permissions", permissions);
-                
+
                 log.info("【临时测试账号】登录成功: username={}, userId={}", username, admin.getId());
-                
-                // 构建返回数据
-                UserVO userVO = UserVO.builder()
-                        .id(admin.getId())
-                        .nickname(admin.getRealName())
-                        .avatar(admin.getAvatar())
-                        .phone(admin.getPhone())
-                        .roles(List.of("admin"))
-                        .permissions(permissions)
-                        .build();
-                
-                LoginVO loginVO = LoginVO.builder()
-                        .token(token)
-                        .userInfo(userVO)
-                        .build();
-                
-                return Result.success("登录成功", loginVO);
+
+                return Result.success("登录成功", buildLoginResult(admin, permissions, List.of("admin")));
             }
             // ====== 以上为临时测试逻辑,生产环境必须移除 ======
             
@@ -135,8 +129,7 @@ public class AdminLoginServiceImpl implements AdminLoginService {
             
             // 使用Sa-Token进行登录,生成token
             StpUtil.login(admin.getId());
-            String token = StpUtil.getTokenValue();
-            
+
             // 在Session中存储用户信息
             StpUtil.getSession().set("username", admin.getUsername());
             StpUtil.getSession().set("realName", admin.getRealName());
@@ -150,23 +143,8 @@ public class AdminLoginServiceImpl implements AdminLoginService {
             List<String> roleCodes = getRoleCodes(admin.getRoleIds());
             
             log.info("管理员登录成功: username={}, userId={}", username, admin.getId());
-            
-            // 构建返回数据
-            UserVO userVO = UserVO.builder()
-                    .id(admin.getId())
-                    .nickname(admin.getRealName() != null ? admin.getRealName() : admin.getUsername())
-                    .avatar(admin.getAvatar())
-                    .phone(admin.getPhone())
-                    .roles(roleCodes)
-                    .permissions(permissions)
-                    .build();
-            
-            LoginVO loginVO = LoginVO.builder()
-                    .token(token)
-                    .userInfo(userVO)
-                    .build();
-            
-            return Result.success("登录成功", loginVO);
+
+            return Result.success("登录成功", buildLoginResult(admin, permissions, roleCodes));
             
         } catch (Exception e) {
             log.error("登录异常: username={}, error={}", username, e.getMessage(), e);
@@ -261,4 +239,78 @@ public class AdminLoginServiceImpl implements AdminLoginService {
             return List.of();
         }
     }
+
+    /**
+     * 构建登录返回结果(含 refreshToken)
+     */
+    private LoginVO buildLoginResult(Admin admin, List<String> permissions, List<String> roles) {
+        String token = StpUtil.getTokenValue();
+        String refreshToken = UUID.randomUUID().toString();
+
+        // 将 refreshToken 存入 Redis,关联 userId
+        stringRedisTemplate.opsForValue().set(
+                REFRESH_TOKEN_PREFIX + refreshToken,
+                admin.getId().toString(),
+                REFRESH_TOKEN_TTL
+        );
+
+        UserVO userVO = UserVO.builder()
+                .id(admin.getId())
+                .nickname(admin.getRealName() != null ? admin.getRealName() : admin.getUsername())
+                .avatar(admin.getAvatar())
+                .phone(admin.getPhone())
+                .roles(roles)
+                .permissions(permissions)
+                .build();
+
+        return LoginVO.builder()
+                .token(token)
+                .refreshToken(refreshToken)
+                .tokenTimeout(TOKEN_TIMEOUT_SECONDS)
+                .userInfo(userVO)
+                .build();
+    }
+
+    @Override
+    public Result<LoginVO> refreshToken(String refreshToken) {
+        if (refreshToken == null || refreshToken.isBlank()) {
+            return Result.error(400, "refreshToken 不能为空");
+        }
+
+        String key = REFRESH_TOKEN_PREFIX + refreshToken;
+        String userId = stringRedisTemplate.opsForValue().get(key);
+
+        if (userId == null) {
+            return Result.error(401, "refreshToken 无效或已过期");
+        }
+
+        // 删除旧的 refreshToken
+        stringRedisTemplate.delete(key);
+
+        // 注销旧的 Sa-Token 会话(如果存在)
+        try {
+            StpUtil.logout(Long.parseLong(userId));
+        } catch (Exception ignored) {
+        }
+
+        // 重新登录
+        StpUtil.login(Long.parseLong(userId));
+
+        // 从旧 Session 恢复用户信息
+        // 旧 session 已被注销,需要重新查询
+        Admin admin = adminMapper.selectById(userId);
+        if (admin == null) {
+            return Result.error(404, "管理员不存在或已被删除");
+        }
+
+        List<String> permissions = getUserPermissions(admin.getRoleIds());
+        List<String> roles = getRoleCodes(admin.getRoleIds());
+
+        StpUtil.getSession().set("username", admin.getUsername());
+        StpUtil.getSession().set("realName", admin.getRealName());
+        StpUtil.getSession().set("roleIds", admin.getRoleIds());
+        StpUtil.getSession().set("permissions", permissions);
+
+        return Result.success("token 刷新成功", buildLoginResult(admin, permissions, roles));
+    }
 }

+ 2 - 0
haha-common/src/main/java/com/haha/common/vo/LoginVO.java

@@ -10,5 +10,7 @@ import lombok.Data;
 @Builder
 public class LoginVO {
     private String token;
+    private String refreshToken;
+    private Integer tokenTimeout;
     private UserVO userInfo;
 }