跳转至

OAuth HTTP 自动化(admin-login.ts 源码完整分析)

源码文件: proxy/src/admin-login.ts — 416 行 分析覆盖: 100%(所有函数全部覆盖) 核心发现: 6 步纯 HTTP 自动化流程、SCaptcha TLS 绕过、10 分钟会话超时、自动 image_id 发现

1. 模块概述

admin-login.ts 是代理层最大的模块(416 行),实现了完整的 百智云 OAuth 纯 HTTP 自动化。它不依赖任何浏览器自动化工具(如 Playwright/Puppeteer),通过模拟 HTTP 请求完成从短信验证到 Session Cookie 获取的全流程。

暴露的 6 个公共函数

函数 行数 用途 调用的私有函数
startOAuthLogin() 11 Step 1: 获取 OAuth 跳转参数
getSCaptchaToken() 35 Step 2: 获取 SCaptcha token
sendSmsCode(phone, token) 23 Step 3: 发送短信验证码
initiateLogin(phone) 24 组合 Step 1~3,创建 OAuth 会话 startOAuthLogin, getSCaptchaToken, sendSmsCode
completeLogin(code) 65 Step 4~6: 验证码→Session Cookie baizhiPhoneLogin, baizhiOAuthAuthorize, monkeycodeCallback + discoverImageId, discoverModels, verifySession
loginWithCallbackUrl(url) 20 备用:直接由回调 URL 登录 monkeycodeCallback, discoverImageId

内部辅助函数

函数 行数 用途
baizhiPhoneLogin(phone, code) 30 Step 4: 百智云手机号登录
baizhiOAuthAuthorize(...) 37 Step 5: OAuth 授权
monkeycodeCallback(callbackUrl) 34 Step 6: MonkeyCode 回调换 Session
verifySession(cookie) 15 验证 Session Cookie 是否有效
discoverImageId(cookie) 30 从任务列表发现 image_id
discoverModels(cookie) 13 获取模型列表

2. 6 步骤 OAuth 自动化详解

2.1 Step 1: 获取 OAuth 参数

// 1. 请求 GET /api/v1/users/login
// 响应 302 → Location: https://baizhi.cloud/oauth/authorize?client_id=monkeycode-ai&...
export async function startOAuthLogin(): Promise<{
  oauthUrl: string
  state: string
  clientId: string
  redirectUri: string
  scope: string
}> {
  const resp = await fetch(`${MONKEYCODE_BASE_URL}/api/v1/users/login`, {
    headers: mkHeaders(),
    redirect: "manual",  // ← 关键:不自动跟随 302
  })

  if (resp.status !== 302) {
    throw new Error(`Expected 302 redirect, got ${resp.status}`)
  }

  const location = resp.headers.get("Location") || ""
  const url = new URL(location)
  return {
    oauthUrl: location,
    state: url.searchParams.get("state") || "",
    clientId: url.searchParams.get("client_id") || "",
    redirectUri: url.searchParams.get("redirect_uri") || "",
    scope: url.searchParams.get("scope") || "",
  }
}

请求/响应示例:

