- SignalDesk2小时前
Original Summary
https://linux.do/t/topic/2945247 前面简单介绍三大厂IDE客户端签到,我看除了qoder cn没有搜索到对应的脚本,那我就写一个详细的分析。 一篇把「设备流登录 → 拿到 token → 静默续期 → 自动签到 → 多账户管理」整条链路讲透的文章。 所有端点、请求头、报文结构、加密格式均来自对 app.asar 的静态逆向 + 客户端自身结构化日志( main.log 的 [Auth] 行)+ auth.v1.dat 加解密实测验证,不是猜测。 目标读者:想理解 OAuth Device Flow + PKCE 真实工程实现、或想给自己的客户端做自动化签到的人。 0. 先把结论摆在最前面 Qoder CN 的登录 不是 「账号密码直接打给原生 App」。它是一条 OAuth 2.0 设备授权流(Device Flow)的变体 ,且用了 PKCE(S256) : 原生 App 只在本地生成一个一次性秘密 code_verifier ; 把 sha256(verifier) (即 challenge )拼进一条授权 URL,丢给 系统浏览器 ; 用户在浏览器里用密码 / 扫码 / 支付宝等方式完成认证,凭据 只存在于网页与 IDP 之间 ,App 全程不经手; App 转而 轮询 deviceToken/poll ,用明文 verifier 作为「票据兑换码」换回 access_token ( dt- 前缀)与 refresh_token ( drt- 前缀)。 这套设计带来一个直接推论: 你在客户端的网络栈里永远抓不到「登录请求」本身 ,能抓到的只有 poll 和之后带 Bearer 的业务请求。理解了这一点,后面所有自动化都好写。 整条链路一图流: ┌──────────── App(本地,纯计算) ───────────┐ ┌──── 系统浏览器 ────┐ │ verifier = rand64([A-Za-z0-9-._~]) │ │ │ │ challenge = b64url(sha256(verifier)) │ │ 输入密码/扫码/授权 │ │ nonce = uuid4() │ │ │ │ │ 拼授权URL ────────── openExternal ──────────┼───────►│ ▼ │ │ │ │ qoder.cn/users/ │ │ loop: GET deviceToken/poll?verifier=... │ │ sign-in │ │ 404 → 继续等;200 → 拿到 dt-/drt- │◄───────┼── IDP 校验通过后 │ └─────────────┬───────────────────────────────┘ poll │ 设备被"批准" │ │ └────────────────────┘ ▼ 存 token → 临期 refresh_token 续期 → 拉 campaigns → claim 签到 → 查 quota 涉及的两个域名务必分清: 域名 用途 鉴权方式 qoder.cn 网站 / 授权页(浏览器里打开) Cookie 会话 openapi.qoder.com.cn 原生客户端业务 API Authorization: Bearer dt-… 这个区分是后文很多「401 / 拿不到数据」坑的根源。 1. 关键端点总清单 先集中列出,后文逐个拆解。基址: AUTH = https://qoder.cn , API = https://openapi.qoder.com.cn 。 # 授权(浏览器打开) GET {AUTH}/users/sign-in?biz_variant=qoder&oauth_callback=<enc(device URL)> GET {AUTH}/device/selectAccounts?challenge&challenge_method=S256&nonce&machine_id&client_id # 领票 / 续期 / 资料 GET {API}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256 (404=未授权 200=成功) POST {API}/api/v1/deviceToken/refresh body {"refresh_token":"drt-…"} (无鉴权头) GET {API}/api/v1/userinfo Bearer # 签到相关 GET {API}/sash/api/v1/me/campaigns Bearer 拉活动列表 POST {API}/sash/api/v1/me/campaigns/{campaignId}/claim Bearer {} (幂等) GET {API}/api/v2/quota/usage Bearer 额度/积分余额 # 无头 / CI 路径 POST {API}/api/v1/jobToken/exchange body {"personal_token":"<PAT>"} 调用任意 Bearer 接口所需请求头(这是能被服务端认成「合法客户端」的最小集合): Authorization: Bearer <dt-token> Cosy-ClientType: 10 User-Agent: Qoder Accept: application/json 2. 第一步:本地生成 PKCE 三件套 设备流的「秘密」全部在本地算, 登录前不存在任何向服务端注册的请求 ——这是它和标准 Authorization Code + PKCE 的最大区别(标准流要先请求 /authorize 拿 code;这里服务端不预先记录,靠 nonce 关联设备与授权页)。 import secrets, string, hashlib, base64, uuid ALPHABET = string.ascii_letters + string.digits + "-._~" # PKCE unreserved 字符集 def new_verifier(n=64): return "".join(secrets.choice(ALPHABET) for _ in range(n)) def s256(verifier: str) -> str: d = hashlib.sha256(verifier.encode("ascii")).digest() # base64url、且去掉 '=' padding —— 这是 OAuth 规范要求的形式 return base64.urlsafe_b64encode(d).decode("ascii").rstrip("=") verifier = new_verifier() # 本地秘密,务必只发给 poll challenge = s256(verifier) # 公开值,可以进浏览器 URL nonce = str(uuid.uuid4()) # 关联「这一次设备会话」 要点: verifier 长度 64,字符集严格是 [A-Za-z0-9-._~] (RFC 7636 的 unreserved)。服务端会用你 poll 时上送的 verifier 现算 sha256 去比对当初授权 URL 里的 challenge 。 challenge 是 唯一 会出现在浏览器地址栏的值;明文 verifier 绝不出现在 URL 里。 nonce 把「App 的轮询」和「浏览器里那次授权」在服务器侧对上号。 3. 第二步:拼装授权 URL,把登录交给浏览器 import urllib.parse AUTH_BASE = "https://qoder.cn" CLIENT_ID = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa" # 从 app.asar 里抠出来的固定值 BIZ_VARIANT = "qoder" def build_authorize_url(nonce, verifier, machine_id): dev = urllib.parse.urlencode({ "challenge": s256(verifier), "challenge_method": "S256", "nonce": nonce, "machine_id": machine_id, "client_id": CLIENT_ID, }) device_url = f"{AUTH_BASE}/device/selectAccounts?{dev}" # 注意是「套娃」:外层 sign-in 用 oauth_callback 指向内层 device 页 wrapped = urllib.parse.urlencode({"biz_variant": BIZ_VARIANT, "oauth_callback": device_url}) return f"{AUTH_BASE}/users/sign-in?{wrapped}" 生成的 URL 长这样(换行仅为可读): https://qoder.cn/users/sign-in ?biz_variant=qoder &oauth_callback=https%3A%2F%2Fqoder.cn%2Fdevice%2FselectAccounts %3Fchallenge%3D<...>%26challenge_method%3DS256%26nonce%3D<uuid> %26machine_id%3D<id>%26client_id%3D732aef47-... machine_id 尽量复用客户端真实值(有些风控/设备维度会看它),拿不到再退化成随机 UUID: def client_machine_id(): for p in (os.path.join(PROFILE, "auth.machine-id"), os.path.expanduser(r"~\.qoder-cn\shared_client/cache/machine_token.json")): try: if p.endswith(".json"): return json.load(open(p, encoding="utf-8")).get("id") t = open(p, encoding="utf-8").read().strip() if t: return t except Exception: pass return str(uuid.uuid4()) 拿到 URL 后,用 webbrowser.open(url) 或让用户手动打开,在浏览器里完成登录。App 这边此时进入下一步:轮询。 4. 第三步:轮询领票, poll 才是「登录成功」的那一刻 def device_poll(nonce, verifier, timeout=300.0, interval=1.0): q = urllib.parse.urlencode({"nonce": nonce, "verifier": verifier, "challenge_method": "S256"}) url = f"{OPENAPI_BASE}/api/v1/deviceToken/poll?{q}" end = time.time() + timeout while time.time() < end: code, body = _req("GET", url, {"Accept": "application/json", "User-Agent": "Qoder"}) if code == 200 and isinstance(body, dict) and (body.get("token") or body.get("device_token")): return body # 成功 if code not in (404, 200): return {"_error": f"poll HTTP {code}", "_body": body} time.sleep(interval) # 404 表示「用户还没在浏览器点确认」,继续等 return {"_error": "timeout"} 行为约定: 用户 还没在浏览器授权 时, poll 返回 404 ,客户端 sleep 1s 重试,总超时 300s。 用户在浏览器点「允许 / 选择账号」后,下一次 poll 返回 200 ,body 里就有了正式凭证: { "token": "dt-XXXXXXXX...", // access_token,Bearer 用 "refresh_token": "drt-YYYYYYYY...", // 续期用 "expires_at": "2026-10-24T05:45:46Z", // 或 expires_in(毫秒)二选一 "user_id": "019f....", // 其它字段视版本而定 } 拿到 token 后立刻可以 GET /api/v1/userinfo 换资料,再规范化成一条「账户记录」存下来: def poll_to_account(res, machine_id, source): token = res.get("token") or res.get("device_token") expires_at = res.get("expires_at") or ( iso_add_seconds(res["expires_in"] // 1000) if res.get("expires_in") else None) prof = fetch_userinfo(token) # 拉 /api/v1/userinfo return { "uid": res.get("user_id") or prof.get("id"), "name": prof.get("name"), "phone": prof
- 情报分类:服务器与云资源
- 分类依据:内容涉及服务器、云资源或网络线路
- 信息来源:服务器 / LINUX DO - 最新话题
- 发布时间:2026/9/24 16:27:06
- 暂无回复