magicrew-auth 0.3.16 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +34 -2
- package/CHANGELOG.md +280 -0
- package/docs/internals.md +155 -4
- package/docs/upstream-issue-3950.md +90 -0
- package/package.json +1 -1
- package/src/captcha.mjs +275 -0
- package/src/headless.mjs +287 -0
- package/src/hooks/loader.mjs +178 -93
- package/src/hooks/methods.mjs +223 -2
- package/src/hooks/usage.mjs +124 -22
- package/src/keys.mjs +48 -1
- package/src/vision.mjs +193 -0
package/src/captcha.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// 无头登录的底层:旋转验证码 + 手机验证码换取登录态
|
|
2
|
+
//
|
|
3
|
+
// 全部是**纯 HTTP**,不开浏览器、不读 localStorage。协议是从网页版
|
|
4
|
+
// `SMSVerificationCodeForm` chunk 里逆出来的,每一步都实测过
|
|
5
|
+
// (见 docs/internals.md「无头登录」一节):
|
|
6
|
+
//
|
|
7
|
+
// ① POST /captcha/generate { account_type, mobile, state_code }
|
|
8
|
+
// → { token, image(data:image/webp;base64,…), expire_time }
|
|
9
|
+
// ② 算角度 → angle = enc(deg, n)
|
|
10
|
+
// ③ POST /captcha/verify { token, angle, account_type, mobile, state_code }
|
|
11
|
+
// → { verified: true, business_token: "biz_…" }
|
|
12
|
+
// ④ POST /v4/user/send-sms { type, phone, state_code }
|
|
13
|
+
// + header `X-Captcha-Token: <business_token>`
|
|
14
|
+
// ⑤ POST /v4/user/login { type: "phone_captcha", phone, state_code, code, device }
|
|
15
|
+
//
|
|
16
|
+
// ⚠ 三个域名各管一段,别混:
|
|
17
|
+
// www.<站点> → 前端页面(只用来取 Origin,别的没用)
|
|
18
|
+
// i-keewood-service… → captcha / v4 用户中心(①②③④⑤ 全在这)
|
|
19
|
+
// 模型网关 → 与登录无关
|
|
20
|
+
//
|
|
21
|
+
// ⚠ 风控是真的(实测):
|
|
22
|
+
// · 「验证次数已超限」 同一 token 试错有上限
|
|
23
|
+
// · 「验证不存在或已过期」 expire_time 给 10 分钟
|
|
24
|
+
// · 「验证提交过于频繁」 连续批量提交会被拦
|
|
25
|
+
// ⇒ 不能枚举角度,一次要算准;失败就换图重来。
|
|
26
|
+
|
|
27
|
+
/** 服务端只认这一套 Unicode;错误码直接用数字,日志里好 grep */
|
|
28
|
+
export const CAPTCHA_CODE = {
|
|
29
|
+
OK: 1000,
|
|
30
|
+
BAD: 2000,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 用户中心的域名按站点分,**两站的命名规则还不一样**(别照抄前缀):
|
|
35
|
+
*
|
|
36
|
+
* 国内 www.letsmagic.cn → https://i-keewood-service.teamshare.cn ← 有 `i-`,根域是 teamshare.cn
|
|
37
|
+
* 国际 www.magicrew.ai → https://keewood-service.magicrew.ai ← 没有 `i-`
|
|
38
|
+
*
|
|
39
|
+
* 出处:桌面客户端内嵌配置里的 `MAGIC_SERVICE_KEEWOOD_BASE_URL`
|
|
40
|
+
* (`GET https://www.magicrew.ai/workspace` 的 HTML 里能直接读到)。
|
|
41
|
+
* 两个都实测过 `POST /captcha/generate` 回 200。
|
|
42
|
+
*/
|
|
43
|
+
export function keewoodOf(site) {
|
|
44
|
+
return site.id === "magicrew" ? "https://i-keewood-service.teamshare.cn" : "https://keewood-service.magicrew.ai"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 拖动角度 → 服务端要的 angle 字符串。
|
|
49
|
+
*
|
|
50
|
+
* 前端是这么算的(`SMSVerificationCodeForm` chunk,混淆过但结构清楚):
|
|
51
|
+
*
|
|
52
|
+
* Se(deg, n) = ((52249517 * (deg + n) + 5976906165) & 0xFFFFFFFF)
|
|
53
|
+
* angle = String(Se(deg, n)) + String(n) // n = Date.now() % 90000 + 10000
|
|
54
|
+
*
|
|
55
|
+
* 验证方式:抓浏览器真实提交的 4 组 angle,对每组正向穷举 deg 0…359,
|
|
56
|
+
* **每组都唯一命中**,且命中值与拖动像素严格成 1.6°/px(225px = 360°)。
|
|
57
|
+
* 所以这里照抄,别改系数。
|
|
58
|
+
*
|
|
59
|
+
* ⚠ deg 是「图片顺时针旋转的角度」,0…359。不是「还差多少度」。
|
|
60
|
+
*/
|
|
61
|
+
export function encodeAngle(deg, n) {
|
|
62
|
+
const d = ((BigInt(deg) % 360n) + 360n) % 360n
|
|
63
|
+
const nn = BigInt(n)
|
|
64
|
+
const v = (52249517n * (d + nn) + 5976906165n) & 0xffffffffn
|
|
65
|
+
return String(v) + String(n)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 服务端给的 n 是 5 位(10000…99999);我们自己造一个同样形状的 */
|
|
69
|
+
export function randomN() {
|
|
70
|
+
return Math.floor(Math.random() * 90000) + 10000
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 钟点 → deg。
|
|
75
|
+
*
|
|
76
|
+
* 判官回答的是「主体自己的上方向指向钟面的几点」(12=正上、3=正右)。
|
|
77
|
+
* 图要**顺时针**转多少度才立起来 = (12 - clock) * 30。
|
|
78
|
+
*/
|
|
79
|
+
export function clockToDeg(clock) {
|
|
80
|
+
const x = Number(clock)
|
|
81
|
+
if (!Number.isFinite(x)) return null
|
|
82
|
+
return Math.round((((12 - x) * 30) % 360 + 360) % 360)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 两个判官分歧多大(按钟点,取最短环距 0…6) */
|
|
86
|
+
export function clockSpread(a, b) {
|
|
87
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return 6
|
|
88
|
+
const d = Math.abs(((a - b) % 12 + 12) % 12)
|
|
89
|
+
return Math.min(d, 12 - d)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 分歧超过这个值就认为「有人把方向看反了」,宁可重来也不要瞎提交 */
|
|
93
|
+
export const CLOCK_MAX_SPREAD = 1.5
|
|
94
|
+
|
|
95
|
+
async function postJSON(url, body, { headers = {}, timeoutMs = 20_000 } = {}) {
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "content-type": "application/json", ...headers },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
})
|
|
102
|
+
const text = await res.text()
|
|
103
|
+
let json = null
|
|
104
|
+
try {
|
|
105
|
+
json = JSON.parse(text)
|
|
106
|
+
} catch {
|
|
107
|
+
/* 非 JSON 就当文本报出去 */
|
|
108
|
+
}
|
|
109
|
+
return { status: res.status, text, json }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** ① 取图。返回 null 表示服务端认为这个号**已经注册**,直接给了 business_token(跳过验证) */
|
|
113
|
+
export async function generateCaptcha(site, { mobile, stateCode = "+86" } = {}) {
|
|
114
|
+
const host = keewoodOf(site)
|
|
115
|
+
const { json } = await postJSON(`${host}/captcha/generate`, {
|
|
116
|
+
account_type: "mobile",
|
|
117
|
+
mobile,
|
|
118
|
+
state_code: stateCode,
|
|
119
|
+
}, { headers: { origin: site.web } })
|
|
120
|
+
|
|
121
|
+
const data = json?.data ?? {}
|
|
122
|
+
if (data.business_token) return { registered: true, businessToken: data.business_token }
|
|
123
|
+
if (!data.token || !data.image) {
|
|
124
|
+
throw new Error(`取验证码失败:${json?.message ?? "没有 token/image"}`)
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
registered: false,
|
|
128
|
+
token: data.token,
|
|
129
|
+
image: String(data.image),
|
|
130
|
+
expireAt: data.expire_time ? Number(data.expire_time) * 1000 : null,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ③ 提交角度 */
|
|
135
|
+
export async function verifyCaptcha(site, { token, deg, mobile, stateCode = "+86" }) {
|
|
136
|
+
const host = keewoodOf(site)
|
|
137
|
+
const n = randomN()
|
|
138
|
+
const { json } = await postJSON(`${host}/captcha/verify`, {
|
|
139
|
+
account_type: "mobile",
|
|
140
|
+
token,
|
|
141
|
+
angle: encodeAngle(deg, n),
|
|
142
|
+
mobile,
|
|
143
|
+
state_code: stateCode,
|
|
144
|
+
}, { headers: { origin: site.web } })
|
|
145
|
+
|
|
146
|
+
const data = json?.data ?? {}
|
|
147
|
+
if (data.verified && data.business_token) return { ok: true, businessToken: data.business_token }
|
|
148
|
+
return { ok: false, message: json?.message ?? "验证失败" }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* ④ 发短信。businessToken 缺了/假的都会被打回来,所以它必须是真的。
|
|
153
|
+
*
|
|
154
|
+
* ⚠ `type` 是 **`"account_login"`**,不是 `"login"`、也不是登录枚举里的 `phone_captcha`。
|
|
155
|
+
* 填错的表现是「服务异常,请联系管理员」(9999) —— 后端内部炸了,不是参数校验失败,
|
|
156
|
+
* 所以从错误信息上完全看不出是 type 的问题。这个值是从浏览器真实请求里抓到的:
|
|
157
|
+
* {"type":"account_login","phone":"199…","state_code":"+86"}
|
|
158
|
+
* ⇒ 别再按直觉改它。
|
|
159
|
+
*/
|
|
160
|
+
export async function sendSms(site, { phone, stateCode = "+86", businessToken, type = "account_login" }) {
|
|
161
|
+
const host = keewoodOf(site)
|
|
162
|
+
const { json } = await postJSON(`${host}/v4/user/send-sms`, {
|
|
163
|
+
type,
|
|
164
|
+
phone,
|
|
165
|
+
state_code: stateCode,
|
|
166
|
+
}, { headers: { origin: site.web, "X-Captcha-Token": businessToken || "" } })
|
|
167
|
+
|
|
168
|
+
if (json?.code === CAPTCHA_CODE.OK) return { ok: true }
|
|
169
|
+
return { ok: false, message: json?.message ?? "发送短信失败" }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ⑤ 验证码换登录态。
|
|
174
|
+
*
|
|
175
|
+
* ⚠ `device` 是必须的,而且结构是一层层试出来的:
|
|
176
|
+
* 缺 device → 「系统异常」(9999)
|
|
177
|
+
* device 不是对象 → 「系统异常」
|
|
178
|
+
* { device_id } → 「id 字段是必须的」
|
|
179
|
+
* { id, name } → 放行,返回 2168「验证码错误」
|
|
180
|
+
* ⇒ { id: 64 位 hex, name: "web" } 就够。
|
|
181
|
+
*
|
|
182
|
+
* ⚠ `auto_register` / `redirect` 一个都不能省(这是网页表单的初始值,抠出来的):
|
|
183
|
+
*
|
|
184
|
+
* { phone, state_code, code, auto_register: true,
|
|
185
|
+
* type: "phone_captcha", redirect: window.location.href }
|
|
186
|
+
*
|
|
187
|
+
* · `auto_register: true` —— 否则新号登不进来(只会报账号不存在)
|
|
188
|
+
* · `redirect` 是**当前页面的 URL**,服务端从它上面取 `?invitation=<码>`。
|
|
189
|
+
* ⇒ **邀请码是靠 redirect 传的,不是独立字段**。这就是为什么浏览器登录的
|
|
190
|
+
* URL 必须带 `?invitation=`:不带,新注册的号就没有邀请关系。
|
|
191
|
+
*/
|
|
192
|
+
export async function loginBySmsCode(
|
|
193
|
+
site,
|
|
194
|
+
{ phone, stateCode = "+86", code, device, autoRegister = true, redirect },
|
|
195
|
+
) {
|
|
196
|
+
const host = keewoodOf(site)
|
|
197
|
+
const body = {
|
|
198
|
+
type: "phone_captcha",
|
|
199
|
+
phone,
|
|
200
|
+
state_code: stateCode,
|
|
201
|
+
code,
|
|
202
|
+
auto_register: autoRegister,
|
|
203
|
+
device: device ?? { id: makeDeviceId(), name: "web" },
|
|
204
|
+
}
|
|
205
|
+
if (redirect) body.redirect = redirect
|
|
206
|
+
|
|
207
|
+
const { json, status, text } = await postJSON(`${host}/v4/user/login`, body, {
|
|
208
|
+
headers: { origin: site.web },
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
return { json, status, text }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 64 位 hex,形状与网页版 cus.device_id 一致 */
|
|
215
|
+
export function makeDeviceId() {
|
|
216
|
+
const bytes = new Uint8Array(32)
|
|
217
|
+
crypto.getRandomValues(bytes)
|
|
218
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 从登录响应里把 jwt / 组织码抠出来。
|
|
223
|
+
*
|
|
224
|
+
* ⚠ 这两个字段的真实形状**没实测过**(手上没有能收短信的号,只能验到 2168)。
|
|
225
|
+
* 所以这里做成「穷举常见键名 + 顺着对象往下找」的两层兜底,
|
|
226
|
+
* 并且把原始响应留在返回值里,出问题时日志能直接看到该怎么改。
|
|
227
|
+
*/
|
|
228
|
+
export function extractCreds(payload) {
|
|
229
|
+
const seen = new Set()
|
|
230
|
+
let jwt = null
|
|
231
|
+
let org = null
|
|
232
|
+
|
|
233
|
+
const looksJwt = (v) => typeof v === "string" && v.startsWith("eyJ") && v.split(".").length === 3
|
|
234
|
+
const looksOrg = (v) => typeof v === "string" && /^[a-z0-9]{6,32}$/i.test(v) && !looksJwt(v)
|
|
235
|
+
|
|
236
|
+
// 这些键名是服务端真实用的(实测 `login-data` 的形状),优先级高于「键名里带 org」的泛匹配 ——
|
|
237
|
+
// 后者会撞上 `organization_name: ""`、`organization_logo: null` 这类同名前缀的字段。
|
|
238
|
+
const STRONG_ORG_KEYS = [
|
|
239
|
+
"magic_organization_code",
|
|
240
|
+
"third_platform_organization_code",
|
|
241
|
+
"teamshare_organization_code",
|
|
242
|
+
"organization_code",
|
|
243
|
+
"organizationCode",
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
const walk = (node, depth) => {
|
|
247
|
+
if (!node || depth > 5 || seen.has(node)) return
|
|
248
|
+
if (typeof node !== "object") return
|
|
249
|
+
seen.add(node)
|
|
250
|
+
|
|
251
|
+
for (const [k, v] of Object.entries(node)) {
|
|
252
|
+
if (!jwt && looksJwt(v)) jwt = v
|
|
253
|
+
if (!org && STRONG_ORG_KEYS.includes(k) && looksOrg(v)) org = v
|
|
254
|
+
}
|
|
255
|
+
// 弱匹配兜底:键名带 org 且值形似组织码
|
|
256
|
+
if (!org) {
|
|
257
|
+
for (const [k, v] of Object.entries(node)) {
|
|
258
|
+
if (looksOrg(v) && /org/i.test(k)) {
|
|
259
|
+
org = v
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const v of Object.values(node)) {
|
|
265
|
+
if (v && typeof v === "object") {
|
|
266
|
+
if (Array.isArray(v)) {
|
|
267
|
+
for (const item of v.slice(0, 5)) walk(item, depth + 1)
|
|
268
|
+
} else walk(v, depth + 1)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
walk(payload, 0)
|
|
273
|
+
|
|
274
|
+
return { jwt, org }
|
|
275
|
+
}
|
package/src/headless.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// 无头登录的编排:一条纯 HTTP 的链路,不开浏览器
|
|
2
|
+
//
|
|
3
|
+
// 取图 → 识图判角度 → 过验证 → 发短信 → (用户填码)→ 换登录态 → 拿组织码
|
|
4
|
+
//
|
|
5
|
+
// 每一步都实测过(见 docs/internals.md「无头登录」):
|
|
6
|
+
// · 取图/过验证在 keewood 用户中心,纯 JSON,不需要 cookie 或指纹
|
|
7
|
+
// · 角度必须「一次算准」:同一 token 试错次数有限,且高频会被拦
|
|
8
|
+
// · 识图模型是用户在 magpie 里选的那个(settings.json 的 vision)
|
|
9
|
+
//
|
|
10
|
+
// ⚠ 没有识图模型时**不在这里瞎猜** —— 猜中的概率只有 ~5.6%(容差 ±10°),
|
|
11
|
+
// 平均要试十几次,先撞上的会是「验证提交过于频繁」。那种情况走浏览器回退
|
|
12
|
+
// (见 hooks/methods.mjs 的 another method),让用户自己转那一下。
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
extractCreds,
|
|
16
|
+
generateCaptcha,
|
|
17
|
+
loginBySmsCode,
|
|
18
|
+
sendSms,
|
|
19
|
+
verifyCaptcha,
|
|
20
|
+
} from "./captcha.mjs"
|
|
21
|
+
import { decideDegFromImage, readVisionModel } from "./vision.mjs"
|
|
22
|
+
|
|
23
|
+
/** 一次登录最多换几张图;每张图只提交一次(风控不允许反复试同一个 token) */
|
|
24
|
+
const MAX_CAPTCHA_ROUNDS = 4
|
|
25
|
+
/** 两次提交之间歇一下,避开「验证提交过于频繁」 */
|
|
26
|
+
const ROUND_GAP_MS = 2500
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 过掉旋转验证,拿到 business_token。
|
|
32
|
+
*
|
|
33
|
+
* @returns {Promise<{businessToken:string, rounds:number, log:string[]}>}
|
|
34
|
+
*/
|
|
35
|
+
export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
|
|
36
|
+
const log = []
|
|
37
|
+
let lastReason = ""
|
|
38
|
+
|
|
39
|
+
for (let round = 1; round <= MAX_CAPTCHA_ROUNDS; round++) {
|
|
40
|
+
const cap = await generateCaptcha(site, { mobile, stateCode })
|
|
41
|
+
// 已注册的号,服务端直接给 business_token,连图都不用看
|
|
42
|
+
if (cap.registered) {
|
|
43
|
+
log.push(`第 ${round} 轮:服务端直接放行(该号已注册)`)
|
|
44
|
+
return { businessToken: cap.businessToken, rounds: round, log }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const decided = await decideDegFromImage(cap.image)
|
|
48
|
+
if (!decided.ok) {
|
|
49
|
+
// 判不出来 / 两个判官对着干 → 换一张图,不要浪费一次提交
|
|
50
|
+
lastReason = `${decided.reason}${decided.detail ? `:${decided.detail}` : ""}`
|
|
51
|
+
log.push(`第 ${round} 轮:判角度不可信(${lastReason}),换图`)
|
|
52
|
+
if (round < MAX_CAPTCHA_ROUNDS) await sleep(500)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const res = await verifyCaptcha(site, { token: cap.token, deg: decided.deg, mobile, stateCode })
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
log.push(`第 ${round} 轮:deg=${decided.deg}(钟点 ${decided.clock?.toFixed?.(2) ?? "?"},分歧 ${decided.spread?.toFixed?.(1) ?? 0})→ 通过`)
|
|
59
|
+
return { businessToken: res.businessToken, rounds: round, log }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lastReason = res.message
|
|
63
|
+
log.push(`第 ${round} 轮:deg=${decided.deg} 没通过(${res.message})`)
|
|
64
|
+
if (round < MAX_CAPTCHA_ROUNDS) await sleep(ROUND_GAP_MS)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw Object.assign(
|
|
68
|
+
new Error(`旋转验证连试 ${MAX_CAPTCHA_ROUNDS} 张图都没过(最后一次:${lastReason})`),
|
|
69
|
+
{ captchaLog: log },
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 第 1 步:发短信。成功即代表「验证已经过了」——服务端把 business_token 当门票。
|
|
75
|
+
*/
|
|
76
|
+
export async function startSmsLogin(site, { mobile, stateCode = "+86" } = {}) {
|
|
77
|
+
if (!readVisionModel()) {
|
|
78
|
+
// 调用方应该先判断这个;走到这里说明判断被绕过了 —— 明确报出来,别静默瞎猜
|
|
79
|
+
throw Object.assign(new Error("没有配置识图模型,不能无头过旋转验证"), { code: "no-vision-model" })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { businessToken, rounds, log } = await passCaptcha(site, { mobile, stateCode })
|
|
83
|
+
const sms = await sendSms(site, { phone: mobile, stateCode, businessToken })
|
|
84
|
+
if (!sms.ok) {
|
|
85
|
+
throw Object.assign(new Error(`发短信失败:${sms.message}`), { captchaLog: log })
|
|
86
|
+
}
|
|
87
|
+
return { businessToken, rounds, log }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 第 2 步:验证码换登录态,并补齐组织码。
|
|
92
|
+
*
|
|
93
|
+
* ⚠ 登录响应里 jwt / 组织码的**确切形状没实测过**(手上没有能收短信的号,
|
|
94
|
+
* 只能验到 2168「验证码错误」)。所以:
|
|
95
|
+
* · jwt 用 extractCreds 从响应里按特征找(以 eyJ 开头的三段式)
|
|
96
|
+
* · 组织码找不着就调 `/api/v1/aggregation/login-data`(带 authorization 头,
|
|
97
|
+
* 服务端会回这个账号所在的组织列表)—— 这条路径是文档里查得到的
|
|
98
|
+
*/
|
|
99
|
+
export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, device, redirect }) {
|
|
100
|
+
const { json, status, text } = await loginBySmsCode(site, {
|
|
101
|
+
phone: mobile,
|
|
102
|
+
stateCode,
|
|
103
|
+
code,
|
|
104
|
+
device,
|
|
105
|
+
autoRegister: true,
|
|
106
|
+
redirect,
|
|
107
|
+
})
|
|
108
|
+
if (!json) {
|
|
109
|
+
throw new Error(`登录接口回了非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`)
|
|
110
|
+
}
|
|
111
|
+
if (json.code !== 1000) {
|
|
112
|
+
// 2168 是验证码错;其余原样透出,别把它吞成「登录失败」
|
|
113
|
+
throw Object.assign(new Error(`登录失败:${json.message ?? json.code}`), {
|
|
114
|
+
code: json.code,
|
|
115
|
+
raw: json,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { jwt, org } = extractLoginCreds(json)
|
|
120
|
+
if (!jwt) {
|
|
121
|
+
throw Object.assign(
|
|
122
|
+
new Error(`登录成功但响应里没找到可用令牌。响应结构:${describeShape(json)}`),
|
|
123
|
+
{ raw: json },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
// 响应里给了组织码就用它;没给就把该账号的组织**全列出来**,
|
|
127
|
+
// 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)。
|
|
128
|
+
const orgs = org ? [org] : await fetchOrgs(site, jwt)
|
|
129
|
+
if (!orgs.length) {
|
|
130
|
+
throw Object.assign(
|
|
131
|
+
new Error(
|
|
132
|
+
`登录成功但拿不到组织码。\n` +
|
|
133
|
+
` · 登录响应结构:${describeShape(json)}\n` +
|
|
134
|
+
` · 用该令牌调 login-data 的探测结果:${orgProbeErrors().join(" / ") || "(没探)"}\n` +
|
|
135
|
+
` (前端拿 access_token 直接 setAuthorization 后就用它调这个接口)`,
|
|
136
|
+
),
|
|
137
|
+
{ raw: json },
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
return { jwt, org: orgs[0], orgs, raw: json }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 把一段 JSON 的形状描述出来:**只有键名和类型,没有任何值**。
|
|
145
|
+
*
|
|
146
|
+
* 用来让真机上的报错自带诊断信息 —— 用户截一张图,就能看出服务端到底回了什么结构,
|
|
147
|
+
* 不用反复试。token 之类的东西一律不会出现在这里。
|
|
148
|
+
*/
|
|
149
|
+
export function describeShape(o, depth = 0) {
|
|
150
|
+
if (o === null) return "null"
|
|
151
|
+
if (o === undefined) return "undefined"
|
|
152
|
+
const t = typeof o
|
|
153
|
+
if (t !== "object") return t === "string" ? `str(${String(o).length})` : t
|
|
154
|
+
if (depth >= 3) return "…"
|
|
155
|
+
if (Array.isArray(o)) {
|
|
156
|
+
return `[${o.length}]` + (o.length ? describeShape(o[0], depth + 1) : "")
|
|
157
|
+
}
|
|
158
|
+
const keys = Object.keys(o).slice(0, 24)
|
|
159
|
+
return "{" + keys.map((k) => `${k}:${describeShape(o[k], depth + 1)}`).join(", ") + "}"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 从登录响应里取「授权令牌」和「组织码」。
|
|
164
|
+
*
|
|
165
|
+
* ⚠ 登录返回的**不是 JWT,是 `access_token`** —— 前端拿到之后立刻
|
|
166
|
+
* `setAuthorization(access_token)` 并用它调 `getUserProfile`。
|
|
167
|
+
* 早先这里只按 `eyJ` 前缀找 JWT,结果在响应里挑到了别的字段,
|
|
168
|
+
* 拿去调 `login-data` 自然拿不到组织(真机表现就是
|
|
169
|
+
* 「登录成功但拿不到组织码」)。
|
|
170
|
+
*/
|
|
171
|
+
function extractLoginCreds(json) {
|
|
172
|
+
const d = json?.data ?? json ?? {}
|
|
173
|
+
const pick = (...keys) => {
|
|
174
|
+
for (const k of keys) {
|
|
175
|
+
const v = d?.[k]
|
|
176
|
+
if (typeof v === "string" && v.trim()) return v.trim()
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
// 优先认登录接口自己给的字段名
|
|
181
|
+
const token =
|
|
182
|
+
pick("access_token", "accessToken", "token", "authorization", "teamshare_token") ??
|
|
183
|
+
extractCreds(json).jwt
|
|
184
|
+
const org =
|
|
185
|
+
pick("organization_code", "organizationCode", "magic_organization_code") ??
|
|
186
|
+
extractCreds(json).org
|
|
187
|
+
return { jwt: token, org }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 这个账号属于哪些组织(按服务端返回的顺序)。
|
|
192
|
+
*
|
|
193
|
+
* ⚠ 浏览器登录能直接读到用户**当前选中**的组织(localStorage 里的
|
|
194
|
+
* `MAGIC:user:organizationCode`),无头这条路没有那个信息 —— 所以只能把候选
|
|
195
|
+
* 全拿回来,让调用方逐个验,验不过再换下一个。
|
|
196
|
+
*
|
|
197
|
+
* ⚠ `authorization` 头**两种写法都要试**:前端这个接口是
|
|
198
|
+
* `h.set("authorization", authorization)` —— 直接放 token,**没有 `Bearer ` 前缀**;
|
|
199
|
+
* 但平台其它接口(`/api/v1/flows/models` 等)走的是 `Bearer <jwt>` 并且能通。
|
|
200
|
+
* 登录拿到的是 438 字符的 `access_token`,和网页 localStorage 里那把 354 字符的不是
|
|
201
|
+
* 同一个东西,所以不能想当然 —— 两种都打,先成的算。
|
|
202
|
+
* 全失败时把每一步的真实原因记在模块变量里,供上层错误信息引用。
|
|
203
|
+
*/
|
|
204
|
+
let lastOrgProbeErrors = []
|
|
205
|
+
export function orgProbeErrors() {
|
|
206
|
+
return lastOrgProbeErrors.slice()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function fetchOrgs(site, jwt) {
|
|
210
|
+
lastOrgProbeErrors = []
|
|
211
|
+
|
|
212
|
+
// 三个端点 × 两种 authorization 写法,全试一遍 —— 它们对令牌形态的宽容度不一样:
|
|
213
|
+
// · login-data 前端是 h.set("authorization", token)(无 Bearer),但它同时
|
|
214
|
+
// 接受 `Bearer <网页版 JWT>`(用本机 354 字符 JWT 实测通过)
|
|
215
|
+
// · me/organizations 实测连 organization-code 头都不要,只认令牌
|
|
216
|
+
// · auth/status 前端 bindMagicAuthorization 用它,返回 Array<MagicOrganization>
|
|
217
|
+
// 登录拿到的是 438 字符的 access_token,和网页 localStorage 那把 354 的不同,
|
|
218
|
+
// 所以不能想当然只打一个。
|
|
219
|
+
const probes = [
|
|
220
|
+
{ tag: "login-data/Bearer", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
|
|
221
|
+
{ tag: "login-data/裸", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: jwt, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
|
|
222
|
+
{ tag: "auth-status/Bearer", url: `${site.host}/api/v1/auth/status?login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data ?? j },
|
|
223
|
+
{ tag: "auth-status/裸", url: `${site.host}/api/v1/auth/status?login_code=`, auth: jwt, pick: (j) => j?.data ?? j },
|
|
224
|
+
{ tag: "me-organizations/Bearer", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.items ?? j?.items },
|
|
225
|
+
{ tag: "me-organizations/裸", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: jwt, pick: (j) => j?.data?.items ?? j?.items },
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
for (const p of probes) {
|
|
229
|
+
let res = null
|
|
230
|
+
try {
|
|
231
|
+
res = await fetch(p.url, {
|
|
232
|
+
headers: { authorization: p.auth, "user-agent": "magpie-magicrew-auth" },
|
|
233
|
+
signal: AbortSignal.timeout(20_000),
|
|
234
|
+
})
|
|
235
|
+
} catch (e) {
|
|
236
|
+
lastOrgProbeErrors.push(`${p.tag}:网络失败 ${String(e?.message ?? e).slice(0, 60)}`)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const text = await res.text().catch(() => "")
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
lastOrgProbeErrors.push(`${p.tag}:HTTP ${res.status} ${text.slice(0, 70)}`)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
let j = null
|
|
246
|
+
try {
|
|
247
|
+
j = JSON.parse(text)
|
|
248
|
+
} catch {
|
|
249
|
+
lastOrgProbeErrors.push(`${p.tag}:非 JSON ${text.slice(0, 70)}`)
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (j?.code !== 1000) {
|
|
253
|
+
lastOrgProbeErrors.push(`${p.tag}:code ${j?.code} ${j?.message ?? ""}`.trim())
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
const list = p.pick(j)
|
|
257
|
+
if (!Array.isArray(list) || !list.length) {
|
|
258
|
+
lastOrgProbeErrors.push(`${p.tag}:200 但列表为空`)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
const orgs = parseOrgs(list)
|
|
262
|
+
if (orgs.length) return orgs
|
|
263
|
+
lastOrgProbeErrors.push(`${p.tag}:有 ${list.length} 项但取不到组织码`)
|
|
264
|
+
}
|
|
265
|
+
return []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseOrgs(list) {
|
|
269
|
+
// ⚠ 字段名是 `magic_organization_code`(实测)。`organization_code` 只在**外层**存在,
|
|
270
|
+
// 而且是空串 —— 认它就会拿到 ""。每项里另有 third_platform_ / teamshare_ 两份同值码。
|
|
271
|
+
const KEYS = [
|
|
272
|
+
"magic_organization_code",
|
|
273
|
+
"third_platform_organization_code",
|
|
274
|
+
"teamshare_organization_code",
|
|
275
|
+
]
|
|
276
|
+
const out = []
|
|
277
|
+
for (const item of list) {
|
|
278
|
+
for (const key of KEYS) {
|
|
279
|
+
const v = item?.[key]
|
|
280
|
+
if (typeof v === "string" && /^[a-z0-9]{6,32}$/i.test(v) && !out.includes(v)) {
|
|
281
|
+
out.push(v)
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out
|
|
287
|
+
}
|