Skip to content

Latest commit

 

History

History
473 lines (358 loc) · 21.1 KB

API-notes.md

File metadata and controls

473 lines (358 loc) · 21.1 KB

AnyShare API 逆向笔记 & 其他高校适配指南

本文记录 AnyShare(爱数文档云) 的 HTTP API 结构,以及如何把它适配到其他高校的 AnyShare 实例。 所有结论均在 山东大学 icloud.sdu.edu.cn(AnyShare 7.0.6) 上实测验证。

⚠️ 本文是逆向分析的产物,非官方文档。接口可能随部署版本变化,请以实测为准。


一、整体架构

AnyShare 7 的部署形态(各高校基本一致):

┌────────────┐   443/HTTPS   ┌──────────────────────────────────────────┐
│  Web 前端   │ ────────────▶ │  nginx 网关                              │
│ (React SPA)│               │   ├─ /anyshare/*    前端 SPA(含兜底)    │
└────────────┘               │   ├─ /api/*         业务 API(EFS/EACP…) │
┌────────────┐               │   ├─ /oauth2/*      OAuth2 服务(ORY Hydra)│
│ 桌面客户端  │ ────────────▶ │   └─ /bucket0x/*    对象存储(Ceph RGW/S3) │
│ (Electron) │               └──────────────────────────────────────────
└────────────┘

要点:

  • 前端是 React SPA,路径前缀通常是 /anyshare/,但 API 在根路径 /api/ (/anyshare/api/... 会被 SPA 兜底返回 HTML,是常见陷阱)
  • OAuth2 由 ORY Hydra 提供(login_challenge 参数是其标志)
  • 文件正文不经过业务 API,而是由 API 下发带签名的对象存储直链(S3 兼容)

二、认证

2.1 客户端注册(关键!)

AnyShare 桌面客户端/网页端都是动态注册自己的 OAuth2 客户端的(所以代码里没有硬编码 secret)。 我们也可以注册一个属于自己的客户端:

POST {baseUrl}/oauth2/clients
Content-Type: application/json

{
  "grant_types": ["authorization_code", "refresh_token", "implicit"],
  "response_types": ["token id_token", "code", "token"],
  "scope": "offline openid all",
  "redirect_uris": ["http://127.0.0.1:8899/callback"],     ← 填自己的回调!
  "post_logout_redirect_uris": ["http://127.0.0.1:8899/logout"],
  "client_name": "asy-cli",
  "metadata": {
    "device": {
      "name": "RichClient",
      "client_type": "windows",       ← 枚举:unknown/ios/android/windows_phone/windows/mac_os/web/mobile_web/console_web/deploy_web/linux
      "description": "asy-cli"
    }
  },
  "login_form": { "remember_password_visible": true }
}

成功返回 201:

{ "client_id": "<你的 client_id>", "client_secret": "<你的 client_secret>" }

💡 把 redirect_uris 填成 http://127.0.0.1:<端口>/callback, 就能在 CLI 里起一个本地 HTTP 服务器自动接收授权码,无需手动复制回调 URL。

⚠️ client_type 决定「统一身份认证」按钮的行为(大坑)

注册客户端时的 metadata.device.client_type 不只是个标签,它会改变登录页上第三方登录按钮的行为:

client_type 按钮点击后 在普通浏览器里的表现
windows / mac_os(桌面客户端) window.parent.postMessage({msg:"OauthUIOpenExternal", data:<CAS地址>}, "*") —— 把"打开浏览器"的活交给桌面客户端外壳 ❌ 点了毫无反应(浏览器里没有父窗口接收这条消息)
web / mobile_web(浏览器) top.location.href = <CAS地址> 直接跳转 ✅ 正常打开统一身份认证页

登录页前端源码(对应逻辑):

H = ("windows" === client.client_type);            // 是否桌面客户端
onClick = () => {
  document.cookie = "is_previous_login_3rd_party=true; ...";
  top && (H ? window.parent.postMessage({ msg: "OauthUIOpenExternal", data: cfg.authServer }, "*")
            : top.window.location.href = cfg.authServer,
          document.cookie = "login_challenge=" + challenge + "; ...");
};

结论:CLI / 浏览器流程注册客户端时必须用 client_type: "web"。 (本 CLI 的 registerClient() 默认已改为 web;踩坑代价:整整一轮排查。)

相关机制:CAS 会话会让登录「看起来自动完成」

如果浏览器里已有统一身份认证会话,/oauth2/signin 会直接 SSO 完成登录并跳回调, 完全不需要点那个按钮。这解释了一个常见困惑:

  • 第一次登录成功 → 其实是复用了已有的 CAS 会话(没碰按钮)
  • 过一阵再登录失败 → CAS 会话过期,需要点按钮 → 而按钮因 client_type 问题点不动

排查时先确认「浏览器里 CAS 会话是否有效」,再看按钮行为。

2.2 授权(浏览器 + 统一身份认证)

GET {baseUrl}/oauth2/auth
      ?client_id=<刚注册的>
      &response_type=code
      &scope=offline+openid+all
      &redirect_uri=http://127.0.0.1:8899/callback
      &state=<至少8个字符,否则报 invalid_state>
      &lang=zh-cn

跳转链:/oauth2/auth → 302 /oauth2/signin?login_challenge=... → 统一身份认证(CAS) → 回调带 ?code=

2.3 换取 token(必须 client_secret_basic)

POST {baseUrl}/oauth2/token
Authorization: Basic base64(client_id:client_secret)      ← 只能用 Basic!
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=<授权码>&redirect_uri=<注册时的>

返回 { access_token, refresh_token, id_token, expires_in, token_type, scope }

刷新:

POST {baseUrl}/oauth2/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=<...>

踩坑记录:

  • 在 body 里传 client_secret(client_secret_post)会报 invalid_client ... supports client_secret_basic, but method client_secret_post was requested
  • redirect_uri 必须与注册时逐字符一致
  • /oauth2/auth 只接受 GET(HEAD 会 405)
  • state 少于 8 字符会被拒(invalid_state)

2.4 无浏览器登录(学号 + 密码,全自动)

浏览器登录需要人工点一次;无人值守场景(服务器/定时任务)用这条链路:

1. GET /oauth2/auth?...&redirect_uri=<你的回调>&state=<≥8字符>
   → 200 登录页 HTML(约 1.2MB,Next.js 应用)
   从 HTML 提取:  "challenge":"<hex>"  和  "csrftoken":"<token>"
   ️ 必须保存本次响应的 Set-Cookie(后续 CSRF 校验依赖会话 Cookie)

2. RSA 加密密码:encrypt(password, RSAES-PKCS1-V1_5) → base64
   ⚠️ 用「2048 位」公钥!(见下方"两把钥匙")

3. POST /oauth2/signin   (JSON,需带步骤 1 的 Cookie)
   {
     "_csrf": "<csrftoken>", "challenge": "<challenge>",
     "account": "<学号>", "password": "<base64 密文>",
     "vcode": {"id":"", "content":""},
     "dualfactorauthinfo": {"validcode":{"vcode":""}, "OTP":{"OTP":""}},
     "remember": false,
     "device": {"name":"RichClient","description":"RichClient for windows",
                "client_type":"windows","udids":["<MAC 形式设备号>"]}
   }
   → {"redirect": "<url>"}

4. 跟随 redirect(手动模式,读 Location 头,直到出现 code=)→ 提取授权码
   (不需要真的监听回调端口——只要从 Location 里读出 code 即可)

5. POST /oauth2/token (client_secret_basic) 换 token

实测错误对照:

现象 原因
403 csrf验证未通过 没带登录页的会话 Cookie
RSA_private_decrypt error 用错公钥(用了 1024 位那把)
401 请输入验证码 服务端要求验证码(账号错误或该校强制验证码)

⚠️ 两把钥匙(重要坑)

登录页 JS 里嵌了两把 RSA 公钥(publicKeyFromPem(...)):

位数 用途 前端变量
2048 位 /oauth2/signin 提交登录密码 ee
1024 位 /eacp/v1/auth1/sendauthvcode(短信验证码) $

用错钥匙的典型症状就是服务端 RSA_private_decrypt error。 各校部署的钥匙可能不同,可以自动发现:抓取 /oauth2/auth 页面 → 下载其中引用的 JS (Next.js chunk)→ 正则 publicKeyFromPem("...") → 取模数最大的那把。 本 CLI 的实现见 discoverPublicKey()(asy login --password --discover-key)。

参考实现:Fucov/Pansh(北航,Python;其公钥按 profile 手动配置为 pubkey)。

2.6 统一身份认证(CAS)全自动登录

适用:认证完全交给 CAS 的学校(AnyShare 侧没有本地密码,原生表单登录必然报"用户名或密码不正确")。 山东大学实测通过:全流程无需浏览器、无需点击、新设备只需一次短信验证。

1. GET /oauth2/auth            【手动逐跳跟随重定向,每跳都吸收 Set-Cookie】
   ← 第一个 302 会下发 ory_hydra_login_csrf_<random>(关键!见下方坑 1)
   从最终 URL 取 login_challenge
2. 写入 Cookie: login_challenge=<值> 和 is_previous_login_3rd_party=true
   (浏览器在点击「统一身份认证登录」按钮时写入;服务端靠它们恢复 OAuth 会话)
3. GET <CAS>/cas/login?service=<base>/oauth2/signin   → lt / execution + CAS Cookie
4. 凭据只藏在 rsa 字段:
   rsa = strEnc(用户名 + 密码 + lt, '1', '2', '3')     # 学校 des.js 的 DES 实现
   ul  = 用户名长度   pl = 密码长度                        # 服务端解密后据此切分
   ⚠️ 用户名/密码【不作为独立字段提交】(页面提交前把输入框 disabled)
5. POST <CAS>/cas/device  m:'1' + 完整设备指纹    → 查设备状态
6. 需要短信时:m:'2' 触发发送 → 用户输入 → m:'3' {u, c:短信码, s:1 信任设备}
7. POST <CAS>/cas/login(表单字段 rsa/ul/pl/lt/execution/_eventId)
   → 302 Location: <base>/oauth2/signin?ticket=ST-xxx
8. 带 ticket 回到 /oauth2/signin(带步骤 1-2 的 Cookie)
   → 302 /oauth2/auth?...login_verifier=...   (Hydra 校验 CSRF Cookie)
   → 302 /oauth2/consent?consent_challenge=...  → 302 /oauth2/auth?...consent_verifier=...
   → 303 <redirect_uri>?code=ory_ac_...        ✅ 拿到授权码
9. POST /oauth2/token (client_secret_basic, grant_type=authorization_code) → token

💡 实现要点:CAS 的 strEnc 直接从学校服务器加载(<CAS>/cas/comm/js/des.js, 在 Node 里用 vm 执行),避免内置第三方代码,也永远跟得上学校改版。

⚠️ 坑 1:fetch 自动重定向会丢 Cookie(本项目最大的一坑)

Node 的 fetch 没有 Cookie 罐:用 fetch(url, { redirect: 'follow' }) 时, 中间跳的 Set-Cookie 会被直接丢弃,而 Hydra 的 CSRF Cookie (ory_hydra_login_csrf_<random>,Path=/; HttpOnly; Secure; SameSite=None) 正是在第一个 302 里下发的。丢了它,第 8 步会得到:

request_forbidden: The request is not allowed.
No CSRF value available in the session cookie.

正确做法:手动逐跳跟随,每一跳都吸收 Cookie:

let cur = authUrl;
for (let hop = 0; hop < 6 && cur; hop++) {
  const r = await fetch(cur, { redirect: 'manual', headers: { Cookie: jar.header() } });
  jar.absorb(r);                       // ✅ 每跳都收 Set-Cookie
  const loc = r.headers.get('location');
  if (!loc) break;
  cur = new URL(loc, cur).toString();
}

这个坑的迷惑性在于:认证其实早就成功了(CAS 票据有效、服务端也发了 303 回回调), 只是回调用 error=request_forbidden 代替了 code=...。容易误判成"认证没通过"。

⚠️ 坑 2:设备状态字符串 bind vs binded

用子串匹配 /bind/i.test(info) 会把 binded(已信任)误判为需要短信, 然后去调 m:'2' 发短信(已信任设备会返回 500)并卡住。必须精确匹配。

设备验证(短信)状态机(实测)

请求 响应 info 含义
m:'1' bind + m:'139*****3662' 新设备,需要短信验证(附屏蔽手机号)
m:'1' binded 该设备已被信任,可跳过短信
m:'2' send 短信已发送
m:'2' max 发送过于频繁(服务端限流)
m:'3' most 验证通过(授信设备达上限,服务端自动解除最早一台)
m:'3' codeErr 验证码错误(发新短信后旧码立即作废)
m:'3' timeout 验证码已超时(约 5 分钟有效)

m:'1' 与 m:'3' 的字段形式不同(容易写错):

// m:'1' —— i 是【加密】的,且需要 d_s / d_md5 / d_browser_md5
{ d: murmur, d_s: murmurS, d_md5: strEnc(...), d_browser_md5: strEnc(...),
  i: strEnc(detailsS), m: '1', u: strEnc(user), p: strEnc(pwd) }

// m:'3' —— d 用【murmurS】,i 用【明文】detailsS,u 用明文
{ d: murmurS, i: detailsS, m: '3', u: user, c: 短信码, s: 1 /* 信任此设备 */ }

