feat: refreshToken 滑动续期 changelog (HL PR #2312)
这个提交包含在:
父节点
f957215002
当前提交
60ebafda75
@ -0,0 +1,234 @@
|
|||||||
|
# auth: refreshToken 滑动续期 + 双 token 机制(admin + 小程序 user)
|
||||||
|
|
||||||
|
> **服务**: hl-user-service (8081) + hl-mp-service (8085) + hl-gateway (8080)
|
||||||
|
> **PR**: #2312 (已合 dev,测试服待 Deploy Panel 部署)
|
||||||
|
> **Issue**: #2309
|
||||||
|
> **日期**: 2026-05-15
|
||||||
|
> **影响范围**: 管理后台 hl-ui + 微信小程序两端的登录态 / 401 拦截器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 关键变化(对前一版 #2285 的演进)
|
||||||
|
|
||||||
|
| 维度 | 上一版 PR #2285 (2026-05-14) | 本版 PR #2312 (2026-05-15) |
|
||||||
|
|------|--------------------------------|------------------------------|
|
||||||
|
| 适用端 | 仅 admin | **admin + 小程序 user 双端** |
|
||||||
|
| accessToken TTL | admin 2h | **admin 30 分钟 / user 30 分钟** |
|
||||||
|
| refreshToken TTL | admin 7d | **admin 7d / user 90d** |
|
||||||
|
| 续期模式 | refresh 字符串**轮转**(每次换新串,旧立即失效) | **滑动续期** — refresh 字符串**不换**,只刷 Redis 中 jti 的 TTL |
|
||||||
|
| 灰度策略 | 直接切 | **网关新 key 优先 + 旧 key fallback**(2026-05-22 删 fallback) |
|
||||||
|
| 字段名 | data.token = access (兼容) + refreshToken | **同上,新增 data.accessToken 显式字段,data.token 仍保留** |
|
||||||
|
|
||||||
|
**核心新原则**:**refreshToken 字符串后端不换,前端无需更新本地存的 refresh,只更新 access**。这是与 #2285 最大的行为差异 —— 上一版前端必须每次 refresh 后立即覆盖本地 refresh,这一版**不需要**(覆盖也无害)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
PR #2285 上线后用户中途反馈:**rotation 模式遇到弱网/重复请求/双标签页时,新 refresh 还没收到旧 refresh 已被废,触发误踢登录**。本版改为滑动续期:refresh 字符串保持稳定 7d/90d 内不变,只刷 Redis TTL,既解决误踢又保留"长期不活跃自动失效"语义。同时把小程序 user 端也接入(此前 user 端是无 refresh 机制 90 天硬到期)。
|
||||||
|
|
||||||
|
TTL 配置(线上确认值):
|
||||||
|
|
||||||
|
| 端 | accessToken | refreshToken | nacos key |
|
||||||
|
|----|-------------|--------------|-----------|
|
||||||
|
| admin | 30 分钟 (1800s) | 7 天 (604800s) | `auth.jwt.admin-access-ttl-seconds` / `auth.jwt.admin-refresh-ttl-seconds` |
|
||||||
|
| 小程序 user | 30 分钟 (1800s) | 90 天 (7776000s) | `auth.jwt.user-access-ttl-seconds` / `auth.jwt.user-refresh-ttl-seconds` |
|
||||||
|
|
||||||
|
灰度期: **2026-05-15 ~ 2026-05-22**,7 天后后端会清掉网关里的旧 key fallback 块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、变更接口清单
|
||||||
|
|
||||||
|
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||||
|
|---|------|------|------|----------|------|
|
||||||
|
| 1 | admin 刷新令牌 | POST | `/admin/auth/refresh` | 行为变化 | 从字符串轮转改滑动续期,refresh 不换串 |
|
||||||
|
| 2 | 小程序刷新令牌 | POST | `/mp/user/refresh` | **新增** | 小程序端首次有 refresh 能力 |
|
||||||
|
| 3 | user-service 刷新令牌(内部) | POST | `/user/refresh` | **新增** | hl-mp-service Feign 调用入口 |
|
||||||
|
| 4 | user-service 刷新令牌(MP 内部) | POST | `/internal/mp/user/refresh` | **新增** | mp-service → user-service Feign |
|
||||||
|
| 5 | admin 所有签发 token 接口 | - | login/2fa/switch-role/wechat-qr/check 等 | 响应字段微调 | data 内仍是 token+refreshToken+accessTokenExpiresIn+refreshTokenExpiresIn,**额外多 accessToken 字段** |
|
||||||
|
| 6 | 小程序 user login + login-by-sms | POST | `/mp/user/login` 等 | 响应新增 | data 多 refreshToken+双 expiresIn+accessToken 字段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、登录响应字段(灰度期前端可零改动)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"token": "eyJ...ACCESS", // 向后兼容字段, = accessToken
|
||||||
|
"accessToken": "eyJ...ACCESS", // 新字段, 同 token
|
||||||
|
"refreshToken": "eyJ...REFRESH", // 新字段, 长效 7d/90d
|
||||||
|
"accessTokenExpiresIn": 1800, // 秒
|
||||||
|
"refreshTokenExpiresIn": 604800, // 秒, admin=604800/user=7776000
|
||||||
|
"...其它现有字段保持不变"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**灰度期内前端不改任何代码,继续读 `data.token` 即可正常登录使用,只是 30 分钟后会被踢**(因为没用 refresh)。要做无感续期才需要新增 refresh 拦截器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、新接口详情
|
||||||
|
|
||||||
|
### 1. POST /admin/auth/refresh (管理端)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "refreshToken": "eyJ...REFRESH" }
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"token": "...新 access...",
|
||||||
|
"accessToken": "...新 access...",
|
||||||
|
"refreshToken": "...原 refresh,字符串不变...",
|
||||||
|
"accessTokenExpiresIn": 1800,
|
||||||
|
"refreshTokenExpiresIn": 604800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 401 — refresh 已过期 / 已被 logout 作废 / 篡改 / tokenKind 错
|
||||||
|
{ "code": 401, "msg": "刷新令牌无效或已过期,请重新登录" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不需要 Authorization 头**(网关 SKIP_URLS 已加白名单)
|
||||||
|
- **refresh JWT 字符串保持不变**,仅刷新 Redis 中 jti 的 TTL
|
||||||
|
- 错误码: `200209 REFRESH_TOKEN_INVALID` / `200210 REFRESH_TOKEN_REPLAY` / `200211 TOKEN_KIND_MISMATCH`
|
||||||
|
|
||||||
|
### 2. POST /mp/user/refresh (小程序端,新接口)
|
||||||
|
|
||||||
|
请求 / 响应结构同上,`refreshTokenExpiresIn = 7776000` (90 天)。
|
||||||
|
|
||||||
|
- **不需要 Authorization 头**
|
||||||
|
- 链路: 小程序 → hl-mp-service `MpUserController` → `MpUserAggregationService` → `MpUserFeignClient.refreshToken()` → hl-user-service `InternalMpUserController` `POST /internal/mp/user/refresh`
|
||||||
|
|
||||||
|
### 3. logout 行为(无契约变化,但行为补强)
|
||||||
|
|
||||||
|
`POST /admin/auth/logout` / `POST /mp/user/logout` 后端同时清:
|
||||||
|
- access 白名单 key (新+旧两套)
|
||||||
|
- refresh 白名单 key
|
||||||
|
|
||||||
|
logout 后立即调 refresh 会 401。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、自动 refresh 拦截器实现要点 (@mmg)
|
||||||
|
|
||||||
|
### admin 端 (axios)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 单飞锁防并发 401 一起触发多次 refresh
|
||||||
|
let refreshing = null;
|
||||||
|
|
||||||
|
async function onResponseError(err) {
|
||||||
|
// 本项目 HTTP 总是 200,401 在 body.code,也要拦
|
||||||
|
const code = err.response?.data?.code;
|
||||||
|
if ((code === 401 || err.response?.status === 401) && !err.config._retried) {
|
||||||
|
if (!refreshing) {
|
||||||
|
refreshing = (async () => {
|
||||||
|
const r = await axios.post('/admin/auth/refresh', {
|
||||||
|
refreshToken: localStorage.getItem('admin_refresh')
|
||||||
|
});
|
||||||
|
if (r.data?.code !== 200) throw new Error('refresh failed');
|
||||||
|
// 关键: refreshToken 字符串和原来一样,但 accessToken 变了
|
||||||
|
localStorage.setItem('admin_access', r.data.data.accessToken); // 或 .token
|
||||||
|
// refreshToken 不需要更新(后端不换串),但更新一下也无害
|
||||||
|
localStorage.setItem('admin_refresh', r.data.data.refreshToken);
|
||||||
|
})().finally(() => { refreshing = null; });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refreshing;
|
||||||
|
err.config._retried = true;
|
||||||
|
err.config.headers.Authorization = `Bearer ${localStorage.getItem('admin_access')}`;
|
||||||
|
return axios.request(err.config);
|
||||||
|
} catch (refreshErr) {
|
||||||
|
localStorage.removeItem('admin_access');
|
||||||
|
localStorage.removeItem('admin_refresh');
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 小程序端
|
||||||
|
|
||||||
|
同样的 401 拦截思路,把 `axios` 换成 `wx.request` 包装,storage 用 `wx.setStorageSync('user_refresh', ...)`,refresh 端点 `/mp/user/refresh`。
|
||||||
|
|
||||||
|
⚠️ **复用同一 refreshToken 字符串** — 后端不换串,你前端**无需**更新 refreshToken,只需更新 accessToken / token 字段。这与 PR #2285 的行为不同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、安全注意
|
||||||
|
|
||||||
|
1. **refreshToken 不要带在业务请求里**,只在调 `/admin/auth/refresh` 或 `/mp/user/refresh` 时用
|
||||||
|
2. **refreshToken 不要传给 Authorization 头** — 网关会显式拒绝(`tokenKind=REFRESH 不能用于业务接口`,401)
|
||||||
|
3. logout 时要把 storage 里的 token + refreshToken 都清掉
|
||||||
|
4. accessToken 30 分钟 TTL,即使被泄露窗口期短;refreshToken 7d/90d TTL 长,泄露后果严重 — 优先存 secure storage(小程序 `wx.setStorage` 即可,管理后台用 httpOnly cookie 不现实就用 localStorage,**禁止打印到 console / 上传到第三方 SDK**)
|
||||||
|
5. switchRole / changePassword / 改密 / 删 admin / 锁定账号 等场景后端会主动清 refresh,前端调 refresh 收到 401 时直接跳登录即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、灰度兼容期具体行为(2026-05-15 ~ 2026-05-22)
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 旧前端 (只读 data.token,不调 refresh) | ✓ 正常工作 |
|
||||||
|
| 旧前端 access 30 分钟后过期 | ⚠️ 30 分钟后 401,被迫重登(因为没用 refresh) |
|
||||||
|
| 新前端 (调 refresh) | ✓ 滑动续期,7d/90d 内活跃永不重登 |
|
||||||
|
| 灰度期内旧 admin_token / user_token Redis key | ✓ 网关 fallback 仍认 |
|
||||||
|
| 2026-05-22 后旧 key 失效 | ❌ 仍读旧 key 的代码会全失败 — **mmg 务必在 5-22 前完成切换** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、不影响范围
|
||||||
|
|
||||||
|
- 现有所有业务接口契约(参/返)零变化
|
||||||
|
- 登录端点 path 不变
|
||||||
|
- /admin/auth/info / /mp/user/profile 等读接口不变
|
||||||
|
- 已签发的旧 access token 在 Redis 还有原 TTL,部署后**不会立即失效**(灰度 fallback)
|
||||||
|
- 接口字段名 `data.token` 保留,**不做 breaking 改名**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、配置项
|
||||||
|
|
||||||
|
| 配置 key | 默认 | 备注 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `auth.jwt.admin-access-ttl-seconds` | 1800 (30 分钟) | nacos 可覆盖 |
|
||||||
|
| `auth.jwt.admin-refresh-ttl-seconds` | 604800 (7 天) | nacos 可覆盖 |
|
||||||
|
| `auth.jwt.user-access-ttl-seconds` | 1800 (30 分钟) | nacos 可覆盖 |
|
||||||
|
| `auth.jwt.user-refresh-ttl-seconds` | 7776000 (90 天) | nacos 可覆盖 |
|
||||||
|
|
||||||
|
代码层有 fallback,nacos 不配也能跑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、相关历史 PR
|
||||||
|
|
||||||
|
| PR | Issue | 说明 | 是否仍有效 |
|
||||||
|
|----|-------|------|------------|
|
||||||
|
| #2285 (2026-05-14) | #2283 | 首版双 token,admin only,字符串轮转 | ❌ 已被本 PR 演进替代(除合规清理逻辑外行为差异) |
|
||||||
|
| #2287 hotfix | - | user-service TokenInterceptor 白名单 | ✅ 仍有效 |
|
||||||
|
| #2294 hotfix | - | deleteAdmin/resetPassword/账号锁定 cleanup | ✅ 仍有效,本 PR 兼容 |
|
||||||
|
| **本 PR #2312** | **#2309** | 滑动续期 + 双端覆盖 + 灰度兼容 | ✅ **最新** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、联调端点
|
||||||
|
|
||||||
|
- 测试服: `https://api.test.1814.love/`(待 Deploy Panel 部署 PR #2312 后生效)
|
||||||
|
- 测试 admin: `admin / Admin@123456`
|
||||||
|
- 测试小程序 user: 用现有微信测试号登录即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、相关文档
|
||||||
|
|
||||||
|
- 关联 Issue: [wx/HL#2309](https://git.1814.love:8443/wx/HL/issues/2309)
|
||||||
|
- 关联 PR: [wx/HL#2312](https://git.1814.love:8443/wx/HL/pulls/2312)
|
||||||
|
- 上一版 changelog: `2026-05/14_feat_admin_auth_refresh_token.md`
|
||||||
正在加载...
x
在新工单中引用
屏蔽一个用户