ds2api / docs /token-refresh-research.md
luckfun233
fix(auth): token 刷新改为无密码会话探针,密码登录仅作兜底
3df9d54
|
Raw History Blame Contribute Delete
23.7 kB
# DeepSeek Token 刷新机制 逆向研究报告
> 研究目标:确认 ds2api 目前的「刷新 token」是否等价于「重新登录」,以及
> DeepSeek 官方客户端是否存在真正的 token 刷新(refresh / rotate)通道。
> 研究时间:2026-10-17
> 证据来源:`chat.deepseek.com (1)/`(Web 端完整抓包)、根目录若干 `.har`、`deepseek.apk`(Android 2.2.2 / 2.3.3)
> 状态:**调研结论已明确**;阶段 1 已落地(§9),并有单账号线上实测(§9.5)
---
## 一、结论摘要(TL;DR)
1. **改动前的「刷新 token」等于「重新登录」。** `Resolver.RefreshToken()`
清空本地 token 后走 `loginAndPersist()` → `Client.Login()` →
`POST /api/v0/users/login`,也就是**每次都把邮箱/手机号 + 明文密码重新提交一次**。
定时刷新(默认 6h)也一样走这条路径。
(已改:现在是「无密码探针优先,明确失效才登录」,见 §9。)
2. **DeepSeek 没有「access_token + refresh_token」那套机制。** 我们在
- Web 端 JS bundle(`main.7d19d7e901.js`)
- Android APK 的 `classes.dex` / `classes2.dex` / `classes3.dex`
三个产物里做了端点全量枚举(APK 侧提取到 206 条 URL 字符串),
**不存在** `/refresh`、`/refresh_token`、`/token/refresh`、`/auth_token/refresh`
之类的端点。登录接口下发的 token 是**不透明字符串(opaque,64 字符 base64),不是 JWT**,
客户端无法本地判断过期时间。
3. **但官方客户端确实「不重新登录就拿到新 token」**,有两条真实通道:
| 通道 | 端点 | 是否需要密码 | 观察到的行为 |
|------|------|--------------|--------------|
| 会话回填(主线) | `GET /api/v0/users/current` | ❌ 不需要 | 响应 `data.biz_data.token` 就是当前 token;Web 端拿到后**直接写回本地存储** |
| token 轮换(支线) | `POST /api/v0/users/auth_token/check_device` | ❌ 不需要 | 响应 `data.biz_data.rotate`,结构为 `{"token": "..."}`;**抓包里始终是 `null`** |
所以「更优雅的刷新」是可行的:**先试 `users/current` 回填,失败再回退到密码登录**,
而不是一上来就重新登录。
4. **另外发现一个副作用风险**:当前每次刷新都是一次真实登录,而登录要过数美设备风控
(`RISK_DEVICE_DETECTED`)与密码校验。项目里 `handleBannedAccount()` 和
`loginWithDevice()` 的「风控时重签 device_id」逻辑,本质上就是在给这条高频登录路径擦屁股。
降低登录频次本身就能减少被风控/锁号的面。
---
## 二、现状:刷新 == 重新登录(代码事实)
> 本节记录的是**改动前**的代码事实(行号同当时版本)。
> 改动后的实现见 §9:`RefreshToken()` 现在先走无密码探针,密码登录只是兜底。
### 2.1 调用链
```
Resolver.RefreshToken(ctx, a) internal/auth/request.go:201
├─ Store.UpdateAccountToken(accountID, "") // 先清空
├─ a.Account.Token = ""
└─ loginAndPersist(ctx, a) internal/auth/request.go:153
├─ r.Login(ctx, a.Account) // ← 注入的 LoginFunc
└─ Store.UpdateAccountToken(accountID, token)
```
`r.Login` 由 `Client.Login` 实现:
```
Client.Login(ctx, acc) internal/deepseek/client/client_auth.go:25
└─ loginWithDevice(ctx, acc, regenerated=false)
├─ persistedDeviceID(acc) / issueDeviceID(ctx, acc) // 数美签发 device_id
└─ POST https://chat.deepseek.com/api/v0/users/login // internal/deepseek/protocol/constants.go:12
payload: { password, device_id, os, email|mobile(+area_code) }
← data.biz_data.user.token
```
即:**刷新 = 提交密码 = 完整登录**。没有任何「拿旧 token 换新 token」的动作。
### 2.2 刷新被触发的 5 个错误路径
`shouldAttemptRefresh()`(`internal/deepseek/client/client_auth.go:320`)命中后都会调
`c.Auth.RefreshToken()`,从而走完整登录:
| 位置 | 场景 |
|------|------|
| `internal/deepseek/client/client_auth.go:145` | 创建会话失败且判定 token 失效 |
| `internal/deepseek/client/client_auth.go:209` | 取 PoW challenge 失败且判定 token 失效 |
| `internal/deepseek/client/client_session.go:90` | 拉取会话列表失败 |
| `internal/deepseek/client/client_session_delete.go:65` | 删除会话失败 |
| `internal/deepseek/client/client_upload.go:161` | 上传文件失败 |
### 2.3 定时刷新也是重新登录
`ensureManagedToken()`(`internal/auth/request.go:300`)在 `shouldForceRefresh()`
(`internal/auth/request.go:314`)到期时同样调 `loginAndPersist()`。
- 间隔配置:`runtime.token_refresh_interval_hours`,默认 **6h**
(`internal/config/store_accessors.go:137`、`config.example.json:67`)
- 带 ±10% 抖动 + 0~30min 随机偏移,避免同刻集中刷新
也就是说:**只要服务跑满 6 小时,每个托管账号就必然重新登录一次**,哪怕 token 完全没坏。
> 已修复:本节描述的是调研时的实现。现在到期只做一次无密码探针复核,
> 只有服务端明确拒绝会话才回退到密码登录,见 §9。
### 2.4 `MarkTokenInvalid` 也是清空
`internal/auth/request.go:214` 把 token 清空落盘,下一次 `ensureManagedToken` 会再登录一次。
---
## 三、这样做的代价(为什么不优雅)
1. **密码高频出网。** 每 6h × 每账号一次的明文密码提交,纯属没必要的暴露面。
一旦某次请求被中间人/代理记录,损失的是账号主凭证,而不是一个可吊销的会话。
2. **每次刷新都要过一次风控。** `client_auth.go` 已被迫实现
「`RISK_DEVICE_DETECTED` → 重新签发数美 device_id → 重试一次」;
登录频次越高,触发风控与设备指纹漂移的概率越大。
3. **可能直接锁号。** `USER_IS_BANNED` 会让项目自动把账号置为 banned
(`internal/auth/request.go:171` `handleBannedAccount`),
而频繁登录正是被风控盯上的典型特征之一。
4. **多一次网络 + 多依赖一个签发服务(数美)。** 刷新本该是一次轻量 GET,
现在变成「数美签发 device_id + 登录 + 落盘」三步,其中任何一步挂了,刷新就失败。
5. **与官方客户端行为不一致。** 官方 Web/Android 都不会为了续期去重新登录(见 §4)。
---
## 四、官方客户端实际怎么「刷新」
### 4.1 先确认:没有任何 refresh 端点
| 产物 | 枚举方式 | 结果 |
|------|----------|------|
| Web | `fe-static.deepseek.com/chat/static/*.js` 里 `"/api/v0/..."` 字面量全提取 | 44 个端点,无 refresh |
| APK | 解析 `classes{,2,3}.dex` 字符串池,过滤含 `/api/` 或 `http` 的字符串 | 206 条,无 refresh |
APK 侧 auth 相关端点全集(节选):
```
/api/v0/users/login
/api/v0/users/current ← 会话回填
/api/v0/users/auth_token/check_device ← rotate 通道
/api/v0/users/logout
/api/v0/users/logout_all_sessions
/api/v0/users/login_by_mobile_sms
/api/v0/users/oauth/{wechat,google,apple}/login
/api/v0/users/oauth/get_token
/api/v0/users/mfa/{verify_signature,trusted_device/register,trusted_device/verify,...}
```
> ⚠️ 排除一个**假线索**:APK 里能 grep 到 `Refreshing token for ` /
> `Refreshing token failed for ` / `Can not add or refresh token`,这些字符串来自随包的
> **Ktor 客户端库**(`io.ktor.client.plugins.auth.BearerAuthProvider`),
> 不是 DeepSeek 自己的 token 刷新逻辑,不能当作「存在 refresh 流程」的证据。
### 4.2 主线:`GET /api/v0/users/current` 回填 token(Web 端已实现)
抓包证据(`chat.deepseek.com_2026_08_01_11_25_19.har`,Android 客户端启动):
```http
GET /api/v0/users/current
authorization: Bearer <TOKEN>
200 OK
{"code":0,"data":{"biz_code":0,"biz_data":{
"id":"<user_id>",
"token":"<TOKEN>", ← 和请求头里带的是同一个
"mobile_number":"152******03", ...}}}
```
紧接着的 `POST /users/login` 返回的 `user.token` **和第 49 条的 token 完全相同**。
`gator.volces.com_2026_09_12_20_19_40.har`(第 66 / 70 条)观察到同样现象。
Web 端 JS 里这个动作有明确实现(`main.7d19d7e901.js`),启动时的伪代码:
```js
// 启动引导
if (!getPersistedCurrentUser().token || isIOS) { navigateToSignIn(); return; }
const localToken = getPersistedCurrentUser().token;
getCurrentUser({ http, baseUrl: "/api/v0/users" }) // → GET /api/v0/users/current
.then(({ data: { user } }) => {
// 只有本地 token 没被并发改写,才应用服务端结果
if (getPersistedCurrentUser().token === localToken) applySession(user);
})
.catch(() => tracker.error({ name: "getCurrentFailed",
message: "刷新当前用户状态失败" }));
// applySession(user):
// user.token && setPersistedCurrentUser(user.token, user.id) ← 采纳服务端下发的 token
```
关键点:
- **注释级事实**:官方前端自己把这一步叫做「刷新当前用户状态」(tracker 消息原文)。
- `setPersistedCurrentUser(user.token, user.id)` 会**用服务端返回的 token 覆盖本地**。
也就是说,只要服务端在 `biz_data.token` 里给了新值,客户端就自动接班,
全程不需要密码。
- 失败时的处理是**跳转到登录页让用户手动登录**(`wE()` 清 token + `navigateToSignIn`),
不是自动重新登录。
### 4.3 支线:`POST /api/v0/users/auth_token/check_device` 的 `rotate`
Web 抓包(`chat.deepseek.com (1)/2222.har` 第 61 条):
```http
POST /api/v0/users/auth_token/check_device
authorization: Bearer <TOKEN>
{"device_id":"7d3c1bcd-...","device_model":""}
200 OK
{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"rotate":null}}}
```
Web 端 JS 对它的处理**只看 `code` / `biz_code` 是否成功,完全忽略 `rotate`**
(`disableChallengeRetry`、`skipGlobalAuthEffects`,纯粹当「上报设备」用)。
所以**光看 Web 端会得出「rotate 没用」的错误结论**。
Android 端不是这样。APK 里有显式的模型类(`classes.dex` 字符串池):
| 类 | 说明 |
|----|------|
| `com.deepseek.chat.network.auth.model.users.CheckDeviceResponseBizData` | 字段 `rotate` |
| `com.deepseek.chat.network.auth.model.users.CheckDeviceRotateData` | 字段 `token`,`toString` 字面量为 `CheckDeviceRotateData(token=` |
即服务端可以为 `rotate` 下发 `{"token": "..."}`,客户端已按「有 token 就说明要换 token」
的数据结构建好了模型。
> **未完成的一步**:Android 侧「拿到 `rotate.token` 之后是否落盘替换本地 token」的
> 业务侧追踪被打断(时间原因),只确认到**模型层已完整建模**,见 §7。
### 4.4 token 语义(决定了「刷新」能是什么样)
综合三次抓包:
| 观察 | 结论 |
|------|------|
| 形如 `<44 字符随机段><20 字符随机段>`(含 `+` `/`,共 64 字符) | 64 字符 base64,**不是 JWT**(无 `.` 分段,无 `exp` 可解析) |
| 同一账号 + 同一 `device_id` 反复登录 | **返回同一个 token** · ⚠️ 该行已被 §9.5 的线上实测推翻 |
| `users/current` 响应 | 回显**同一个 token**(线上实测仍成立) |
| 不同平台 / 不同 `device_id` | token 不同(`os=web` 与 `os=android` 互不相同) |
所以 DeepSeek 是「**服务端长期会话 token,按设备绑定**」模型,
不是短效 access token。这意味着:
- 没有「token 快过期了所以必须刷新」这回事 → **定时 6h 强制重登在语义上没有必要**;
- 真正需要处理的只有两类事件:
1. **token 被服务端吊销/轮换** → `users/current` 可能回填到新值,或直接报鉴权失败;
2. **会话彻底死了** → 才需要密码登录。
---
## 五、建议方案
### 5.1 目标
- 绝大多数「刷新」用**无密码**通道完成;
- 密码登录只作为**最终兜底**;
- 定时任务不再触发密码登录。
### 5.2 分阶段实现
**阶段 1(低风险,收益最大):新增 `GET /api/v0/users/current` 回填 —— ✅ 已实现(见 §9)**
- 在 `dsprotocol` 增加 `DeepSeekCurrentUserURL = ".../api/v0/users/current"`。
- `Client` 新增 `RefreshSessionToken(ctx, a) (string, error)`:
带 `Bearer a.DeepSeekToken`(或持久化 token)发 GET,取 `data.biz_data.token`;
非空则 `Store.UpdateAccountToken()` 落盘并回写 `a.DeepSeekToken`。
- `Resolver.RefreshToken()` 改为:**先调用该通道**,成功即返回;
失败(网络/401/biz 错误)才 fallback 到现有 `loginAndPersist()`。
- `shouldForceRefresh()` 到期时同样走这条轻量路径。
收益:把默认 6h 的强制重登从「必发密码」变成「一次 GET」,风险面直接降一个量级。
**阶段 2(可选):接入 `check_device` 的 rotate**
- `POST /api/v0/users/auth_token/check_device`,body
`{"device_id": <acc.DeviceID>, "device_model": ""}`。
- 若 `data.biz_data.rotate` 非 null 且带 `token`,落盘替换。
- 注意:`DeviceID` 为空(用户直接填 token、没填 device_id)时**不要调用**,
否则等于主动上报一个可疑设备身份。
- 该通道在全部抓包里都是 `null`,属于「有更好、没有也不影响」的增强。
**阶段 3:明确降级顺序,收敛重试**
建议把认证失败的处置顺序固定为:
```
users/current 回填 → check_device rotate → 密码登录 → 切换账号
```
并且**每次请求生命周期内每级最多触发一次**(现有 `refreshed` 布尔量已经做了雏形,
建议随新通道扩成枚举/计数,避免多级连环重试放大延迟)。
### 5.3 不建议做的事
- ❌ 不建议靠缩短 `token_refresh_interval_hours` 来「保持新鲜」——只会加剧重登。
- ❌ 不建议把 `MarkTokenInvalid()` 之后的路径也改成必然登录;应统一走 §5.2 的降级链。
---
## 六、与现有文档的关系
- `security_best_practices_report.md:171` 曾建议「定期刷新 token(已有
`TokenRefreshIntervalHours` 配置)」。本文确认该配置**旧语义是定期重新登录**;
该条目已随 §9 的改动同步为「定期做无密码探针复核」。
- `docs/web-endpoint-upgrade.md` 记录的是登录协议的数美风控细节,与本文互补:
那条链路越少走越好,正是本文的动机。
---
## 七、未完成 / 待验证(重要)
下列项目**没有闭环**,后续要补时从这里接:
1. **Android 是否真的采纳 `rotate.token`。**
已确认:`CheckDeviceResponseBizData.rotate` 与 `CheckDeviceRotateData.token`
两个模型类齐全,反序列化器(`Ldq2;` / `Lgq2;`)齐全;
业务侧「读到 rotate 后写入本地 token」的调用点追踪中断在 `Lma0;`,
**未形成完整证据链**。
续做法:继续用 `classes.dex` 方法级交叉引用往上追,
或等 jadx 全量反编译完成后(本次因机器只剩 ~2GB 提交内存、jadx 反复 OOM,
只能小堆跑,最终未跑完)直接读 `com.deepseek.chat.network.auth` 包下的 UseCase / Repository。
2. **`rotate` 什么时候非 null。**
三次抓包(含 Web 首次登录后、App 启动、长时间使用后)全部为 `null`。
推测与「设备指纹变更 / 会话被顶号 / 服务端主动轮换」有关,**未验证**。
3. **`users/current` 是否会返回「与请求不同的 token」。**
所有抓包中 `response.token == request token`。若永远相同,
则阶段 1 的价值是「用一次 GET 确认会话有效 + 拿到落盘值」,
而不是「换到新 token」——仍然比重新登录好,但收益要按这个预期描述。
4. **未做任何线上实测。**
抓包里含真实账号的密码与 token,本文**已全部脱敏**;
也没有用这些凭证去实际登录或验证端点,
因为这会真实触发数美风控、可能影响该账号状态。
---
## 八、附录:证据与复现
### 8.1 关键证据位置
| 证据 | 位置 |
|------|------|
| `users/current` 回填 token(Android 启动) | `chat.deepseek.com_2026_08_01_11_25_19.har` 第 49、50 条 |
| `users/current` 回填 token(另一轮) | `gator.volces.com_2026_09_12_20_19_40.har` 第 66、70 条 |
| `users/current` 回填 token(Web) | `chat.deepseek.com_2026_07_14_20_56_28.har` 第 15 条 |
| `check_device` 请求/响应、`rotate:null` | `chat.deepseek.com (1)/2222.har` 第 61 条 |
| Web 端回填逻辑、`刷新当前用户状态失败` | `chat.deepseek.com (1)/fe-static.deepseek.com/chat/static/main.7d19d7e901.js` |
| Web 端忽略 `rotate` | 同上,`/auth_token/check_device` 调用点 |
| Android rotate 模型类 | `deepseek.apk` → `classes.dex` 字符串池 |
### 8.2 复现命令(DEX 串/交叉引用)
```powershell
# 1) 解包 APK(只解 dex 与 lib 即可,不需要资源)
Expand-Archive -LiteralPath deepseek.apk -DestinationPath $env:TEMP\apkext -Force
# 2) 提取所有 URL / 端点字符串(本次得到 206 条,无 refresh 端点)
# 见 §8.3 的脚本思路:对 *.dex 做 printable-run 提取 + 过滤 '/api/' | 'http'
# 3) 定位含某字符串的类与方法
# 解析 DEX 头 → string_ids / method_ids / class_defs / class_data_item,
# 扫 code_item 的 const-string(0x1a) / const-string/jumbo(0x1b) 操作数
# 4) 定位 cross-reference(谁构造了 CheckDeviceRotateData)
# 按 opcode 宽度表遍历 code_item,命中 invoke-*(0x6e-0x72,0x74-0x78)
# 的 method_ids 索引是否指向目标类
```
反编译 APK 的建议(避免踩本机内存坑):
```powershell
# jadx.bat 的 APP_HOME 解析有 bug(会去 D:\lib\... 找 jar),直接用 java 调用:
java -Xms64m -Xmx1100m -XX:MaxMetaspaceSize=320m -XX:ReservedCodeCacheSize=96m `
-XX:+UseSerialGC -XX:+IgnoreUnrecognizedVMOptions `
-cp "D:\apktool\jadx-1.5.3-all.jar" jadx.cli.JadxCLI `
-d "$env:TEMP\apk_jadx" --no-res -j 2 "deepseek.apk"
```
> 注意:`deepseek.apk` 21MB、`classes.dex` 12MB、`defpackage` 下 8500+ 混淆类,
> 全量反编译约需 1.5–2GB 堆 + 3GB 提交内存。内存不足时 jadx 会报
> `Native memory allocation (mmap) failed`,此时要么加页面文件,要么只做 §8.2 的
> 定点 DEX 解析(本次结论主要靠后者得到)。
---
## 九、实现落地(阶段 1)
调研结论是「DeepSeek 没有可刷新的对象,只有失效后的恢复」。按这个结论落地后,
「刷新 token」从「必发密码」变成「先免费探针、明确失效才登录」。
### 9.1 改动清单
| 文件 | 内容 |
|------|------|
| `internal/deepseek/protocol/constants.go` | 新增 `DeepSeekCurrentUserURL` |
| `internal/deepseek/client/client_user_session.go` | 新增 `RefreshSessionToken()`:带现有 token GET `users/current`,取 `data.biz_data.token`(兼容 `biz_data.user.token`) |
| `internal/auth/request.go` | 新增 `RefreshSessionFunc` 注入点与 `ErrSessionExpired`;`RefreshToken()` / `ensureManagedToken()` 改为「探针优先」 |
| `internal/server/router.go` | 把 `Client.RefreshSessionToken` 注入 `Resolver.RefreshSession` |
### 9.2 三种探针结果,三种处置
判定刻意收窄,避免把限流、风控、临时故障误判成「必须重新登录」:
| 探针结果 | 判定依据 | 定时路径(§2.3) | 失败恢复路径(§2.2) |
|----------|----------|------------------|----------------------|
| 会话有效,回显同一 token | `status=200`、`code=0`、`biz_code=0` 且有 token | 只更新体检时间戳,不写盘 | 直接返回成功,重试原请求 |
| 服务端轮换了 token | 同上,但 token 与本地不同 | 落盘并采纳新 token | 落盘并采纳新 token |
| 会话被明确拒绝 | `401`/`403` 或 `code`/`biz_code` ∈ {40001, 40002, 40003} | 清空 token → 密码登录 | 清空 token → 密码登录 |
| 无法判定 | 网络错误、5xx、未知 biz_code、响应缺 token | **保留现有 token,不登录** | 保留 token,仍兜底密码登录(历史行为) |
两个调用点策略不同的原因:
- **定时路径**手里是一个还能用的 token,「一次网络抖动就提交密码」是净损失,
所以探测失败宁可什么都不做,等下一个请求用失败路径去处理。
- **失败恢复路径**是请求已经吃到鉴权类错误、正在重试,宁可多登录一次也不能把
本来可恢复的请求判死;探针的作用是挡掉 `isTokenInvalid()` 的宽匹配误判
(消息里出现 `token` / `login` 就算鉴权失败)。
### 9.3 行为变化
| 场景 | 改动前 | 改动后 |
|------|--------|--------|
| 跑满 6h(默认间隔) | 每账号一次密码登录 + 数美风控 | 一次 `GET users/current`,通常不产生任何写盘 |
| 请求被误判为鉴权失败 | 清空 token + 密码登录 | 探针确认有效即继续,不登录 |
| token 真的被吊销 | 密码登录 | 探针 401 → 密码登录(同上,但只在真失效时发生) |
| 探针未注入(测试/自定义装配) | — | 保持历史行为:直接密码登录 |
### 9.4 本次没做的事
- **阶段 2(`check_device` 的 rotate)**:抓包里始终 `null`,Android 侧证据链未闭合
(§7.1),收益不确定,暂不接入。
- **阶段 3 的重试计数**:现有 `refreshed` 布尔量仍按「每个请求生命周期每级最多一次」
工作,未扩成枚举,等阶段 2 接入时再一并处理。
- 保留 `token_refresh_interval_hours` 配置与默认 6h,语义变为「会话体检间隔」;
想彻底关掉可以设为 0(`shouldForceRefresh` 直接返回 false)。
### 9.5 线上实测(2026-10-18,单账号单设备)
用一个临时账号、固定 `device_id`、android 平台直接跑新代码,结果如下:
| 检查项 | 实测结果 |
|--------|----------|
| `GET users/current` 回填 | 连续 3 次全部 `200`,返回的 token 与登录 token **完全一致** |
| 探针耗时 | 约 150–165 ms(对比:一次密码登录要多走数美 + 登录两步) |
| 用无效 token 打探针 | `HTTP 200` + `code=40003` + `msg="Authorization Failed (invalid token)"` |
| 失效判定 | 被正确归类为「会话失效」而不是「无法判定」 |
| 刷新链(有效 token) | `RefreshToken` 成功,密码登录次数 **0** |
| 刷新链(失效 token) | 探针失败 → 清空 → 密码登录 **1** 次 → 恢复 |
两个推论,都影响本文之前的判断:
1. **鉴权失败不一定走 HTTP 401。** 实测是 `HTTP 200 + code=40003`。
所以「只按状态码判断」的探针会漏判,`sessionRejectedStatus()` 必须同时看
`code`/`biz_code`(这也解释了为什么不能直接复用宽匹配的 `isTokenInvalid()`:
那个函数还会把消息里带 `token` 字样的非鉴权错误一起算进来)。
2. **§4.4 里「同一 device_id 反复登录返回同一个 token」这一行是错的。**
实测每次密码登录都会下发**新的** token,而**旧 token 继续有效**
(重新登录后再拿旧 token 打 `users/current` 仍 `200`)。
也就是说:每次重新登录都在账号上多留下一个仍然可用的长期会话凭证。
这比 §3 原先担心的「密码出网」更进一步——高频重登不仅暴露密码,
还在持续堆积可用的会话。