浏览器端用 Fingerprint2 生成 murmur/murmurS/detailsS;Node 端用确定性值即可 (本项目用 md5(账号 + '|asy-cli') 派生),且因为是确定性的, 同一账号在多台机器上会被识别为同一台设备——设备信任可"随身携带"。

被学校关闭的接口(排查记录)

接口 结果
POST /api/eacp/v1/auth1/getbyticket {ticket, service} 403 第三方认证功能未开启(学校关闭,客户端 SSO 用不了)
POST /api/authentication/v1/sso 400 body.credential is required(语义未知,未继续)

2.7 token 生命周期与轮换(多机部署必读)

实测(山东大学):

access_token  = ory_at_...   有效期 3599 秒 ≈ 60 分钟
refresh_token = ory_rt_...   长期有效(Hydra TTL)
  • 续期不需要重新登录:POST /oauth2/token + grant_type=refresh_token
    • client_secret_basic,实测 135 ms,无需密码 / 浏览器 / CAS
  • ⚠️ refresh_token 会轮换:每次续期服务端下发新的 refresh_token,旧的立即作废, 必须把新值存回配置(本项目 applyToken() 已处理)
  • 多机部署禁忌:两台机器不能共用同一份 config.json —— A 机器续期后 B 机器手里的 refresh_token 就失效了,B 会突然要求重新登录。 正确做法:每台机器各自执行一次 asy login --cas(全自动、设备已信任、免短信), 各自拥有独立的 token 链。

