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 客户端启动):

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),启动时的伪代码:

// 启动引导
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 条):

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 串/交叉引用)

# 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 的建议(避免踩本机内存坑):

# 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 原先担心的「密码出网」更进一步——高频重登不仅暴露密码, 还在持续堆积可用的会话。