GET /api/v1/users/login HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...
Accept: application/json, text/plain, */*
Sec-Fetch-Site: same-origin
Origin: https://monkeycode-ai.com

← HTTP/1.1 302 Found
Location: https://baizhi.cloud/oauth/authorize
  ?client_id=monkeycode-ai
  &redirect_uri=https://monkeycode-ai.com/api/v1/users/baizhi/callback
  &response_type=code
  &scope=user+phone
  &state=550e8400-e29b-41d4-a716-446655440000

2.2 Step 2: 获取 SCaptcha Token(含 TLS 绕过)

// 2. 获取 SCaptcha 验证码 token
export async function getSCaptchaToken(): Promise<string> {
  // ⚠️ 安全风险:临时禁用 TLS 证书验证
  const originalTlsSetting = process.env.NODE_TLS_REJECT_UNAUTHORIZED
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"

  let resp: Response
  try {
    resp = await fetch(`${SCAPTCHA_API}/v1/api/challenge`, {
      method: "POST",
      headers: scHeaders(),
      body: JSON.stringify({ business_id: SCAPTCHA_BUSINESS_ID }),
    })
  } finally {
    // 恢复原始 TLS 设置
    if (originalTlsSetting === undefined) {
      delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
    } else {
      process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsSetting
    }
  }

  const data = await resp.json() as any
  if (!data.success) {
    throw new Error(`SCaptcha failed: ${JSON.stringify(data)}`)
  }
  return data.data?.token || ""
}

SCaptcha 请求/响应:

POST /v1/api/challenge HTTP/1.1
Host: 0196c95c-620c-7cde-9c2d-b10d0faf5583.safepoint.s-captcha-r1.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...
Content-Type: application/json

{"business_id": "0196c95c-620c-7cde-9c2d-b10d0faf5583"}

 HTTP/1.1 200 OK
{"success": true, "data": {"token": "sc_captcha_token_xxx"}}

2.3 Step 3: 发送短信验证码

// 3. 向百智云发送短信验证码
export async function sendSmsCode(phone: string, captchaToken: string): Promise<boolean> {
  const resp = await fetch(`${BAIZHI_URL}/api/v1/user/phone_code`, {
    method: "POST",
    headers: bzHeaders({ "Content-Type": "application/json" }),
    body: JSON.stringify({
      phone,
      kind: "login",
      token: captchaToken,
    }),
  })

  const data = await resp.json() as any
  if (data.code !== 0) {
    throw new Error(`SMS send error: code=${data.code}, msg=${data.message}`)
  }
  return true
}

请求示例:

POST /api/v1/user/phone_code HTTP/1.1
Host: baizhi.cloud
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...
Origin: https://baizhi.cloud
Sec-Fetch-Site: same-origin
Content-Type: application/json

{"phone": "138xxxx8888", "kind": "login", "token": "sc_captcha_token_xxx"}

 HTTP/1.1 200 OK
{"code": 0, "message": "success"}

2.4 Step 4: 百智云手机号登录

// 4. 用短信验证码登录百智云,获取百智云 Session Cookie
async function baizhiPhoneLogin(
  phone: string, code: string
): Promise<{ cookies: string; data: any }> {
  const resp = await fetch(`${BAIZHI_URL}/api/v1/user/login/phone`, {
    method: "POST",
    headers: bzHeaders({ "Content-Type": "application/json" }),
    body: JSON.stringify({ phone, code }),
  })

  if (!resp.ok) throw new Error(`百智云 login failed: ${resp.status}`)

  const data = await resp.json() as any
  if (data.code !== 0) {
    throw new Error(`百智云 login error: code=${data.code}, msg=${data.message}`)
  }

  // 从 Set-Cookie 中提取所有 Cookie
  const setCookie = resp.headers.get("Set-Cookie") || ""
  const cookies: string[] = []
  for (const part of setCookie.split(",")) {
    const match = part.trim().match(/^([^=]+)=([^;]+)/)
    if (match) cookies.push(`${match[1]}=${match[2]}`)
  }

  return { cookies: cookies.join("; "), data: data.data }
}

2.5 Step 5: OAuth 授权

// 5. 用百智云 Session 请求 OAuth 授权 → 获取 authorization code
async function baizhiOAuthAuthorize(
  baizhiCookies: string,
  clientId: string, redirectUri: string,
  scope: string, state: string
): Promise<{ code: string; callbackUrl: string }> {
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope, state,
    response_type: "code",
  })

  const resp = await fetch(`${BAIZHI_URL}/api/v1/oauth/authorize?${params}`, {
    headers: {
      ...bzHeaders(),
      Cookie: baizhiCookies,
      Accept: navHeaders("baizhi.cloud").Accept,
      "Sec-Fetch-Dest": "document",
      "Sec-Fetch-Mode": "navigate",
      "Upgrade-Insecure-Requests": "1",
    },
    redirect: "manual",
  })

  // 百智云返回 302 → Location 中携带 code
  const location = resp.headers.get("Location") || ""
  const url = new URL(location)
  const code = url.searchParams.get("code") || ""
  if (!code) {
    const error = url.searchParams.get("error") || "unknown"
    throw new Error(`OAuth authorize failed: error=${error}`)
  }

  return { code, callbackUrl: location }
}

关键请求头转换: Step 5 中使用了 navHeaders()AcceptSec-Fetch-* 值,这是因为 OAuth 授权请求模拟的是浏览器页面跳转行为(而非 XHR 请求):

请求头 XHR 请求(bzHeaders) 导航请求(navHeaders)
Accept application/json text/html,application/xhtml+xml,...
Sec-Fetch-Dest empty document
Sec-Fetch-Mode cors navigate
// 6. 用 OAuth code 换取 MonkeyCode Session Cookie
async function monkeycodeCallback(callbackUrl: string): Promise<string> {
  const resp = await fetch(callbackUrl, {
    headers: navHeaders("monkeycode-ai.com", {
      "Sec-Fetch-Site": "cross-site",
      Referer: "https://baizhi.cloud/",     // ← Referer 指向百智云(跨站跳转来源)
    }),
    redirect: "manual",
  })

  // 尝试从 Set-Cookie 直接提取
  const setCookie = resp.headers.get("Set-Cookie") || ""
  const match = setCookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`))
  if (match) return match[1]

  // 如果回调返回重定向(302),跟随重定向后查找 Cookie
  const location = resp.headers.get("Location") || ""
  if (location) {
    const resp2 = await fetch(
      location.startsWith("http") ? location : `${MONKEYCODE_BASE_URL}${location}`,
      { headers: mkHeaders(), redirect: "manual" }
    )
    const match2 = resp2.headers.get("Set-Cookie") || ""
      .match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`))
    if (match2) return match2[1]
  }

  throw new Error("Failed to extract session cookie from callback")
}

回调流程的双重策略:

OAuth 回调 URL(形如 https://monkeycode-ai.com/api/v1/users/baizhi/callback?code=xxx&state=yyy)
  ├── 方案 A:直接提取 Set-Cookie(最终响应包含 Cookie)
  │   └── 成功 → 返回 session cookie
  └── 方案 B:响应是 302 → 跟随重定向 → 提取最终 Cookie
      └── 成功 → 返回 session cookie
      └── 失败 → 抛出异常

3. OAuth 会话状态管理

3.1 会话结构

export interface OAuthSession {
  phone: string           // 手机号
  state: string           // CSRF 保护 state 参数
  clientId: string        // OAuth client_id
  redirectUri: string     // OAuth 回调 URL
  scope: string           // 授权范围
  baizhiCookies: string   // 百智云会话 Cookie
  createdAt: number       // 创建时间戳(用于超时判断)
}

// 全局单例会话(一次只能有一个进行中的登录流程)
let currentOAuthSession: OAuthSession | null = null

3.2 10 分钟超时保护

// completeLogin() 中的超时检查
export async function completeLogin(smsCode: string): Promise<{...}> {
  if (!currentOAuthSession) {
    throw new Error("No pending login session. Call send-code first.")
  }

  if (Date.now() - currentOAuthSession.createdAt > 10 * 60 * 1000) {
    currentOAuthSession = null                          // 清除过期会话
    throw new Error("Login session expired. Request new SMS code.")
  }

  // ... 执行登录流程 ...
  currentOAuthSession = null                            // 成功后清除
}

4. 辅助功能:登录后的自动发现

4.1 Image ID 自动发现

export async function discoverImageId(sessionCookie: string): Promise<{...} | null> {
  // 从最近的 5 个任务中查找 image_id
  const resp = await fetch(
    `${MONKEYCODE_BASE_URL}/api/v1/users/tasks?page=1&size=5`,
    { headers: mkHeaders({ Cookie: `${SESSION_COOKIE_NAME}=${sessionCookie}` }) }
  )

  const data = await resp.json() as any
  const tasks = data.data?.tasks || []

  for (const task of tasks) {
    if (task.image?.id) {
      return { imageId: task.image.id, imageName: task.image.name || "unknown" }
    }
  }
  return null
}

4.2 验证复用

// verifySession 和 discoverModels 被 completeLogin 串联调用:
return {
  sessionCookie,          // 必须
  imageId,                // 可选(来自任务列表)
  imageName,              // 可选
  models,                 // 可选(模型列表)
  user,                   // 可选(用户信息)
}

5. 完整的 6 步时序图

代理 (admin-login.ts)               MonkeyCode API                  百智云 API                 SCaptcha
  │                                    │                              │                         │
  │  initiateLogin(phone)               │                              │                         │
  │────────────────────────────────────►│                              │                         │
  │  GET /api/v1/users/login             │                              │                         │
  │◄──────── 302 + Location ──────────│                              │                         │
  │                                    │                              │                         │
  │                                    │                              │                         │
  │                                    │              POST /v1/api/challenge                   │
  │                                    │────────────────────────────────────────────────────────►│
  │                                    │                              │                  ◄── token │
  │                                    │                              │                         │
  │                                    │     POST /api/v1/user/phone_code                       │
  │                                    │─────────────────────────────►│                         │
  │                                    │◄────────── { code:0 } ─────│                         │
  │◄──────── { state, msg } ──────────│                              │                         │
  │                                    │                              │                         │
  │  completeLogin(code)                │                              │                         │
  │                                    │                              │                         │
  │                                    │     POST /api/v1/user/login/phone                      │
  │                                    │─────────────────────────────►│                         │
  │                                    │◄── { code:0 } + baizhi Cookie                         │
  │                                    │                              │                         │
  │                                    │     GET /api/v1/oauth/authorize?code=...               │
  │                                    │─────────────────────────────►│                         │
  │                                    │◄── 302 + Location(callback)◄                          │
  │                                    │                              │                         │
  │  GET callback?code=xxx&state=yyy    │                              │                         │
  │────────────────────────────────────►│                              │                         │
  │◄── Set-Cookie: monkeycode_ai_session│                              │                         │
  │                                    │                              │                         │
  │  discoverImageId(cookie)            │                              │                         │
  │────────────────────────────────────►│                              │                         │
  │◄── { imageId: "...", ... } ───────│                              │                         │
  │                                    │                              │                         │
  │  discoverModels(cookie)             │                              │                         │
  │────────────────────────────────────►│                              │                         │
  │◄── [{ model, provider, ... }] ────│                              │                         │

6. 安全分析

6.1 SCaptcha Token 的安全风险

// ⚠️ TLS 绕过:请求 SCaptcha 时禁用证书验证
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"
问题 影响 根本原因
TLS 证书验证被禁用 MITM 攻击可拦截 SCaptcha 流量 SCaptcha 端点证书链可能不完整
NODE_TLS_REJECT_UNAUTHORIZED 修改是全局的 可能影响其他并发请求 使用了 process.env 而非 request-level 的 rejectUnauthorized
错误恢复实现正确 try/finally 确保恢复 正确使用了原始值保存+恢复模式

6.2 OAuth 会话安全

安全方面 当前状态 评估
state 参数(CSRF 防护) 使用随机 UUID ✅ 有效
10 分钟会话超时 硬编码在 completeLogin ✅ 有限窗口
Session Cookie 内存存储 仅内存,不落盘 ✅ 不持久化
短信验证码使用限制 依赖后端限流 🟡 后端检测
授权码(code)一次性 依赖 OAuth 协议保证 ✅ OAuth 标准
Referer 头伪造 navHeaders 正确设置跨站 Referer

6.3 潜在攻击面

攻击向量: 短信轰炸
  场景: 反复调用 initiateLogin() 触发发送短信
  影响: 手机号收到大量短信
  缓解: 应用层需限制 SMS 发送频率

攻击向量: 会话固定
  场景: state 参数虽然随机,但 OAuth 会话存储在全局变量中
  影响: 如果多个登录流程并发,会互相覆盖
  缓解: 当前为顺序设计,不支持并发登录

7. 与 Playwright 方案的对比

维度 HTTP 自动化(admin-login.ts) Playwright 方案(oauth_login.py)
依赖 零额外依赖(纯 fetch) 需要 Playwright + Chromium
速度 快(毫秒级 HTTP 请求) 慢(需要启动浏览器 + 页面渲染)
浏览器指纹 手动构造请求头 真实浏览器指纹
验证码处理 SCaptcha token 直接获取 需用户手动干预
稳定性 高(纯协议交互) 中(依赖页面元素选择器)
适用场景 无头服务器、自动化脚本 需要人工交互的复杂流程

相关章节