hl-api-changelog/changelogs/2026-05/14_feat_admin_auth_refresh_token.md

7.6 KiB

auth: admin 登录加 refresh token 双令牌轮转机制

服务: hl-user-service (端口 8081) + hl-gateway (端口 8080) PR: #2285 + hotfix #2287 Issue: #2283 日期: 2026-05-14 影响范围: 管理后台所有调用方(hl-ui)的登录态管理


⚠️ 关键变化

之前: admin access token 固定 7 天过期,无刷新机制,过期 → 强制重登。

现在:

  • access token TTL 改为 2 小时(可被环境变量 ADMIN_ACCESS_TTL 覆盖)
  • 新增 refresh token TTL 7 天
  • 新增 POST /admin/auth/refresh 用 refresh 换新 access(refresh 一次性轮转防重放)
  • 登出时后端自动清两套 token

前端必做:接 axios 401 拦截器自动调 /admin/auth/refresh 续期,否则用户 2h 后被强制登出。


接口变化

1. 登录响应新增 3 字段

POST /admin/auth/loginPOST /admin/auth/wechat/verify-2faPOST /admin/auth/switch-roleGET /admin/auth/wechat-qr/statusGET /admin/auth/2fa/check 所有签发 token 的接口,响应 data 多 3 字段:

{
  "code": 200,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiJ9...",            // 旧字段,=accessToken,保留向后兼容
    "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",     // 新增,用于换新 access
    "accessTokenExpiresIn": 7200,                  // 新增,access token 有效期(秒)
    "refreshTokenExpiresIn": 604800,               // 新增,refresh token 有效期(秒)
    "adminId": 1002,
    "username": "test_admin",
    "role": "ADMIN",
    "roleName": "管理员",
    "avatar": null,
    "roles": [...]
  }
}

2. 新端点 POST /admin/auth/refresh

方法/路径 POST /admin/auth/refresh
Authorization 不需要(access 可能已过期)
body { "refreshToken": "<当前 refresh token>" }
成功返回 Result<AdminLoginResponse> (与登录响应结构一致)
失败返回 { "code": 401, "message": "刷新令牌无效或已过期,请重新登录" }

轮转特性:每次 refresh 成功,旧 refresh 立即失效(防重放)。前端必须立即用新返回的 refreshToken 替换本地存储的老值。

3. 登出行为变化

POST /admin/auth/logout 后端同时清:

  • admin_token:{adminId} (access 白名单)
  • admin_refresh_token:{adminId} (refresh 白名单)

前端流程不变,登出后调 refresh 会立即报 401。


前端 axios 拦截器伪代码 (@mmg)

// 1. 登录后存全套 token
const { token, refreshToken } = res.data;
localStorage.setItem('admin_access', token);
localStorage.setItem('admin_refresh', refreshToken);

// 2. 业务请求拦截:Authorization Bearer access
request.interceptors.request.use((cfg) => {
  const access = localStorage.getItem('admin_access');
  if (access) cfg.headers.Authorization = `Bearer ${access}`;
  return cfg;
});

// 3. 401 响应拦截:自动 refresh,失败才跳登录
let refreshing = null;  // 单飞防并发 401 一起触发多次 refresh
request.interceptors.response.use(
  (res) => {
    // 业务 code 401 也算未授权(本项目 HTTP 总是 200,401 在 body.code)
    if (res.data?.code === 401) return handleAuth401(res);
    return res;
  },
  (err) => {
    if (err.response?.status === 401) return handleAuth401(err);
    return Promise.reject(err);
  }
);

async function handleAuth401(errOrRes) {
  if (refreshing) {
    await refreshing;
    return request(errOrRes.config);  // 等别人 refresh 完用新 token 重发
  }
  refreshing = (async () => {
    const refresh = localStorage.getItem('admin_refresh');
    if (!refresh) throw new Error('no refresh');
    try {
      const r = await axios.post('/admin/auth/refresh', { refreshToken: refresh });
      if (r.data?.code !== 200) throw new Error('refresh failed');
      const { token, refreshToken } = r.data.data;
      localStorage.setItem('admin_access', token);
      localStorage.setItem('admin_refresh', refreshToken);
    } catch (e) {
      // refresh 也挂了 -> 清状态 -> 跳登录
      localStorage.removeItem('admin_access');
      localStorage.removeItem('admin_refresh');
      router.push('/login');
      throw e;
    } finally {
      refreshing = null;
    }
  })();
  await refreshing;
  // 用新 access 重发原请求
  errOrRes.config.headers.Authorization = `Bearer ${localStorage.getItem('admin_access')}`;
  return request(errOrRes.config);
}

