luckfun233 commited on
Commit
3df9d54
·
1 Parent(s): 1a72a9a

fix(auth): token 刷新改为无密码会话探针,密码登录仅作兜底

Browse files

DeepSeek 的 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 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
- > 状态:**调研结论已明确;未做任何线上实测**(见 §7)
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
 
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` 可能回填到新值,或直接 401;
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
- 修完阶段 1 之后才是真正的「刷新」。
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
- _ = r.Store.UpdateAccountToken(a.AccountID, "")
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.loginAndPersist(ctx, a); err != nil {
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: ±15% random variation to avoid all accounts refreshing at the same time.
334
- // For a 6h interval, this gives a range of ~5h06m to ~6h54m.
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. 定期刷新 token(已有 `TokenRefreshIntervalHours` 配置)。
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 静态文件路径穿越(已防护,建议加固)