luckfun233 commited on
Commit ·
3df9d54
1
Parent(s): 1a72a9a
fix(auth): token 刷新改为无密码会话探针,密码登录仅作兜底
Browse filesDeepSeek 的 token 是按设备绑定的长期会话凭证,不存在 access/refresh
两段式机制,也没有可换新的端点;原来的「刷新」实际是每 6h(以及每次
鉴权类失败)提交一次明文密码并过一次数美风控。
- 新增 Client.RefreshSessionToken:GET /api/v0/users/current 回填会话 token
- Resolver 刷新链改为探针优先,只有服务端明确拒绝会话才回退密码登录;
探针无法判定时,定时路径保留现有 token,失败恢复路径仍兜底登录
- 失效判定收窄到 401/403 与 code/biz_code 40001-40003(实测失效响应是
HTTP 200 + code=40003,只按状态码会漏判)
- 未注入探针时保持历史行为,测试与自定义装配不受影响
- 文档:research 文档补落地细节与线上实测,security 报告与 API.md 同步配置语义
- API.en.md +1 -0
- API.md +1 -0
- docs/token-refresh-research.md +97 -9
- internal/auth/request.go +107 -8
- internal/auth/session_probe_test.go +235 -0
- internal/deepseek/client/client_user_session.go +88 -0
- internal/deepseek/client/client_user_session_test.go +89 -0
- internal/deepseek/protocol/constants.go +1 -0
- internal/server/router.go +5 -0
- security_best_practices_report.md +1 -1
API.en.md
CHANGED
|
@@ -766,6 +766,7 @@ Reads runtime settings and status, including:
|
|
| 766 |
- `success`
|
| 767 |
- `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`)
|
| 768 |
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`)
|
|
|
|
| 769 |
- `config_snapshot`: raw config values (`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `model_aliases`) before env overrides
|
| 770 |
- `responses` / `embeddings`
|
| 771 |
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
|
|
|
|
| 766 |
- `success`
|
| 767 |
- `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`)
|
| 768 |
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`)
|
| 769 |
+
- `runtime.token_refresh_interval_hours` is a session health-check interval: on expiry the gateway performs a password-free check (`GET /api/v0/users/current`) and only falls back to a password login when the server explicitly rejects the session
|
| 770 |
- `config_snapshot`: raw config values (`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `model_aliases`) before env overrides
|
| 771 |
- `responses` / `embeddings`
|
| 772 |
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
|
API.md
CHANGED
|
@@ -772,6 +772,7 @@ data: {"type":"message_stop"}
|
|
| 772 |
- `success`
|
| 773 |
- `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`)
|
| 774 |
- `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`)
|
|
|
|
| 775 |
- `config_snapshot`:原始配置快照(`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `model_aliases`),用于区分环境变量覆盖前的配置
|
| 776 |
- `responses` / `embeddings`
|
| 777 |
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
|
|
|
|
| 772 |
- `success`
|
| 773 |
- `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`)
|
| 774 |
- `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`)
|
| 775 |
+
- `runtime.token_refresh_interval_hours` 的语义是「会话体检间隔」:到期只做一次无密码复核(`GET /api/v0/users/current`),只有服务端明确拒绝会话时才回退到密码登录
|
| 776 |
- `config_snapshot`:原始配置快照(`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `model_aliases`),用于区分环境变量覆盖前的配置
|
| 777 |
- `responses` / `embeddings`
|
| 778 |
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
|
docs/token-refresh-research.md
CHANGED
|
@@ -4,16 +4,17 @@
|
|
| 4 |
> DeepSeek 官方客户端是否存在真正的 token 刷新(refresh / rotate)通道。
|
| 5 |
> 研究时间:2026-10-17
|
| 6 |
> 证据来源:`chat.deepseek.com (1)/`(Web 端完整抓包)、根目录若干 `.har`、`deepseek.apk`(Android 2.2.2 / 2.3.3)
|
| 7 |
-
> 状态:**调研结论已明确;
|
| 8 |
|
| 9 |
---
|
| 10 |
|
| 11 |
## 一、结论摘要(TL;DR)
|
| 12 |
|
| 13 |
-
1. **
|
| 14 |
清空本地 token 后走 `loginAndPersist()` → `Client.Login()` →
|
| 15 |
`POST /api/v0/users/login`,也就是**每次都把邮箱/手机号 + 明文密码重新提交一次**。
|
| 16 |
定时刷新(默认 6h)也一样走这条路径。
|
|
|
|
| 17 |
|
| 18 |
2. **DeepSeek 没有「access_token + refresh_token」那套机制。** 我们在
|
| 19 |
- Web 端 JS bundle(`main.7d19d7e901.js`)
|
|
@@ -43,6 +44,9 @@
|
|
| 43 |
|
| 44 |
## 二、现状:刷新 == 重新登录(代码事实)
|
| 45 |
|
|
|
|
|
|
|
|
|
|
| 46 |
### 2.1 调用链
|
| 47 |
|
| 48 |
```
|
|
@@ -91,6 +95,9 @@ Client.Login(ctx, acc) internal/deepseek/client/cli
|
|
| 91 |
|
| 92 |
也就是说:**只要服务跑满 6 小时,每个托管账号就必然重新登录一次**,哪怕 token 完全没坏。
|
| 93 |
|
|
|
|
|
|
|
|
|
|
| 94 |
### 2.4 `MarkTokenInvalid` 也是清空
|
| 95 |
|
| 96 |
`internal/auth/request.go:214` 把 token 清空落盘,下一次 `ensureManagedToken` 会再登录一次。
|
|
@@ -224,8 +231,8 @@ Android 端不是这样。APK 里有显式的模型类(`classes.dex` 字符串
|
|
| 224 |
| 观察 | 结论 |
|
| 225 |
|------|------|
|
| 226 |
| 形如 `<44 字符随机段><20 字符随机段>`(含 `+` `/`,共 64 字符) | 64 字符 base64,**不是 JWT**(无 `.` 分段,无 `exp` 可解析) |
|
| 227 |
-
| 同一账号 + 同一 `device_id` 反复登录 | **返回同一个 token** |
|
| 228 |
-
| `users/current` 响应 | 回显**同一个 token** |
|
| 229 |
| 不同平台 / 不同 `device_id` | token 不同(`os=web` 与 `os=android` 互不相同) |
|
| 230 |
|
| 231 |
所以 DeepSeek 是「**服务端长期会话 token,按设备绑定**」模型,
|
|
@@ -233,7 +240,7 @@ Android 端不是这样。APK 里有显式的模型类(`classes.dex` 字符串
|
|
| 233 |
|
| 234 |
- 没有「token 快过期了所以必须刷新」这回事 → **定时 6h 强制重登在语义上没有必要**;
|
| 235 |
- 真正需要处理的只有两类事件:
|
| 236 |
-
1. **token 被服务端吊销/轮换** → `users/current` 可能回填到新值,或直接
|
| 237 |
2. **会话彻底死了** → 才需要密码登录。
|
| 238 |
|
| 239 |
---
|
|
@@ -248,7 +255,7 @@ Android 端不是这样。APK 里有显式的模型类(`classes.dex` 字符串
|
|
| 248 |
|
| 249 |
### 5.2 分阶段实现
|
| 250 |
|
| 251 |
-
**阶段 1(低风险,收益最大):新增 `GET /api/v0/users/current` 回填**
|
| 252 |
|
| 253 |
- 在 `dsprotocol` 增加 `DeepSeekCurrentUserURL = ".../api/v0/users/current"`。
|
| 254 |
- `Client` 新增 `RefreshSessionToken(ctx, a) (string, error)`:
|
|
@@ -289,9 +296,9 @@ users/current 回填 → check_device rotate → 密码登录 → 切换
|
|
| 289 |
|
| 290 |
## 六、与现有文档的关系
|
| 291 |
|
| 292 |
-
- `security_best_practices_report.md:171` 建议「定期刷新 token(已有
|
| 293 |
-
`TokenRefreshIntervalHours` 配置)」。本文确认该配置**
|
| 294 |
-
|
| 295 |
- `docs/web-endpoint-upgrade.md` 记录的是登录协议的数美风控细节,与本文互补:
|
| 296 |
那条链路越少走越好,正是本文的动机。
|
| 297 |
|
|
@@ -372,3 +379,84 @@ java -Xms64m -Xmx1100m -XX:MaxMetaspaceSize=320m -XX:ReservedCodeCacheSize=96m `
|
|
| 372 |
> 全量反编译约需 1.5–2GB 堆 + 3GB 提交内存。内存不足时 jadx 会报
|
| 373 |
> `Native memory allocation (mmap) failed`,此时要么加页面文件,要么只做 §8.2 的
|
| 374 |
> 定点 DEX 解析(本次结论主要靠后者得到)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
> DeepSeek 官方客户端是否存在真正的 token 刷新(refresh / rotate)通道。
|
| 5 |
> 研究时间:2026-10-17
|
| 6 |
> 证据来源:`chat.deepseek.com (1)/`(Web 端完整抓包)、根目录若干 `.har`、`deepseek.apk`(Android 2.2.2 / 2.3.3)
|
| 7 |
+
> 状态:**调研结论已明确**;阶段 1 已落地(§9),并有单账号线上实测(§9.5)
|
| 8 |
|
| 9 |
---
|
| 10 |
|
| 11 |
## 一、结论摘要(TL;DR)
|
| 12 |
|
| 13 |
+
1. **改动前的「刷新 token」等于「重新登录」。** `Resolver.RefreshToken()`
|
| 14 |
清空本地 token 后走 `loginAndPersist()` → `Client.Login()` →
|
| 15 |
`POST /api/v0/users/login`,也就是**每次都把邮箱/手机号 + 明文密码重新提交一次**。
|
| 16 |
定时刷新(默认 6h)也一样走这条路径。
|
| 17 |
+
(已改:现在是「无密码探针优先,明确失效才登录」,见 §9。)
|
| 18 |
|
| 19 |
2. **DeepSeek 没有「access_token + refresh_token」那套机制。** 我们在
|
| 20 |
- Web 端 JS bundle(`main.7d19d7e901.js`)
|
|
|
|
| 44 |
|
| 45 |
## 二、现状:刷新 == 重新登录(代码事实)
|
| 46 |
|
| 47 |
+
> 本节记录的是**改动前**的代码事实(行号同当时版本)。
|
| 48 |
+
> 改动后的实现见 §9:`RefreshToken()` 现在先走无密码探针,密码登录只是兜底。
|
| 49 |
+
|
| 50 |
### 2.1 调用链
|
| 51 |
|
| 52 |
```
|
|
|
|
| 95 |
|
| 96 |
也就是说:**只要服务跑满 6 小时,每个托管账号就必然重新登录一次**,哪怕 token 完全没坏。
|
| 97 |
|
| 98 |
+
> 已修复:本节描述的是调研时的实现。现在到期只做一次无密码探针复核,
|
| 99 |
+
> 只有服务端明确拒绝会话才回退到密码登录,见 §9。
|
| 100 |
+
|
| 101 |
### 2.4 `MarkTokenInvalid` 也是清空
|
| 102 |
|
| 103 |
`internal/auth/request.go:214` 把 token 清空落盘,下一次 `ensureManagedToken` 会再登录一次。
|
|
|
|
| 231 |
| 观察 | 结论 |
|
| 232 |
|------|------|
|
| 233 |
| 形如 `<44 字符随机段><20 字符随机段>`(含 `+` `/`,共 64 字符) | 64 字符 base64,**不是 JWT**(无 `.` 分段,无 `exp` 可解析) |
|
| 234 |
+
| 同一账号 + 同一 `device_id` 反复登录 | **返回同一个 token** · ⚠️ 该行已被 §9.5 的线上实测推翻 |
|
| 235 |
+
| `users/current` 响应 | 回显**同一个 token**(线上实测仍成立) |
|
| 236 |
| 不同平台 / 不同 `device_id` | token 不同(`os=web` 与 `os=android` 互不相同) |
|
| 237 |
|
| 238 |
所以 DeepSeek 是「**服务端长期会话 token,按设备绑定**」模型,
|
|
|
|
| 240 |
|
| 241 |
- 没有「token 快过期了所以必须刷新」这回事 → **定时 6h 强制重登在语义上没有必要**;
|
| 242 |
- 真正需要处理的只有两类事件:
|
| 243 |
+
1. **token 被服务端吊销/轮换** → `users/current` 可能回填到新值,或直接报鉴权失败;
|
| 244 |
2. **会话彻底死了** → 才需要密码登录。
|
| 245 |
|
| 246 |
---
|
|
|
|
| 255 |
|
| 256 |
### 5.2 分阶段实现
|
| 257 |
|
| 258 |
+
**阶段 1(低风险,收益最大):新增 `GET /api/v0/users/current` 回填 —— ✅ 已实现(见 §9)**
|
| 259 |
|
| 260 |
- 在 `dsprotocol` 增加 `DeepSeekCurrentUserURL = ".../api/v0/users/current"`。
|
| 261 |
- `Client` 新增 `RefreshSessionToken(ctx, a) (string, error)`:
|
|
|
|
| 296 |
|
| 297 |
## 六、与现有文档的关系
|
| 298 |
|
| 299 |
+
- `security_best_practices_report.md:171` 曾建议「定期刷新 token(已有
|
| 300 |
+
`TokenRefreshIntervalHours` 配置)」。本文确认该配置**旧语义是定期重新登录**;
|
| 301 |
+
该条目已随 §9 的改动同步为「定期做无密码探针复核」。
|
| 302 |
- `docs/web-endpoint-upgrade.md` 记录的是登录协议的数美风控细节,与本文互补:
|
| 303 |
那条链路越少走越好,正是本文的动机。
|
| 304 |
|
|
|
|
| 379 |
> 全量反编译约需 1.5–2GB 堆 + 3GB 提交内存。内存不足时 jadx 会报
|
| 380 |
> `Native memory allocation (mmap) failed`,此时要么加页面文件,要么只做 §8.2 的
|
| 381 |
> 定点 DEX 解析(本次结论主要靠后者得到)。
|
| 382 |
+
|
| 383 |
+
---
|
| 384 |
+
|
| 385 |
+
## 九、实现落地(阶段 1���
|
| 386 |
+
|
| 387 |
+
调研结论是「DeepSeek 没有可刷新的对象,只有失效后的恢复」。按这个结论落地后,
|
| 388 |
+
「刷新 token」从「必发密码」变成「先免费探针、明确失效才登录」。
|
| 389 |
+
|
| 390 |
+
### 9.1 改动清单
|
| 391 |
+
|
| 392 |
+
| 文件 | 内容 |
|
| 393 |
+
|------|------|
|
| 394 |
+
| `internal/deepseek/protocol/constants.go` | 新增 `DeepSeekCurrentUserURL` |
|
| 395 |
+
| `internal/deepseek/client/client_user_session.go` | 新增 `RefreshSessionToken()`:带现有 token GET `users/current`,取 `data.biz_data.token`(兼容 `biz_data.user.token`) |
|
| 396 |
+
| `internal/auth/request.go` | 新增 `RefreshSessionFunc` 注入点与 `ErrSessionExpired`;`RefreshToken()` / `ensureManagedToken()` 改为「探针优先」 |
|
| 397 |
+
| `internal/server/router.go` | 把 `Client.RefreshSessionToken` 注入 `Resolver.RefreshSession` |
|
| 398 |
+
|
| 399 |
+
### 9.2 三种探针结果,三种处置
|
| 400 |
+
|
| 401 |
+
判定刻意收窄,避免把限流、风控、临时故障误判成「必须重新登录」:
|
| 402 |
+
|
| 403 |
+
| 探针结果 | 判定依据 | 定时路径(§2.3) | 失败恢复路径(§2.2) |
|
| 404 |
+
|----------|----------|------------------|----------------------|
|
| 405 |
+
| 会话有效,回显同一 token | `status=200`、`code=0`、`biz_code=0` 且有 token | 只更新体检时间戳,不写盘 | 直接返回成功,重试原请求 |
|
| 406 |
+
| 服务端轮换了 token | 同上,但 token 与本地不同 | 落盘并采纳新 token | 落盘并采纳新 token |
|
| 407 |
+
| 会话被明确拒绝 | `401`/`403` 或 `code`/`biz_code` ∈ {40001, 40002, 40003} | 清空 token → 密码登录 | 清空 token → 密码登录 |
|
| 408 |
+
| 无法判定 | 网络错误、5xx、未知 biz_code、响应缺 token | **保留现有 token,不登录** | 保留 token,仍兜底密码登录(历史行为) |
|
| 409 |
+
|
| 410 |
+
两个调用点策略不同的原因:
|
| 411 |
+
|
| 412 |
+
- **定时路径**手里是一个还能用的 token,「一次网络抖动就提交密码」是净损失,
|
| 413 |
+
所以探测失败宁可什么都不做,等下一个请求用失败路径去处理。
|
| 414 |
+
- **失败恢复路径**是请求已经吃到鉴权类错误、正在重试,宁可多登录一次也不能把
|
| 415 |
+
本来可恢复的请求判死;探针的作用是挡掉 `isTokenInvalid()` 的宽匹配误判
|
| 416 |
+
(消息里出现 `token` / `login` 就算鉴权失败)。
|
| 417 |
+
|
| 418 |
+
### 9.3 行为变化
|
| 419 |
+
|
| 420 |
+
| 场景 | 改动前 | 改动后 |
|
| 421 |
+
|------|--------|--------|
|
| 422 |
+
| 跑满 6h(默认间隔) | 每账号一次密码登录 + 数美风控 | 一次 `GET users/current`,通常不产生任何写盘 |
|
| 423 |
+
| 请求被误判为鉴权失败 | 清空 token + 密码登录 | 探针确认有效即继续,不登录 |
|
| 424 |
+
| token 真的被吊销 | 密码登录 | 探针 401 → 密码登录(同上,但只在真失效时发生) |
|
| 425 |
+
| 探针未注入(测试/自定义装配) | — | 保持历史行为:直接密码登录 |
|
| 426 |
+
|
| 427 |
+
### 9.4 本次没做的事
|
| 428 |
+
|
| 429 |
+
- **阶段 2(`check_device` 的 rotate)**:抓包里始终 `null`,Android 侧证据链未闭合
|
| 430 |
+
(§7.1),收益不确定,暂不接入。
|
| 431 |
+
- **阶段 3 的重试计数**:现有 `refreshed` 布尔量仍按「每个请求生命周期每级最多一次」
|
| 432 |
+
工作,未扩成枚举,等阶段 2 接入时再一并处理。
|
| 433 |
+
- 保留 `token_refresh_interval_hours` 配置与默认 6h,语义变为「会话体检间隔」;
|
| 434 |
+
想彻底关掉可以设为 0(`shouldForceRefresh` 直接返回 false)。
|
| 435 |
+
|
| 436 |
+
### 9.5 线上实测(2026-10-18,单账号单设备)
|
| 437 |
+
|
| 438 |
+
用一个临时账号、固定 `device_id`、android 平台直接跑新代码,结果如下:
|
| 439 |
+
|
| 440 |
+
| 检查项 | 实测结果 |
|
| 441 |
+
|--------|----------|
|
| 442 |
+
| `GET users/current` 回填 | 连续 3 次全部 `200`,返回的 token 与登录 token **完全一致** |
|
| 443 |
+
| 探针耗时 | 约 150–165 ms(对比:一次密码登录要多走数美 + 登录两步) |
|
| 444 |
+
| 用无效 token 打探针 | `HTTP 200` + `code=40003` + `msg="Authorization Failed (invalid token)"` |
|
| 445 |
+
| 失效判定 | 被正确归类为「会话失效」而不是「无法判定」 |
|
| 446 |
+
| 刷新链(有效 token) | `RefreshToken` 成功,密码登录次数 **0** |
|
| 447 |
+
| 刷新链(失效 token) | 探针失败 → 清空 → 密码登录 **1** 次 → 恢复 |
|
| 448 |
+
|
| 449 |
+
两个推论,都影响本文之前的判断:
|
| 450 |
+
|
| 451 |
+
1. **鉴权失败不一定走 HTTP 401。** 实测是 `HTTP 200 + code=40003`。
|
| 452 |
+
所以「只按状态码判断」的探针会漏判,`sessionRejectedStatus()` 必须同时看
|
| 453 |
+
`code`/`biz_code`(这也解释了为什么不能直接复用宽匹配的 `isTokenInvalid()`:
|
| 454 |
+
那个函数还会把消息里带 `token` 字样的非鉴权错误一起算进来)。
|
| 455 |
+
2. **§4.4 里「同一 device_id 反复登录返回同一个 token」这一行是错的。**
|
| 456 |
+
实测每次密码登录都会下发**新的** token,而**旧 token 继续有效**
|
| 457 |
+
(重新登录后再拿旧 token 打 `users/current` 仍 `200`)。
|
| 458 |
+
也就是说:每次重新登录都在账号上多留下一个仍然可用的长期会话凭证。
|
| 459 |
+
这比 §3 原先担心的「密码出网」更进一步——高频重登不仅暴露密码,
|
| 460 |
+
还在持续堆积可用的会话。
|
| 461 |
+
|
| 462 |
+
|
internal/auth/request.go
CHANGED
|
@@ -22,6 +22,14 @@ const authCtxKey ctxKey = "auth_context"
|
|
| 22 |
var (
|
| 23 |
ErrUnauthorized = errors.New("unauthorized: missing auth token")
|
| 24 |
ErrNoAccount = errors.New("no accounts configured or all accounts are busy")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
type RequestAuth struct {
|
|
@@ -37,10 +45,18 @@ type RequestAuth struct {
|
|
| 37 |
|
| 38 |
type LoginFunc func(ctx context.Context, acc config.Account) (string, error)
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
type Resolver struct {
|
| 41 |
Store *config.Store
|
| 42 |
Pool *account.Pool
|
| 43 |
Login LoginFunc
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
mu sync.Mutex
|
| 46 |
tokenRefreshedAt map[string]time.Time
|
|
@@ -198,19 +214,81 @@ func (r *Resolver) handleBannedAccount(accountID string) {
|
|
| 198 |
}
|
| 199 |
}
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
|
| 202 |
if !a.UseConfigToken || a.AccountID == "" {
|
| 203 |
return false
|
| 204 |
}
|
| 205 |
-
|
| 206 |
-
a.Account.Token = ""
|
| 207 |
-
if err := r.loginAndPersist(ctx, a); err != nil {
|
| 208 |
config.Logger.Error("[refresh_token] failed", "account", a.AccountID, "error", err)
|
| 209 |
return false
|
| 210 |
}
|
| 211 |
return true
|
| 212 |
}
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
func (r *Resolver) MarkTokenInvalid(a *RequestAuth) {
|
| 215 |
if !a.UseConfigToken || a.AccountID == "" {
|
| 216 |
return
|
|
@@ -302,15 +380,36 @@ func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error
|
|
| 302 |
return r.loginAndPersist(ctx, a)
|
| 303 |
}
|
| 304 |
if r.shouldForceRefresh(a.AccountID) {
|
| 305 |
-
if err := r.
|
| 306 |
return err
|
| 307 |
}
|
| 308 |
-
return nil
|
| 309 |
}
|
| 310 |
a.DeepSeekToken = a.Account.Token
|
| 311 |
return nil
|
| 312 |
}
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
func (r *Resolver) shouldForceRefresh(accountID string) bool {
|
| 315 |
if r == nil || r.Store == nil {
|
| 316 |
return false
|
|
@@ -330,8 +429,8 @@ func (r *Resolver) shouldForceRefresh(accountID string) bool {
|
|
| 330 |
r.tokenRefreshedAt[accountID] = now
|
| 331 |
return false
|
| 332 |
}
|
| 333 |
-
// Add jitter: ±
|
| 334 |
-
// For a 6h interval, this gives a range of ~
|
| 335 |
baseInterval := time.Duration(intervalHours) * time.Hour
|
| 336 |
jitterRange := baseInterval / 10 // ±10% → total range is 80%-120% of base
|
| 337 |
jitter := time.Duration(rand.Int63n(int64(2*jitterRange+1))) - jitterRange
|
|
@@ -348,7 +447,7 @@ func (r *Resolver) markTokenRefreshedNow(accountID string) {
|
|
| 348 |
}
|
| 349 |
// Add a random offset (0-30min) to spread out future refreshes across accounts.
|
| 350 |
// This prevents all accounts from refreshing at the same wall-clock time.
|
| 351 |
-
offset := time.Duration(rand.Int63n(int64(30*time.Minute)))
|
| 352 |
r.mu.Lock()
|
| 353 |
defer r.mu.Unlock()
|
| 354 |
r.tokenRefreshedAt[accountID] = time.Now().Add(-offset)
|
|
|
|
| 22 |
var (
|
| 23 |
ErrUnauthorized = errors.New("unauthorized: missing auth token")
|
| 24 |
ErrNoAccount = errors.New("no accounts configured or all accounts are busy")
|
| 25 |
+
|
| 26 |
+
// ErrSessionExpired 表示 DeepSeek 明确拒绝了这个会话 token(401/403 或鉴权类业务码)。
|
| 27 |
+
// 只有这种情况才值得清空本地 token 并回退到密码登录。
|
| 28 |
+
ErrSessionExpired = errors.New("deepseek session rejected")
|
| 29 |
+
|
| 30 |
+
// errSessionProbeUnavailable 表示当前没有可用的无密码探针:未注入探针函数,
|
| 31 |
+
// 或本地还没有 token 可以拿去做探测。
|
| 32 |
+
errSessionProbeUnavailable = errors.New("session probe unavailable")
|
| 33 |
)
|
| 34 |
|
| 35 |
type RequestAuth struct {
|
|
|
|
| 45 |
|
| 46 |
type LoginFunc func(ctx context.Context, acc config.Account) (string, error)
|
| 47 |
|
| 48 |
+
// RefreshSessionFunc 是不需要密码的会话回填通道:带现有 token 请求
|
| 49 |
+
// GET /api/v0/users/current,返回服务端认定的当前 token。
|
| 50 |
+
// 明确失效时返回包装了 ErrSessionExpired 的错误,其余错误表示无法判定。
|
| 51 |
+
type RefreshSessionFunc func(ctx context.Context, acc config.Account) (string, error)
|
| 52 |
+
|
| 53 |
type Resolver struct {
|
| 54 |
Store *config.Store
|
| 55 |
Pool *account.Pool
|
| 56 |
Login LoginFunc
|
| 57 |
+
// RefreshSession 为可选的轻量续期通道;未注入时所有刷新都会回退到密码登录
|
| 58 |
+
// (与历史行为一致)。
|
| 59 |
+
RefreshSession RefreshSessionFunc
|
| 60 |
|
| 61 |
mu sync.Mutex
|
| 62 |
tokenRefreshedAt map[string]time.Time
|
|
|
|
| 214 |
}
|
| 215 |
}
|
| 216 |
|
| 217 |
+
// RefreshToken 由鉴权类失败触发。DeepSeek 的 token 是按设备绑定的长期会话凭证,
|
| 218 |
+
// 不存在“旧 token 换新 token”的端点,所以这里先用不带密码的 users/current 探针复核:
|
| 219 |
+
// 探针确认会话仍有效就直接沿用(顺带收下服务端轮换的新值),只有服务端明确拒绝会话
|
| 220 |
+
// 或探针本身不可用时才回退到密码登录。这样既能挡住把非鉴权错误误判成登录失效的情况,
|
| 221 |
+
// 又保留了历史行为里的恢复能力。
|
| 222 |
func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
|
| 223 |
if !a.UseConfigToken || a.AccountID == "" {
|
| 224 |
return false
|
| 225 |
}
|
| 226 |
+
if err := r.refreshSessionReactive(ctx, a); err != nil {
|
|
|
|
|
|
|
| 227 |
config.Logger.Error("[refresh_token] failed", "account", a.AccountID, "error", err)
|
| 228 |
return false
|
| 229 |
}
|
| 230 |
return true
|
| 231 |
}
|
| 232 |
|
| 233 |
+
// refreshSessionReactive 处理“请求已经拿到鉴权类失败”的场景:探针能证明会话有效就
|
| 234 |
+
// 直接返回;否则按历史行为兜底到密码登录(探针不可判定时不清空 token,登录失败还能
|
| 235 |
+
// 留着下次再试)。
|
| 236 |
+
func (r *Resolver) refreshSessionReactive(ctx context.Context, a *RequestAuth) error {
|
| 237 |
+
err := r.probeSessionToken(ctx, a)
|
| 238 |
+
if err == nil {
|
| 239 |
+
return nil
|
| 240 |
+
}
|
| 241 |
+
if errors.Is(err, ErrSessionExpired) {
|
| 242 |
+
// 会话确认失效:清掉落盘值,避免后续请求继续复用死 token。
|
| 243 |
+
_ = r.Store.UpdateAccountToken(a.AccountID, "")
|
| 244 |
+
a.Account.Token = ""
|
| 245 |
+
a.DeepSeekToken = ""
|
| 246 |
+
} else {
|
| 247 |
+
config.Logger.Warn("[refresh_token] session probe inconclusive, falling back to login",
|
| 248 |
+
"account", a.AccountID, "error", err)
|
| 249 |
+
}
|
| 250 |
+
return r.loginAndPersist(ctx, a)
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// probeSessionToken 用无密码通道复核当前 token,成功时把服务端值回写内存与配置。
|
| 254 |
+
func (r *Resolver) probeSessionToken(ctx context.Context, a *RequestAuth) error {
|
| 255 |
+
if r == nil || r.RefreshSession == nil {
|
| 256 |
+
return errSessionProbeUnavailable
|
| 257 |
+
}
|
| 258 |
+
if a == nil || a.AccountID == "" {
|
| 259 |
+
return errSessionProbeUnavailable
|
| 260 |
+
}
|
| 261 |
+
current := strings.TrimSpace(a.Account.Token)
|
| 262 |
+
if current == "" {
|
| 263 |
+
return errSessionProbeUnavailable
|
| 264 |
+
}
|
| 265 |
+
token, err := r.RefreshSession(ctx, a.Account)
|
| 266 |
+
if err != nil {
|
| 267 |
+
return err
|
| 268 |
+
}
|
| 269 |
+
token = strings.TrimSpace(token)
|
| 270 |
+
if token == "" {
|
| 271 |
+
return errors.New("session probe returned empty token")
|
| 272 |
+
}
|
| 273 |
+
a.Account.Token = token
|
| 274 |
+
a.DeepSeekToken = token
|
| 275 |
+
r.markTokenRefreshedNow(a.AccountID)
|
| 276 |
+
// 以配置存储中的值为准判断是否需要写盘:池里的 Account 可能是较早的快照。
|
| 277 |
+
persisted := current
|
| 278 |
+
if fresh, ok := r.Store.FindAccount(a.AccountID); ok {
|
| 279 |
+
persisted = strings.TrimSpace(fresh.Token)
|
| 280 |
+
}
|
| 281 |
+
if token == persisted {
|
| 282 |
+
// 服务端回显同一 token(抓包与线上实测观察到的主要形态):无需写盘。
|
| 283 |
+
return nil
|
| 284 |
+
}
|
| 285 |
+
config.Logger.Info("[refresh_token] persisted session token from probe", "account", a.AccountID)
|
| 286 |
+
if err := r.Store.UpdateAccountToken(a.AccountID, token); err != nil {
|
| 287 |
+
config.Logger.Warn("[refresh_token] persist rotated token failed", "account", a.AccountID, "error", err)
|
| 288 |
+
}
|
| 289 |
+
return nil
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
func (r *Resolver) MarkTokenInvalid(a *RequestAuth) {
|
| 293 |
if !a.UseConfigToken || a.AccountID == "" {
|
| 294 |
return
|
|
|
|
| 380 |
return r.loginAndPersist(ctx, a)
|
| 381 |
}
|
| 382 |
if r.shouldForceRefresh(a.AccountID) {
|
| 383 |
+
if err := r.refreshSessionProactively(ctx, a); err != nil {
|
| 384 |
return err
|
| 385 |
}
|
|
|
|
| 386 |
}
|
| 387 |
a.DeepSeekToken = a.Account.Token
|
| 388 |
return nil
|
| 389 |
}
|
| 390 |
|
| 391 |
+
// refreshSessionProactively 是定时任务的“体检”:到期时先做一次免费探针,
|
| 392 |
+
// 只有会话被明确拒绝(或根本没有探针可用)才回退到密码登录;
|
| 393 |
+
// 探针结果不可判定时保留现有 token,不因为一次网络抖动就提交密码。
|
| 394 |
+
func (r *Resolver) refreshSessionProactively(ctx context.Context, a *RequestAuth) error {
|
| 395 |
+
err := r.probeSessionToken(ctx, a)
|
| 396 |
+
if err == nil {
|
| 397 |
+
return nil
|
| 398 |
+
}
|
| 399 |
+
if !errors.Is(err, ErrSessionExpired) && !errors.Is(err, errSessionProbeUnavailable) {
|
| 400 |
+
config.Logger.Warn("[token_refresh] session probe inconclusive, keeping current token",
|
| 401 |
+
"account", a.AccountID, "error", err)
|
| 402 |
+
return nil
|
| 403 |
+
}
|
| 404 |
+
config.Logger.Info("[token_refresh] session probe requires login", "account", a.AccountID, "reason", err)
|
| 405 |
+
_ = r.Store.UpdateAccountToken(a.AccountID, "")
|
| 406 |
+
a.Account.Token = ""
|
| 407 |
+
a.DeepSeekToken = ""
|
| 408 |
+
return r.loginAndPersist(ctx, a)
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// shouldForceRefresh 判断是否到了定期体检的时间点。到期并不等于要重新登录:
|
| 412 |
+
// 调用方会用无密码探针复核会话,只有探针明确失败才走密码登录。
|
| 413 |
func (r *Resolver) shouldForceRefresh(accountID string) bool {
|
| 414 |
if r == nil || r.Store == nil {
|
| 415 |
return false
|
|
|
|
| 429 |
r.tokenRefreshedAt[accountID] = now
|
| 430 |
return false
|
| 431 |
}
|
| 432 |
+
// Add jitter: ±10% random variation to avoid all accounts probing at the same time.
|
| 433 |
+
// For a 6h interval, this gives a range of ~5h24m to ~6h36m.
|
| 434 |
baseInterval := time.Duration(intervalHours) * time.Hour
|
| 435 |
jitterRange := baseInterval / 10 // ±10% → total range is 80%-120% of base
|
| 436 |
jitter := time.Duration(rand.Int63n(int64(2*jitterRange+1))) - jitterRange
|
|
|
|
| 447 |
}
|
| 448 |
// Add a random offset (0-30min) to spread out future refreshes across accounts.
|
| 449 |
// This prevents all accounts from refreshing at the same wall-clock time.
|
| 450 |
+
offset := time.Duration(rand.Int63n(int64(30 * time.Minute)))
|
| 451 |
r.mu.Lock()
|
| 452 |
defer r.mu.Unlock()
|
| 453 |
r.tokenRefreshedAt[accountID] = time.Now().Add(-offset)
|
internal/auth/session_probe_test.go
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package auth
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"sync/atomic"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"ds2api/internal/account"
|
| 12 |
+
"ds2api/internal/config"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
func newProbeTestResolver(t *testing.T, login LoginFunc) (*Resolver, *config.Store) {
|
| 16 |
+
t.Helper()
|
| 17 |
+
t.Setenv("DS2API_CONFIG_JSON", `{
|
| 18 |
+
"keys":["managed-key"],
|
| 19 |
+
"accounts":[{"email":"acc@example.com","password":"pwd"}]
|
| 20 |
+
}`)
|
| 21 |
+
store := config.LoadStore()
|
| 22 |
+
if err := store.UpdateAccountToken("acc@example.com", "session-token"); err != nil {
|
| 23 |
+
t.Fatalf("seed token failed: %v", err)
|
| 24 |
+
}
|
| 25 |
+
return NewResolver(store, account.NewPool(store), login), store
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func newProbeTestAuth(token string) *RequestAuth {
|
| 29 |
+
return &RequestAuth{
|
| 30 |
+
UseConfigToken: true,
|
| 31 |
+
AccountID: "acc@example.com",
|
| 32 |
+
Account: config.Account{Email: "acc@example.com", Password: "pwd", Token: token},
|
| 33 |
+
DeepSeekToken: token,
|
| 34 |
+
TriedAccounts: map[string]bool{},
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
func countingLogin(loginCount *int32, token string) LoginFunc {
|
| 39 |
+
return func(_ context.Context, _ config.Account) (string, error) {
|
| 40 |
+
atomic.AddInt32(loginCount, 1)
|
| 41 |
+
return token, nil
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// 探针确认会话仍有效时,刷新不应提交密码。
|
| 46 |
+
func TestRefreshTokenUsesProbeWithoutLogin(t *testing.T) {
|
| 47 |
+
var loginCount int32
|
| 48 |
+
r, store := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 49 |
+
r.RefreshSession = func(_ context.Context, acc config.Account) (string, error) {
|
| 50 |
+
if acc.Token != "session-token" {
|
| 51 |
+
t.Fatalf("probe should see current token, got %q", acc.Token)
|
| 52 |
+
}
|
| 53 |
+
return "session-token", nil
|
| 54 |
+
}
|
| 55 |
+
a := newProbeTestAuth("session-token")
|
| 56 |
+
|
| 57 |
+
if !r.RefreshToken(context.Background(), a) {
|
| 58 |
+
t.Fatal("expected refresh to succeed via session probe")
|
| 59 |
+
}
|
| 60 |
+
if got := atomic.LoadInt32(&loginCount); got != 0 {
|
| 61 |
+
t.Fatalf("expected no password login, got %d", got)
|
| 62 |
+
}
|
| 63 |
+
if a.DeepSeekToken != "session-token" {
|
| 64 |
+
t.Fatalf("expected existing token to be kept, got %q", a.DeepSeekToken)
|
| 65 |
+
}
|
| 66 |
+
if token := findTestToken(t, store); token != "session-token" {
|
| 67 |
+
t.Fatalf("expected persisted token unchanged, got %q", token)
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// 服务端主动轮换时,探针拿回的新 token 要落盘并生效。
|
| 72 |
+
func TestRefreshTokenAdoptsRotatedTokenFromProbe(t *testing.T) {
|
| 73 |
+
var loginCount int32
|
| 74 |
+
r, store := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 75 |
+
r.RefreshSession = func(_ context.Context, _ config.Account) (string, error) {
|
| 76 |
+
return "rotated-token", nil
|
| 77 |
+
}
|
| 78 |
+
a := newProbeTestAuth("session-token")
|
| 79 |
+
|
| 80 |
+
if !r.RefreshToken(context.Background(), a) {
|
| 81 |
+
t.Fatal("expected refresh to succeed via session probe")
|
| 82 |
+
}
|
| 83 |
+
if got := atomic.LoadInt32(&loginCount); got != 0 {
|
| 84 |
+
t.Fatalf("expected no password login, got %d", got)
|
| 85 |
+
}
|
| 86 |
+
if a.DeepSeekToken != "rotated-token" {
|
| 87 |
+
t.Fatalf("expected rotated token on auth, got %q", a.DeepSeekToken)
|
| 88 |
+
}
|
| 89 |
+
if token := findTestToken(t, store); token != "rotated-token" {
|
| 90 |
+
t.Fatalf("expected rotated token persisted, got %q", token)
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// 探针明确判定会话失效时才回退到密码登录。
|
| 95 |
+
func TestRefreshTokenFallsBackToLoginWhenSessionRejected(t *testing.T) {
|
| 96 |
+
var loginCount int32
|
| 97 |
+
r, store := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 98 |
+
r.RefreshSession = func(_ context.Context, _ config.Account) (string, error) {
|
| 99 |
+
return "", fmt.Errorf("check session: %w", ErrSessionExpired)
|
| 100 |
+
}
|
| 101 |
+
a := newProbeTestAuth("session-token")
|
| 102 |
+
|
| 103 |
+
if !r.RefreshToken(context.Background(), a) {
|
| 104 |
+
t.Fatal("expected fallback login to succeed")
|
| 105 |
+
}
|
| 106 |
+
if got := atomic.LoadInt32(&loginCount); got != 1 {
|
| 107 |
+
t.Fatalf("expected exactly one login, got %d", got)
|
| 108 |
+
}
|
| 109 |
+
if a.DeepSeekToken != "login-token" {
|
| 110 |
+
t.Fatalf("expected login token, got %q", a.DeepSeekToken)
|
| 111 |
+
}
|
| 112 |
+
if token := findTestToken(t, store); token != "login-token" {
|
| 113 |
+
t.Fatalf("expected login token persisted, got %q", token)
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// 探针无法判定时仍保留密码登录兜底,避免把可恢复的请求判死。
|
| 118 |
+
func TestRefreshTokenFallsBackToLoginWhenProbeInconclusive(t *testing.T) {
|
| 119 |
+
var loginCount int32
|
| 120 |
+
r, _ := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 121 |
+
r.RefreshSession = func(_ context.Context, _ config.Account) (string, error) {
|
| 122 |
+
return "", errors.New("dial tcp: connection refused")
|
| 123 |
+
}
|
| 124 |
+
a := newProbeTestAuth("session-token")
|
| 125 |
+
|
| 126 |
+
if !r.RefreshToken(context.Background(), a) {
|
| 127 |
+
t.Fatal("expected fallback login to succeed")
|
| 128 |
+
}
|
| 129 |
+
if got := atomic.LoadInt32(&loginCount); got != 1 {
|
| 130 |
+
t.Fatalf("expected exactly one login, got %d", got)
|
| 131 |
+
}
|
| 132 |
+
if a.DeepSeekToken != "login-token" {
|
| 133 |
+
t.Fatalf("expected login token, got %q", a.DeepSeekToken)
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// 定时体检:探针成功即完成,不应触发登录。
|
| 138 |
+
func TestScheduledRefreshUsesProbeWithoutLogin(t *testing.T) {
|
| 139 |
+
var loginCount int32
|
| 140 |
+
r, _ := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 141 |
+
r.RefreshSession = func(_ context.Context, _ config.Account) (string, error) {
|
| 142 |
+
return "session-token", nil
|
| 143 |
+
}
|
| 144 |
+
markRefreshDue(t, r)
|
| 145 |
+
a := newProbeTestAuth("session-token")
|
| 146 |
+
|
| 147 |
+
if err := r.ensureManagedToken(context.Background(), a); err != nil {
|
| 148 |
+
t.Fatalf("ensure managed token failed: %v", err)
|
| 149 |
+
}
|
| 150 |
+
if got := atomic.LoadInt32(&loginCount); got != 0 {
|
| 151 |
+
t.Fatalf("expected no password login, got %d", got)
|
| 152 |
+
}
|
| 153 |
+
if a.DeepSeekToken != "session-token" {
|
| 154 |
+
t.Fatalf("expected token kept, got %q", a.DeepSeekToken)
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// 定时体检:探针结果不可判定时保留 token,不能因为一次网络抖动就提交密码。
|
| 159 |
+
func TestScheduledRefreshKeepsTokenWhenProbeInconclusive(t *testing.T) {
|
| 160 |
+
var loginCount int32
|
| 161 |
+
r, _ := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 162 |
+
r.RefreshSession = func(_ context.Context, _ config.Account) (string, error) {
|
| 163 |
+
return "", errors.New("connection reset by peer")
|
| 164 |
+
}
|
| 165 |
+
markRefreshDue(t, r)
|
| 166 |
+
a := newProbeTestAuth("session-token")
|
| 167 |
+
|
| 168 |
+
if err := r.ensureManagedToken(context.Background(), a); err != nil {
|
| 169 |
+
t.Fatalf("ensure managed token failed: %v", err)
|
| 170 |
+
}
|
| 171 |
+
if got := atomic.LoadInt32(&loginCount); got != 0 {
|
| 172 |
+
t.Fatalf("expected no password login on inconclusive probe, got %d", got)
|
| 173 |
+
}
|
| 174 |
+
if a.DeepSeekToken != "session-token" {
|
| 175 |
+
t.Fatalf("expected token kept, got %q", a.DeepSeekToken)
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// 定时体检:探针明确判定失效时回退到密码登录。
|
| 180 |
+
func TestScheduledRefreshLogsInWhenSessionRejected(t *testing.T) {
|
| 181 |
+
var loginCount int32
|
| 182 |
+
r, _ := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 183 |
+
r.RefreshSession = func(_ context.Context, _ config.Account) (string, error) {
|
| 184 |
+
return "", ErrSessionExpired
|
| 185 |
+
}
|
| 186 |
+
markRefreshDue(t, r)
|
| 187 |
+
a := newProbeTestAuth("session-token")
|
| 188 |
+
|
| 189 |
+
if err := r.ensureManagedToken(context.Background(), a); err != nil {
|
| 190 |
+
t.Fatalf("ensure managed token failed: %v", err)
|
| 191 |
+
}
|
| 192 |
+
if got := atomic.LoadInt32(&loginCount); got != 1 {
|
| 193 |
+
t.Fatalf("expected exactly one login, got %d", got)
|
| 194 |
+
}
|
| 195 |
+
if a.DeepSeekToken != "login-token" {
|
| 196 |
+
t.Fatalf("expected login token, got %q", a.DeepSeekToken)
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// 未注入探针时保持历史行为:定时到期直接登录。
|
| 201 |
+
func TestScheduledRefreshWithoutProbeFallsBackToLogin(t *testing.T) {
|
| 202 |
+
var loginCount int32
|
| 203 |
+
r, _ := newProbeTestResolver(t, countingLogin(&loginCount, "login-token"))
|
| 204 |
+
markRefreshDue(t, r)
|
| 205 |
+
a := newProbeTestAuth("session-token")
|
| 206 |
+
|
| 207 |
+
if err := r.ensureManagedToken(context.Background(), a); err != nil {
|
| 208 |
+
t.Fatalf("ensure managed token failed: %v", err)
|
| 209 |
+
}
|
| 210 |
+
if got := atomic.LoadInt32(&loginCount); got != 1 {
|
| 211 |
+
t.Fatalf("expected exactly one login, got %d", got)
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
func markRefreshDue(t *testing.T, r *Resolver) {
|
| 216 |
+
t.Helper()
|
| 217 |
+
if err := r.Store.Update(func(c *config.Config) error {
|
| 218 |
+
c.Runtime.TokenRefreshIntervalHours = 1
|
| 219 |
+
return nil
|
| 220 |
+
}); err != nil {
|
| 221 |
+
t.Fatalf("update runtime failed: %v", err)
|
| 222 |
+
}
|
| 223 |
+
r.mu.Lock()
|
| 224 |
+
r.tokenRefreshedAt["acc@example.com"] = time.Now().Add(-2 * time.Hour)
|
| 225 |
+
r.mu.Unlock()
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
func findTestToken(t *testing.T, store *config.Store) string {
|
| 229 |
+
t.Helper()
|
| 230 |
+
acc, ok := store.FindAccount("acc@example.com")
|
| 231 |
+
if !ok {
|
| 232 |
+
t.Fatal("account not found in store")
|
| 233 |
+
}
|
| 234 |
+
return acc.Token
|
| 235 |
+
}
|
internal/deepseek/client/client_user_session.go
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package client
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"net/http"
|
| 8 |
+
"strings"
|
| 9 |
+
|
| 10 |
+
"ds2api/internal/auth"
|
| 11 |
+
"ds2api/internal/config"
|
| 12 |
+
dsprotocol "ds2api/internal/deepseek/protocol"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// DeepSeek 没有 access_token + refresh_token 的两段式机制:登录下发的 token 是
|
| 16 |
+
// 按设备绑定的长期会话凭证,服务端不存在可换新的 refresh 端点。官方 Web/Android
|
| 17 |
+
// 客户端续用会话的做法是 GET /api/v0/users/current,响应里的 data.biz_data.token
|
| 18 |
+
// 就是当前会话 token(服务端主动轮换时会是新值),客户端拿到后直接写回本地存储,
|
| 19 |
+
// 全程不需要密码。
|
| 20 |
+
//
|
| 21 |
+
// RefreshSessionToken 复刻这条通道,用来替代“到期就提交一次明文密码”的做法。
|
| 22 |
+
// 返回的错误分两类:
|
| 23 |
+
// - auth.ErrSessionExpired:服务端明确拒绝该会话(401/403 或鉴权类 biz_code),
|
| 24 |
+
// 只能靠密码重新登录恢复;
|
| 25 |
+
// - 其他错误:无法判定(网络、5xx、未知 biz_code、响应缺字段),
|
| 26 |
+
// 调用方不应据此丢弃仍然可能有效的 token。
|
| 27 |
+
func (c *Client) RefreshSessionToken(ctx context.Context, acc config.Account) (string, error) {
|
| 28 |
+
token := strings.TrimSpace(acc.Token)
|
| 29 |
+
if token == "" {
|
| 30 |
+
return "", errors.New("refresh session token: empty token")
|
| 31 |
+
}
|
| 32 |
+
clients := c.requestClientsForAccount(acc)
|
| 33 |
+
headers := c.authHeadersWithRangers(token, config.AndroidAccountRangersID(acc))
|
| 34 |
+
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, dsprotocol.DeepSeekCurrentUserURL, headers)
|
| 35 |
+
if err != nil {
|
| 36 |
+
return "", fmt.Errorf("refresh session token: %w", err)
|
| 37 |
+
}
|
| 38 |
+
return interpretCurrentUserResponse(status, resp)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// interpretCurrentUserResponse 把 users/current 的响应翻译成“新 token / 会话失效 /
|
| 42 |
+
// 无法判定”三种结果。判定刻意收窄:只有 HTTP 401/403 或鉴权类业务码才认定会话失效,
|
| 43 |
+
// 避免把限流、风控、临时故障误判成“必须重新登录”。
|
| 44 |
+
func interpretCurrentUserResponse(status int, resp map[string]any) (string, error) {
|
| 45 |
+
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
| 46 |
+
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
| 47 |
+
if token := extractCurrentUserToken(resp); token != "" {
|
| 48 |
+
return token, nil
|
| 49 |
+
}
|
| 50 |
+
return "", errors.New("refresh session token: response has no token")
|
| 51 |
+
}
|
| 52 |
+
if sessionRejectedStatus(status, code, bizCode) {
|
| 53 |
+
return "", fmt.Errorf(
|
| 54 |
+
"refresh session token: session rejected (status=%d code=%d biz_code=%d msg=%q biz_msg=%q): %w",
|
| 55 |
+
status, code, bizCode, msg, bizMsg, auth.ErrSessionExpired,
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
return "", fmt.Errorf(
|
| 59 |
+
"refresh session token: inconclusive response (status=%d code=%d biz_code=%d msg=%q biz_msg=%q)",
|
| 60 |
+
status, code, bizCode, msg, bizMsg,
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func sessionRejectedStatus(status int, code int, bizCode int) bool {
|
| 65 |
+
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
| 66 |
+
return true
|
| 67 |
+
}
|
| 68 |
+
for _, candidate := range []int{code, bizCode} {
|
| 69 |
+
switch candidate {
|
| 70 |
+
case 40001, 40002, 40003:
|
| 71 |
+
return true
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
return false
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// extractCurrentUserToken 兼容两种响应形状:data.biz_data.token(抓包观察到的)
|
| 78 |
+
// 与 data.biz_data.user.token(登录响应同构的嵌套形状)。
|
| 79 |
+
func extractCurrentUserToken(resp map[string]any) string {
|
| 80 |
+
data, _ := resp["data"].(map[string]any)
|
| 81 |
+
bizData, _ := data["biz_data"].(map[string]any)
|
| 82 |
+
if token, _ := bizData["token"].(string); strings.TrimSpace(token) != "" {
|
| 83 |
+
return strings.TrimSpace(token)
|
| 84 |
+
}
|
| 85 |
+
user, _ := bizData["user"].(map[string]any)
|
| 86 |
+
token, _ := user["token"].(string)
|
| 87 |
+
return strings.TrimSpace(token)
|
| 88 |
+
}
|
internal/deepseek/client/client_user_session_test.go
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package client
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"ds2api/internal/auth"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func TestInterpretCurrentUserResponseReturnsFlatToken(t *testing.T) {
|
| 11 |
+
resp := map[string]any{
|
| 12 |
+
"code": 0,
|
| 13 |
+
"data": map[string]any{
|
| 14 |
+
"biz_code": 0,
|
| 15 |
+
"biz_data": map[string]any{
|
| 16 |
+
"id": "user-1",
|
| 17 |
+
"token": "flat-token",
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
}
|
| 21 |
+
token, err := interpretCurrentUserResponse(200, resp)
|
| 22 |
+
if err != nil {
|
| 23 |
+
t.Fatalf("unexpected error: %v", err)
|
| 24 |
+
}
|
| 25 |
+
if token != "flat-token" {
|
| 26 |
+
t.Fatalf("expected flat-token, got %q", token)
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func TestInterpretCurrentUserResponseReturnsNestedUserToken(t *testing.T) {
|
| 31 |
+
resp := map[string]any{
|
| 32 |
+
"code": 0,
|
| 33 |
+
"data": map[string]any{
|
| 34 |
+
"biz_code": 0,
|
| 35 |
+
"biz_data": map[string]any{
|
| 36 |
+
"user": map[string]any{"token": "nested-token"},
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
}
|
| 40 |
+
token, err := interpretCurrentUserResponse(200, resp)
|
| 41 |
+
if err != nil {
|
| 42 |
+
t.Fatalf("unexpected error: %v", err)
|
| 43 |
+
}
|
| 44 |
+
if token != "nested-token" {
|
| 45 |
+
t.Fatalf("expected nested-token, got %q", token)
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func TestInterpretCurrentUserResponseMarksUnauthorizedAsExpired(t *testing.T) {
|
| 50 |
+
_, err := interpretCurrentUserResponse(401, map[string]any{"code": 40001, "msg": "unauthorized"})
|
| 51 |
+
if !errors.Is(err, auth.ErrSessionExpired) {
|
| 52 |
+
t.Fatalf("expected ErrSessionExpired, got %v", err)
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func TestInterpretCurrentUserResponseMarksAuthBizCodeAsExpired(t *testing.T) {
|
| 57 |
+
resp := map[string]any{
|
| 58 |
+
"code": 0,
|
| 59 |
+
"data": map[string]any{"biz_code": 40003, "biz_msg": "invalid permission"},
|
| 60 |
+
}
|
| 61 |
+
_, err := interpretCurrentUserResponse(200, resp)
|
| 62 |
+
if !errors.Is(err, auth.ErrSessionExpired) {
|
| 63 |
+
t.Fatalf("expected ErrSessionExpired, got %v", err)
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// 5xx / 网络类失败不能当成“会话失效”,否则又会退回高频密码登录。
|
| 68 |
+
func TestInterpretCurrentUserResponseKeepsServerErrorInconclusive(t *testing.T) {
|
| 69 |
+
_, err := interpretCurrentUserResponse(500, map[string]any{"code": 500, "msg": "internal error"})
|
| 70 |
+
if err == nil {
|
| 71 |
+
t.Fatal("expected error")
|
| 72 |
+
}
|
| 73 |
+
if errors.Is(err, auth.ErrSessionExpired) {
|
| 74 |
+
t.Fatalf("server error must not be treated as expired session: %v", err)
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
func TestInterpretCurrentUserResponseMissingTokenIsInconclusive(t *testing.T) {
|
| 79 |
+
_, err := interpretCurrentUserResponse(200, map[string]any{
|
| 80 |
+
"code": 0,
|
| 81 |
+
"data": map[string]any{"biz_code": 0, "biz_data": map[string]any{"id": "user-1"}},
|
| 82 |
+
})
|
| 83 |
+
if err == nil {
|
| 84 |
+
t.Fatal("expected error when token is missing")
|
| 85 |
+
}
|
| 86 |
+
if errors.Is(err, auth.ErrSessionExpired) {
|
| 87 |
+
t.Fatalf("missing token must not be treated as expired session: %v", err)
|
| 88 |
+
}
|
| 89 |
+
}
|
internal/deepseek/protocol/constants.go
CHANGED
|
@@ -10,6 +10,7 @@ import (
|
|
| 10 |
const (
|
| 11 |
DeepSeekHost = "chat.deepseek.com"
|
| 12 |
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
|
|
|
|
| 13 |
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
| 14 |
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
| 15 |
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
|
|
|
|
| 10 |
const (
|
| 11 |
DeepSeekHost = "chat.deepseek.com"
|
| 12 |
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
|
| 13 |
+
DeepSeekCurrentUserURL = "https://chat.deepseek.com/api/v0/users/current"
|
| 14 |
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
| 15 |
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
| 16 |
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
|
internal/server/router.go
CHANGED
|
@@ -54,6 +54,11 @@ func NewApp() (*App, error) {
|
|
| 54 |
return dsClient.Login(ctx, acc)
|
| 55 |
})
|
| 56 |
dsClient = dsclient.NewClient(store, resolver)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
if err := dsClient.PreloadPow(context.Background()); err != nil {
|
| 58 |
config.Logger.Warn("[PoW] init failed", "error", err)
|
| 59 |
} else {
|
|
|
|
| 54 |
return dsClient.Login(ctx, acc)
|
| 55 |
})
|
| 56 |
dsClient = dsclient.NewClient(store, resolver)
|
| 57 |
+
// 无密码续期通道:users/current 回填当前会话 token。
|
| 58 |
+
// 注入后 Resolver 的刷新链会先走这条通道,密码登录只作为兜底。
|
| 59 |
+
resolver.RefreshSession = func(ctx context.Context, acc config.Account) (string, error) {
|
| 60 |
+
return dsClient.RefreshSessionToken(ctx, acc)
|
| 61 |
+
}
|
| 62 |
if err := dsClient.PreloadPow(context.Background()); err != nil {
|
| 63 |
config.Logger.Warn("[PoW] init failed", "error", err)
|
| 64 |
} else {
|
security_best_practices_report.md
CHANGED
|
@@ -168,7 +168,7 @@
|
|
| 168 |
|
| 169 |
**建议修复**: 不建议立即修复 —— Go 进程内存加密会显著增加复杂度且无法抵御 root 攻击者。建议:
|
| 170 |
1. 确保 HF Space 容器以非 root 用户运行(已确认 `ds2api` 用户)。
|
| 171 |
-
2. 定期
|
| 172 |
3. 监控异常 token 使用。
|
| 173 |
|
| 174 |
### TODO-6: webui 静态文件路径穿越(已防护,建议加固)
|
|
|
|
| 168 |
|
| 169 |
**建议修复**: 不建议立即修复 —— Go 进程内存加密会显著增加复杂度且无法抵御 root 攻击者。建议:
|
| 170 |
1. 确保 HF Space 容器以非 root 用户运行(已确认 `ds2api` 用户)。
|
| 171 |
+
2. 定期复核会话有效性(`TokenRefreshIntervalHours` 到期时先走无密码的 `GET /api/v0/users/current`,只有服务端明确拒绝会话才重新登录)。
|
| 172 |
3. 监控异常 token 使用。
|
| 173 |
|
| 174 |
### TODO-6: webui 静态文件路径穿越(已防护,建议加固)
|