mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
审计 12 个平台时发现:四个标 flow="manual" 的平台里,**三个的登录协议完全可以在
Lua 里实现** —— 我把"没实现"写成了"做不到",理由全是错的。
## Qoder:WASM 是聊天加密,不是登录
错误理由:「登录要 WASM 签名头」。实际:WASM 用于 **/algo/ 加密推理端点**,
登录本身是标准 PKCE 设备码轮询(qoder-oauth.ts / qoder.ts):
授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
&nonce&machine_id&client_id(本地拼装,不打网络)
轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
三个必须照抄的细节(参考点名):
· client_id 用 **prod 的**(非 prod 会在授权回调阶段被拒)
· poll 挂 openApiBase(openapi.qoder.sh);authBase 返回 401
· 404 = 尚无会话(**继续等**,判失败会让登录永远失败)
实现:新增 judge="pending404" 判据 + 既有的 device-poll 机制支持
`GET_QUERY`/`auth_query`(PKCE 授权页形态,无 device_code)。
顺带修 poll_login 的条件(原要求 `s.device_code`,PKCE 形态没有它 → 永远不推进)。
## ZCode:授权流不涉及 captcha
错误理由:「登录要解 captcha」。实际:captcha 属于**另一条已废弃的旁路**
(读本机官方客户端数据);本流注释明确写着「普通进程就能走完」:
① POST {origin}/api/v1/oauth/cli/init Bearer <本地造的 flow_secret>
→ {flow_id, authorize_url, poll_interval_sec}
② 用户浏览器打开 authorize_url
③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
判据(参考点名):4xx(**除 408/429**)才是终态失败;code!=0 或 data 缺失 → pending;
token(zcode JWT) 与 user.user_id 必需,第三方 token 取 bigmodel/zai 二者之一。
另按参考修正 base_url/adapter(走 Anthropic 协议的 zcode.z.ai 免费额度通道)
并补余额端点。
## Loomy:扫码在手机上,协议在 HTTP 里
错误理由:「扫码在微信里完成,Lua 做不了」。实际:扫码动作确实在用户手机上,
但协议全是纯 HTTP:
① 取 uuid(授权页 HTML 里抽)② 二维码图 URL(前端渲染)
③ 长轮询 long.open.weixin.qq.com/connect/l/qrconnect
④ wx_code → 讯飞账号端点换凭据(HMAC-SHA1 签名)
判据(参考点名,早期版本**读反了**导致"扫码后卡在已扫码"):
408=等待;404=已扫码待确认(**继续轮询**);405=**已确认**(wx_code 在此帧);
403=取消;402=失效
签名:内核只有 sha256/hmac_sha256,没有 sha1/md5 —— 插件用 openssl 算
(HMAC-SHA1 与 Python 实测一致),**不改内核**。账号头前缀是 `account ak:sig`。
## 顺带修的真 bug
1. `loomyAuthHeaders` 调 `newToken()`,而它定义在文件后段 → Lua 词法作用域下是
nil,调用即崩。改用内核全局 sha256_hex 造 nonce。
2. `declaredHosts()` 只看 hosts/base_url/checkin → 各 flow 的端点域名
(open.weixin.qq.com / account.xfyun.cn / openapi.qoder.sh / zcode.z.ai …)
不在出站白名单里,授权页与长轮询会被拦。已按 flow 补全推导。
3. `set_endpoints` 不支持 wechat —— 自动化测试无法把 Loomy 端点指到假上游
(端点覆盖是参考明确的产品配置能力,也是测试缝)。已补 wechat/initpoll。
4. 两处 Lua 字符串引号错误(`"$(cat ...)"` 与 `"([^"]+)"` 提前结束字符串,
插件编译失败)。插件加载测试抓到的。
## 判据
- TestQoderDevicePollBehavior(5 项):授权页含 PKCE 四要素与 prod client_id;
404 保持 pending;轮询带 verifier/nonce;拿 token 即完成;池路径凭据落账号 ref
- TestZcodeInitPollBehavior(4 项):init 带会话密钥与 provider;pending 保持;
ready 完成且凭据含 zcode_jwt + 第三方 token;池路径
- TestLoomyWechatPollBehavior(4 项):抽出 uuid 并给二维码;**408/404 都是
pending**(404 非完成);405 才完成且账号端点用 account ak:sig 签名;403/402 失败
- 全量 core 回归通过;确认测试不写生产插件目录、无孤儿进程
- 内核对照 v2.0.2:diff 为空
## 剩余
Raccoon 仍为 manual(本地承载页 + SMS,含人工输入验证码),另做。
182 lines
5.8 KiB
Go
182 lines
5.8 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// qoderHarness:Qoder PKCE 设备码轮的测试台。
|
||
//
|
||
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
|
||
//
|
||
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
|
||
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
|
||
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
|
||
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
|
||
//
|
||
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
|
||
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
|
||
type qoderHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
ts *httptest.Server
|
||
poll int32 // 已发生的轮询次数
|
||
// step: 第几次轮询开始返回 token(0 = 一直 404)
|
||
tokenAt int32
|
||
gotQuery string
|
||
}
|
||
|
||
func newQoderHarness(t *testing.T) *qoderHarness {
|
||
t.Helper()
|
||
h := &qoderHarness{t: t, tokenAt: 2}
|
||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if r.URL.Path == "/api/v1/deviceToken/poll" {
|
||
h.gotQuery = r.URL.RawQuery
|
||
n := atomic.AddInt32(&h.poll, 1)
|
||
if n < atomic.LoadInt32(&h.tokenAt) {
|
||
w.WriteHeader(404) // 尚无会话
|
||
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
|
||
return
|
||
}
|
||
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
|
||
return
|
||
}
|
||
w.WriteHeader(404)
|
||
}))
|
||
t.Cleanup(h.ts.Close)
|
||
|
||
c := kernelTestCore(t)
|
||
h.ps = c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
|
||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "qoder",
|
||
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
|
||
}); err != nil {
|
||
t.Fatalf("set_endpoints: %v", err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (h *qoderHarness) begin() (map[string]interface{}, string) {
|
||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
h.t.Fatal(err)
|
||
}
|
||
j := out.JSON.(map[string]interface{})
|
||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||
}
|
||
|
||
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
|
||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
h.t.Fatal(err)
|
||
}
|
||
return out.JSON.(map[string]interface{})
|
||
}
|
||
|
||
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
|
||
func TestQoderDevicePollBehavior(t *testing.T) {
|
||
h := newQoderHarness(t)
|
||
|
||
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
|
||
j, _ := h.begin()
|
||
url, _ := j["url"].(string)
|
||
for _, want := range []string{
|
||
"qoder.com/device/selectAccounts",
|
||
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
|
||
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
|
||
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
|
||
} {
|
||
if !strings.Contains(url, want) {
|
||
t.Fatalf("授权页缺 %q:\n%s", want, url)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.poll, 0)
|
||
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
|
||
_, sid := h.begin()
|
||
for i := 0; i < 3; i++ {
|
||
st := h.pollOnce(sid)
|
||
if st["state"] != "pending" {
|
||
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.poll, 0)
|
||
atomic.StoreInt32(&h.tokenAt, 99)
|
||
_, sid := h.begin()
|
||
h.pollOnce(sid)
|
||
q := h.gotQuery
|
||
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
|
||
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
|
||
}
|
||
if !strings.Contains(q, "challenge_method=S256") {
|
||
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
|
||
}
|
||
})
|
||
|
||
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.poll, 0)
|
||
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
|
||
_, sid := h.begin()
|
||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||
t.Fatalf("首拍应 pending: %v", st)
|
||
}
|
||
st := h.pollOnce(sid)
|
||
if st["state"] != "completed" {
|
||
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
|
||
}
|
||
// 凭据落保险库(旧路径键)
|
||
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
|
||
if !ok || !strings.Contains(v, "QD-AT-1") {
|
||
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
|
||
}
|
||
})
|
||
|
||
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
|
||
atomic.StoreInt32(&h.poll, 0)
|
||
atomic.StoreInt32(&h.tokenAt, 1)
|
||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
|
||
})
|
||
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
|
||
st := h.pollOnce(sid)
|
||
if st["state"] != "completed" {
|
||
t.Fatalf("池路径应完成: %v", st)
|
||
}
|
||
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
|
||
t.Fatalf("★ 池路径凭据未落到 channels:qoder:%s", aid)
|
||
}
|
||
})
|
||
}
|