2.8 调用 API

Authorization: Bearer <access_token>
X-Requested-With: XMLHttpRequest

未认证时返回:

{"cause":"access token校验失败...","code":401001001,"message":"Access Token无效或已过期。"}

401001001 = token 过期 → 用 refresh_token 换新的。

三、文件 API(/api/efast/v1/...)—— 已全部实测

用途 方法 & 路径 请求体
用户信息 GET /api/eacp/v1/user/get —
个人文档根 GET /api/efast/v1/classified-entry-doc-libs — (取 type=user_doc_lib)
共享文档库 GET /api/efast/v1/entry-doc-lib —
列目录 GET /api/efast/v1/folders/{docid}/sub_objects?limit=N&sort=name&direction=asc — (limit 必填)
新建目录 POST /api/efast/v1/dir/create {docid:父, name, ondup:1}
删除文件 POST /api/efast/v1/file/delete {docid}
删除目录 POST /api/efast/v1/dir/delete {docid, check_upload_process:false}
重命名 POST /api/efast/v1/file/rename {docid, name, ondup}(目录也走 file/rename)
移动 POST /api/efast/v1/file/move {docid, destparent, ondup}
复制 POST /api/efast/v1/file/copy {docid, destparent, ondup}
下载 POST /api/efast/v1/file/osdownload {docid, authtype:"1", savename, usehttps:true, rev?}
上传-开始 POST /api/efast/v1/file/osbeginupload {client_mtime, docid:目标目录, length, name, ondup, reqmethod:"POST"}
上传-结束 POST /api/efast/v1/file/osendupload {docid, rev, csflevel}
分片上传 osinitmultiupload / osuploadpart / oscompleteupload 大文件用(本 CLI 暂未实现)