关键点:

  • 单飞锁 refreshing 防并发 401 一起触发多次 refresh(会被 jti 轮转拒)
  • refresh 失败 → 清本地 → 跳 /login(等同于以前 401 弹窗的兜底)
  • 老前端不发版也能用(老 access 7d→2h 后会被踢,但 token 字段还在,业务调用方式不变)

测试服 9 步真测 PASS (admin / Admin@123456)

STEP 1: login                                       -> 200 + 4 字段齐全 (accessTTL=7200, refreshTTL=604800)
STEP 2: GET /admin/auth/info Bearer access1         -> 200 (access 可用)
STEP 3: POST /admin/auth/refresh {refresh1}         -> 200 + 新 access2+refresh2
STEP 4: GET /admin/auth/info Bearer access2         -> 200 (新 access 可用)
STEP 5: POST /admin/auth/refresh {OLD refresh1}     -> 401 (轮转防重放)
STEP 6: POST /admin/auth/logout Bearer access2      -> 200
STEP 7: POST /admin/auth/refresh {refresh2 登出后}   -> 401 (Redis 清掉)
STEP 8: POST /admin/auth/refresh {access1}          -> 401 (access 当 refresh,type 校验)
STEP 9: POST /admin/auth/refresh {篡改 refresh}      -> 401

配置项

配置 key 默认 环境变量
auth.jwt.admin-access-ttl-seconds 7200 (2h) ADMIN_ACCESS_TTL
auth.jwt.admin-refresh-ttl-seconds 604800 (7d) ADMIN_REFRESH_TOKEN_TTL

代码层已 fallback,nacos 不配也跑;需要调整时通过环境变量或 nacos 配置覆盖。


⚠️ 正式服上线建议

正式服部署前,在 Deploy Panel 给 hl-user-service 设环境变量 ADMIN_ACCESS_TTL=604800(把 access 临时维持 7 天),等前端 mmg 接好 axios 拦截器后再去掉环境变量自动切回 2h。否则一上线所有 admin 用户 2h 后被强制踢下线。

测试服不需要(用户只有 wx/test_admin)。


不影响范围

  • 小程序 user (USER 类型 token) — 现状 90 天不变
  • 登录端点 path 不变
  • /admin/auth/info 等业务接口契约不变
  • 已签发的 token 在 Redis 还有原 TTL,不会因部署立即失效

switchRole 提醒(@mmg 重要)

切换角色 POST /admin/auth/switch-role 响应也包含新的 refreshToken/accessTokenExpiresIn/refreshTokenExpiresIn 字段。前端必须同时更新本地存储的 accessToken 和 refreshToken(背后 jti 已轮换,旧 refresh 立即失效)。不要只取 token 字段。

已审视的反向 cleanup 场景

场景 是否清 token 说明
logout 清 access + refresh 主动登出
changePassword 清 access + refresh 改密强制重登
deleteAdmin 清 access + refresh (#2294 补) 删 admin 防绕过
resetPassword 清 access + refresh (#2294 补) 超管重置他人密码
login 账号锁定 (失败 5 次) 清 access + refresh (#2294 补 + 修旧 bug) 可疑账号防已签发 token 继续生效
switchRole 不主动清 (jti 轮转自动覆盖) issueAdminTokens 写新 jti 覆盖旧
login 成功 不主动清 (同 switchRole)
unlockAdmin 不清 登录态本就无效

相关文档

  • Issue: wx/HL#2283
  • PR 主体: wx/HL#2285
  • Hotfix #2287: 补 user-service TokenInterceptor 白名单
  • Hotfix #2294: 补 deleteAdmin/resetPassword/账号锁定 3 处 refresh cleanup + 锁定旧 bug