本文记录 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 兼容)
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。
注册客户端时的 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;踩坑代价:整整一轮排查。)
如果浏览器里已有统一身份认证会话,/oauth2/signin 会直接 SSO 完成登录并跳回调,
完全不需要点那个按钮。这解释了一个常见困惑:
- 第一次登录成功 → 其实是复用了已有的 CAS 会话(没碰按钮)
- 过一阵再登录失败 → CAS 会话过期,需要点按钮 → 而按钮因
client_type问题点不动
排查时先确认「浏览器里 CAS 会话是否有效」,再看按钮行为。
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=
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)
浏览器登录需要人工点一次;无人值守场景(服务器/定时任务)用这条链路:
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)。
适用:认证完全交给 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执行),避免内置第三方代码,也永远跟得上学校改版。
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=...。容易误判成"认证没通过"。
用子串匹配 /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(语义未知,未继续) |
实测(山东大学):
access_token = ory_at_... 有效期 3599 秒 ≈ 60 分钟
refresh_token = ory_rt_... 长期有效(Hydra TTL)
- 续期不需要重新登录:
POST /oauth2/token+grant_type=refresh_tokenclient_secret_basic,实测 135 ms,无需密码 / 浏览器 / CAS
⚠️ refresh_token 会轮换:每次续期服务端下发新的 refresh_token,旧的立即作废, 必须把新值存回配置(本项目applyToken()已处理)- 多机部署禁忌:两台机器不能共用同一份 config.json ——
A 机器续期后 B 机器手里的 refresh_token 就失效了,B 会突然要求重新登录。
正确做法:每台机器各自执行一次
asy login --cas(全自动、设备已信任、免短信), 各自拥有独立的 token 链。
Authorization: Bearer <access_token>
X-Requested-With: XMLHttpRequest未认证时返回:
{"cause":"access token校验失败...","code":401001001,"message":"Access Token无效或已过期。"}401001001 = token 过期 → 用 refresh_token 换新的。
| 用途 | 方法 & 路径 | 请求体 |
|---|---|---|
| 用户信息 | 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)收尾,否则文件不落库。
AnyShare 是通用产品,大概率你学校的网盘也是同一套架构。适配步骤:
# 打开你的网盘网页,看 HTML 里有没有这些特征:
# id="__anyshare" / webpackJsonp@anyshare/web → 是 AnyShare
curl -s https://<你的网盘域名>/anyshare/ | grep -o '__anyshare'# 前端前缀(通常是 /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/ 或其他前缀下,逐个试# 注意:路径是根路径 /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 步
asy register --port 8899 # 或手动改 config.json 的 baseUrl
asy login
asy ls可选方案(欢迎实现后 PR):
- 复用官方客户端的已注册凭据:桌面客户端首次连接服务器时会注册并保存
client_id/client_secret到本地配置 (Windows:%APPDATA%\<品牌名>\config.json的lastConnectedDomain字段)。 缺点:其redirect_uris是anyshare://...私有协议,授权码回调不好接。 - 从浏览器 Cookie 导入 refresh_token:网页端把 token 放在 Cookie 里
(
client.oauth2_refresh_token、Authorization=Bearer ...)。asy login --cookie "<从 DevTools 复制的 document.cookie>"即可。 但用官方 client 的 refresh_token 去刷新同样需要它的 secret。 - 模拟 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本文与仓库代码仅用于个人学习与合法的个人数据管理。请遵守所在学校的 服务条款与网络安全规定,不要用于高频轮询、批量抓取或任何滥用行为。