Download docs/token-refresh-research.md from a3216/ds2api: direct link, hf CLI and curl.
- Browser
- Download file 23.7 kB
-
https://huggingface.co/spaces/a3216/ds2api/resolve/main/docs/token-refresh-research.md
- Command line
-
hf download hf://spaces/a3216/ds2api/docs/token-refresh-research.md
-
curl -L -o token-refresh-research.md https://huggingface.co/spaces/a3216/ds2api/resolve/main/docs/token-refresh-research.md
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)
改动前的「刷新 token」等于「重新登录」。
Resolver.RefreshToken()清空本地 token 后走loginAndPersist()→Client.Login()→POST /api/v0/users/login,也就是每次都把邮箱/手机号 + 明文密码重新提交一次。 定时刷新(默认 6h)也一样走这条路径。 (已改:现在是「无密码探针优先,明确失效才登录」,见 §9。)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, 客户端无法本地判断过期时间。- Web 端 JS bundle(
但官方客户端确实「不重新登录就拿到新 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回填,失败再回退到密码登录, 而不是一上来就重新登录。另外发现一个副作用风险:当前每次刷新都是一次真实登录,而登录要过数美设备风控 (
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 会再登录一次。
三、这样做的代价(为什么不优雅)
- 密码高频出网。 每 6h × 每账号一次的明文密码提交,纯属没必要的暴露面。 一旦某次请求被中间人/代理记录,损失的是账号主凭证,而不是一个可吊销的会话。
- 每次刷新都要过一次风控。
client_auth.go已被迫实现 「RISK_DEVICE_DETECTED→ 重新签发数美 device_id → 重试一次」; 登录频次越高,触发风控与设备指纹漂移的概率越大。 - 可能直接锁号。
USER_IS_BANNED会让项目自动把账号置为 banned (internal/auth/request.go:171handleBannedAccount), 而频繁登录正是被风控盯上的典型特征之一。 - 多一次网络 + 多依赖一个签发服务(数美)。 刷新本该是一次轻量 GET, 现在变成「数美签发 device_id + 登录 + 落盘」三步,其中任何一步挂了,刷新就失败。
- 与官方客户端行为不一致。 官方 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 强制重登在语义上没有必要;
- 真正需要处理的只有两类事件:
- token 被服务端吊销/轮换 →
users/current可能回填到新值,或直接报鉴权失败; - 会话彻底死了 → 才需要密码登录。
- token 被服务端吊销/轮换 →
五、建议方案
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记录的是登录协议的数美风控细节,与本文互补: 那条链路越少走越好,正是本文的动机。
七、未完成 / 待验证(重要)
下列项目没有闭环,后续要补时从这里接:
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。rotate什么时候非 null。 三次抓包(含 Web 首次登录后、App 启动、长时间使用后)全部为null。 推测与「设备指纹变更 / 会话被顶号 / 服务端主动轮换」有关,未验证。users/current是否会返回「与请求不同的 token」。 所有抓包中response.token == request token。若永远相同, 则阶段 1 的价值是「用一次 GET 确认会话有效 + 拿到落盘值」, 而不是「换到新 token」——仍然比重新登录好,但收益要按这个预期描述。未做任何线上实测。 抓包里含真实账号的密码与 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.apk21MB、classes.dex12MB、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 次 → 恢复 |
两个推论,都影响本文之前的判断:
- 鉴权失败不一定走 HTTP 401。 实测是
HTTP 200 + code=40003。 所以「只按状态码判断」的探针会漏判,sessionRejectedStatus()必须同时看code/biz_code(这也解释了为什么不能直接复用宽匹配的isTokenInvalid(): 那个函数还会把消息里带token字样的非鉴权错误一起算进来)。 - §4.4 里「同一 device_id 反复登录返回同一个 token」这一行是错的。
实测每次密码登录都会下发新的 token,而旧 token 继续有效
(重新登录后再拿旧 token 打
users/current仍200)。 也就是说:每次重新登录都在账号上多留下一个仍然可用的长期会话凭证。 这比 §3 原先担心的「密码出网」更进一步——高频重登不仅暴露密码, 还在持续堆积可用的会话。