踩坑记录:

  • 用户会话下 不要用 /efast/v1/entry-item —— 那是外链分享场景的接口, 会返回 403 不支持的用户类型(错误提供者:EFSHttpServer,错误值:1809)
  • 删除/重命名/移动/复制都只接受单个 docid 字符串(不是数组)
  • copy / move 不能指定新名字(只有 docid + destparent + ondup): 同目录操作要用 ondup=2(自动改名),再单独调 rename 改成想要的名字
  • 目录与文件在 sub_objects 里通过 size === -1(目录)区分;id 是完整 GNS 路径 (形如 gns://<doclib>/<folder>/<file>)

四、文件传输(对象存储直链 + 签名头)

下载

osdownload 返回:

{
  "authrequest": [
    "GET",
    "https://<host>:443/bucket02/<...>/<rev>",
    "Authorization: AWS as01:<签名>",          ← 必须原样携带!
    "x-amz-date: Wed, 16 Sep 2026 12:22:20 GMT" ← 必须原样携带!
  ]
}
  • 不带签名头 → 403 RequestTimeTooSkewed(client date time:0)
  • 用自己的时间头 → 403 AccessDenied can not found "authorization" or "Signature"
  • ✅ 正确做法:把 authrequest[2:] 里的条目解析成请求头,原样发送

上传

osbeginupload 同样返回 authrequest,格式为 [method, url, ...entries],其中 entry 可能是 "Header: value" 或 "field=value":

  • method = PUT → 直接把文件流 PUT 上去,带解析出的头
  • method = POST → multipart/form-data,文件字段名固定为 file,其余条目作为表单字段

上传完成后必须调 osendupload(带 docid/rev/csflevel)收尾,否则文件不落库。

五、如何适配你的学校(欢迎 PR!)

AnyShare 是通用产品,大概率你学校的网盘也是同一套架构。适配步骤:

第 1 步:确认底座

# 打开你的网盘网页,看 HTML 里有没有这些特征:
#   id="__anyshare" / webpackJsonp@anyshare/web   → 是 AnyShare
curl -s https://<你的网盘域名>/anyshare/ | grep -o '__anyshare'

第 2 步:找到 API 根与前端前缀

# 前端前缀(通常是 /anyshare/ 或空)
curl -sI https://<域名>/ | grep -i location

# API 根:这个应该返回 401(存在但需认证),返回 404/503 说明路径不对
curl -s -o /dev/null -w '%{http_code}\n' https://<域名>/api/efast/v1/classified-entry-doc-libs

# 有些部署把 API 放在 /anyshare/api/ 或其他前缀下,逐个试

第 3 步:注册你的 OAuth2 客户端

# 注意:路径是根路径 /oauth2/clients(不带 /api)
curl -s -X POST https://<域名>/oauth2/clients \
  -H 'Content-Type: application/json' \
  -d @register-client.json      # 见仓库内 templates/register-client.json
  • 返回 201 + client_id/client_secret → 完美,直接用本 CLI
  • 返回 404/403 → 该部署未开放客户端注册,退路见第 5 步

第 4 步:跑通并回填配置

asy register --port 8899     # 或手动改 config.json 的 baseUrl
asy login
asy ls

第 5 步:如果注册接口被关闭

可选方案(欢迎实现后 PR):

  1. 复用官方客户端的已注册凭据:桌面客户端首次连接服务器时会注册并保存 client_id/client_secret 到本地配置 (Windows:%APPDATA%\<品牌名>\config.json 的 lastConnectedDomain 字段)。 缺点:其 redirect_uris 是 anyshare://... 私有协议,授权码回调不好接。
  2. 从浏览器 Cookie 导入 refresh_token:网页端把 token 放在 Cookie 里 (client.oauth2_refresh_token、Authorization=Bearer ...)。 asy login --cookie "<从 DevTools 复制的 document.cookie>" 即可。 但用官方 client 的 refresh_token 去刷新同样需要它的 secret。
  3. 模拟 CAS 登录(全自动但易碎):直接 POST 统一身份认证表单,跟随重定向拿 Cookie。

各高校可能有差异的地方

可能不同 说明 本 CLI 的应对
baseUrl 各校域名/端口不同 asy config set baseUrl <...> 或 ASY_BASE_URL
前端前缀 可能不是 /anyshare 仅影响浏览器登录地址,API 路径一般不变
API 根 极少数部署改过 asy debug 逐条试
登录方式 多数走 CAS,少数可能本地账号 浏览器流程均适用
对象存储 Ceph RGW / 其他 S3 兼容 已按 authrequest 通用解析,一般不敏感
客户端注册 可能被管理员关闭 见第 5 步

六、调试工具

本 CLI 内置原始请求调试:

asy debug /efast/v1/classified-entry-doc-libs
asy debug /efast/v1/file/osdownload --method POST --json '{"docid":"...","authtype":"1","savename":"a.txt","usehttps":true}'
asy --debug ls          # 所有命令都支持 --debug

七、免责声明

本文与仓库代码仅用于个人学习与合法的个人数据管理。请遵守所在学校的 服务条款与网络安全规定,不要用于高频轮询、批量抓取或任何滥用行为。