diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index 15f13672..b2fa8fad 100644 --- a/src/backend/drivers/139/driver.test.ts +++ b/src/backend/drivers/139/driver.test.ts @@ -174,6 +174,158 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { } }) +test("Yun139 keeps a valid near-expiry token and stays quiet when user_domain_id is not configured", async () => { + const expiresAt = Date.now() + 5 * 24 * 60 * 60 * 1000 + const token = `legacy-token|1|RCS|${expiresAt}|opaque` + const authorization = Buffer.from(`pc:13800138000:${token}`).toString( + "base64", + ) + const originalFetch = globalThis.fetch + const originalWarn = console.warn + const originalDebug = console.debug + const calls: string[] = [] + const warnings: string[] = [] + console.warn = (...args: any[]) => { + warnings.push(args.map((arg) => String(arg)).join(" ")) + } + console.debug = () => {} + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if ( + url === "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do" + ) { + return new Response( + "1unavailable", + { status: 200, headers: { "content-type": "application/xml" } }, + ) + } + assert.equal( + url, + "https://user-njs.yun.139.com/user/route/qryRoutePolicy", + "missing user_domain_id must not block the normal route policy request", + ) + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { + routePolicyList: [ + { + modName: "personal", + httpsUrl: "https://personal-kd-njs.yun.139.com/hcy", + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + try { + const client = new Yun139ApiClient({ authorization, type: "personal_new" }) + await client.init() + + assert.deepEqual(calls, [ + "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do", + "https://user-njs.yun.139.com/user/route/qryRoutePolicy", + ]) + assert.deepEqual(warnings, [], "near-expiry warning must be suppressed") + assert.equal( + Buffer.from(client.getAuthString(), "base64").toString("utf8"), + `pc:13800138000:${token}`, + ) + } finally { + globalThis.fetch = originalFetch + console.warn = originalWarn + console.debug = originalDebug + } +}) + +test("Yun139 refreshes a near-expiry token via the legacy endpoint when user_domain_id is absent", async () => { + const account = "13800138000" + const oldToken = `legacy-old|1|RCS|${Date.now() + 5 * 24 * 60 * 60 * 1000}|opaque` + const newToken = `legacy-new|1|RCS|${Date.now() + 30 * 24 * 60 * 60 * 1000}|opaque` + const addition: Yun139Addition = { + authorization: Buffer.from(`pc:${account}:${oldToken}`).toString("base64"), + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input) + calls.push({ url, init }) + + if ( + url === "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do" + ) { + // `` 可能仍是原 token,只有过期时间更晚的候选才能写回。 + return new Response( + `0${oldToken}` + + `${newToken}` + + "2592000", + { status: 200, headers: { "content-type": "application/xml" } }, + ) + } + + if (url === "https://user-njs.yun.139.com/user/route/qryRoutePolicy") { + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { + routePolicyList: [ + { + modName: "personal", + httpsUrl: "https://personal-kd-njs.yun.139.com/hcy", + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const refreshedAuthorizations: string[] = [] + const client = new Yun139ApiClient(addition, async (authorization) => { + refreshedAuthorizations.push(authorization) + }) + await client.init() + + assert.deepEqual( + calls.map((call) => call.url), + [ + "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do", + "https://user-njs.yun.139.com/user/route/qryRoutePolicy", + ], + ) + const refreshCall = calls[0] + assert.equal(refreshCall.init?.method, "POST") + const headers = refreshCall.init?.headers as Record + assert.equal(headers["Content-Type"], "application/xml;charset=UTF-8") + assert.equal(headers.Referer, "https://yun.139.com/") + const body = String(refreshCall.init?.body) + assert.ok(body.includes(`${oldToken}`)) + assert.ok(body.includes(`${account}`)) + assert.ok(body.includes("656")) + + assert.equal( + Buffer.from(addition.authorization, "base64").toString("utf8"), + `pc:${account}:${newToken}`, + ) + assert.deepEqual(refreshedAuthorizations, [addition.authorization]) + } finally { + globalThis.fetch = originalFetch + } +}) + test("Yun139Driver lists subdir contents; file path must not degrade to parent (issue #112)", async () => { const addition: Yun139Addition = { authorization: Buffer.from( diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index cf38306f..8d208a5a 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -5,6 +5,7 @@ import { } from "../../internal/driver/base" import { sortFileItems } from "../../internal/driver/sort" import { Yun139Addition } from "./types" +import { Yun139LoginResult } from "./login" import { Yun139ApiClient } from "./util" export class Yun139Driver implements StorageDriver { @@ -14,9 +15,14 @@ export class Yun139Driver implements StorageDriver { constructor( addition: Yun139Addition, onAuthorizationRefresh?: (authorization: string) => Promise, + onLoginStateChange?: (state: Yun139LoginResult) => Promise, ) { this.addition = addition - this.client = new Yun139ApiClient(addition, onAuthorizationRefresh) + this.client = new Yun139ApiClient( + addition, + onAuthorizationRefresh, + onLoginStateChange, + ) } async init(): Promise { diff --git a/src/backend/drivers/139/login.test.ts b/src/backend/drivers/139/login.test.ts new file mode 100644 index 00000000..40f8c8aa --- /dev/null +++ b/src/backend/drivers/139/login.test.ts @@ -0,0 +1,475 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { constants, generateKeyPairSync, privateDecrypt } from "node:crypto" +import CryptoJS from "crypto-js" +import { Yun139Addition } from "./types" +import { Yun139ApiClient } from "./util" +import { + aesCbcDecrypt, + aesCbcEncrypt, + aesEcbDecrypt, + aesEcbEncrypt, + loginWithCredentials, + rsaEncryptPKCS1v15, + sortedJsonStringify, + Yun139SmsRequiredError, +} from "./login" + +const KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" +const KEY_HEX_2 = "7150714477323633586746674c337538" +const MAIL_PASSWORD_URL = "https://mail.10086.cn/Login/Login.ashx" +const SSO_LOGIN_URL = "https://user-njs.yun.139.com/user/thirdlogin" +const ROUTE_POLICY_URL = + "https://user-njs.yun.139.com/user/route/qryRoutePolicy" + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") +} + +function encryptCbcBase64(plaintext: string, keyHex: string): string { + const iv = CryptoJS.lib.WordArray.random(16) + const encrypted = CryptoJS.AES.encrypt( + CryptoJS.enc.Utf8.parse(plaintext), + CryptoJS.enc.Hex.parse(keyHex), + { + iv, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return CryptoJS.enc.Base64.stringify(iv.clone().concat(encrypted.ciphertext)) +} + +function encryptedStep3Response(payload: any): string { + const finalJson = JSON.stringify(payload) + const inner = bytesToHex(aesEcbEncrypt(finalJson, KEY_HEX_2)) + return encryptCbcBase64(JSON.stringify({ data: inner }), KEY_HEX_1) +} + +function routePolicyResponse(): Response { + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { + routePolicyList: [ + { + modName: "personal", + httpsUrl: "https://personal-kd-njs.yun.139.com/hcy", + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) +} + +test("Yun139 sorted JSON matches Go sortedJsonStringify", () => { + assert.equal( + sortedJsonStringify({ + z: true, + a: { d: "x", c: [2, 1], e: null }, + n: 3, + }), + '{"a":{"c":[2,1],"d":"x","e":null},"n":3,"z":true}', + ) +}) + +test("Yun139 AES CBC/ECB roundtrip", () => { + const { iv, ciphertext } = aesCbcEncrypt("hello 139", KEY_HEX_1) + assert.equal(aesCbcDecrypt(ciphertext, KEY_HEX_1, iv), "hello 139") + const ecb = aesEcbEncrypt("outer", KEY_HEX_2) + assert.equal(aesEcbDecrypt(ecb, KEY_HEX_2), "outer") +}) + +test("Yun139 RSA PKCS1 v1.5 encryption decrypts with the matching private key", () => { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 1024, + }) + const der = publicKey.export({ type: "spki", format: "der" }) as Buffer + const ciphertext = rsaEncryptPKCS1v15( + Buffer.from("13800138000"), + der.toString("base64"), + ) + const decrypted = privateDecrypt( + { key: privateKey, padding: constants.RSA_PKCS1_PADDING }, + Buffer.from(ciphertext), + ) + assert.equal(decrypted.toString("utf8"), "13800138000") +}) + +test("Yun139 password login obtains account, user_domain_id and authorization", async () => { + const addition: Yun139Addition = { + authorization: "", + username: "13800138000", + password: "secret", + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url === MAIL_PASSWORD_URL) { + return new Response(null, { + status: 302, + headers: { + Location: "https://mail.10086.cn/default.html?sid=test-sid", + "Set-Cookie": "RMKEY=test-rmkey; Path=/; HttpOnly", + }, + }) + } + if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) { + return new Response( + JSON.stringify({ var: { artifact: "test-artifact" } }), + { + status: 200, + }, + ) + } + if (url === SSO_LOGIN_URL) { + return new Response( + encryptedStep3Response({ + authToken: "test-token", + account: "13800138000", + userDomainId: "domain-123", + }), + { status: 200 }, + ) + } + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const result = await loginWithCredentials(addition) + assert.equal(result.account, "13800138000") + assert.equal(result.userDomainId, "domain-123") + assert.equal( + result.authorization, + Buffer.from("pc:13800138000:test-token").toString("base64"), + ) + assert.ok(result.mailCookies.includes("RMKEY=test-rmkey")) + assert.equal(calls.length, 3) + assert.equal(calls[0], MAIL_PASSWORD_URL) + assert.ok(calls[1].startsWith("https://smsrebuild1.mail.10086.cn/")) + assert.equal(calls[2], SSO_LOGIN_URL) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 login surfaces SMS verification and preserves refreshed cookies", async () => { + const addition: Yun139Addition = { + authorization: "", + username: "13800138000", + password: "secret", + type: "personal_new", + } + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + if (url === MAIL_PASSWORD_URL) { + return new Response(null, { + status: 302, + headers: { + Location: "https://mail.10086.cn/default.html?ec=PML401010062", + }, + }) + } + if (url.startsWith("https://mail.10086.cn/s?")) { + return new Response(JSON.stringify({ code: "S_OK" }), { + status: 200, + headers: { "Set-Cookie": "Os_SSo_Sid=test-sid; Path=/" }, + }) + } + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + await assert.rejects( + () => loginWithCredentials(addition), + (error: unknown) => { + assert.ok(error instanceof Yun139SmsRequiredError) + assert.ok(error.mailCookies.includes("Os_SSo_Sid=test-sid")) + assert.match(error.message, /fill sms_code/) + return true + }, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 login verifies the SMS code and continues the password flow", async () => { + const addition: Yun139Addition = { + authorization: "", + username: "13800138000", + password: "secret", + sms_code: "123456", + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url === MAIL_PASSWORD_URL) { + return new Response(null, { + status: 302, + headers: { + Location: "https://mail.10086.cn/default.html?ec=MW0016", + }, + }) + } + if (url.startsWith("https://mail.10086.cn/s?")) { + return new Response( + JSON.stringify({ + code: "S_OK", + var: { + loginSuccessUrl: "https://mail.10086.cn/default.html?sid=sms-sid", + }, + }), + { status: 200, headers: { "Set-Cookie": "RMKEY=test-rmkey; Path=/" } }, + ) + } + if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) { + return new Response( + JSON.stringify({ var: { artifact: "test-artifact" } }), + { status: 200 }, + ) + } + if (url === SSO_LOGIN_URL) { + return new Response( + encryptedStep3Response({ + authToken: "test-token", + account: "13800138000", + userDomainId: "domain-sms", + }), + { status: 200 }, + ) + } + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const result = await loginWithCredentials(addition) + assert.equal(result.userDomainId, "domain-sms") + assert.equal(calls.length, 4) + assert.ok(calls[1].includes("inlogin.action")) + assert.equal(calls[3], SSO_LOGIN_URL) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 fast login with existing mail cookies obtains user_domain_id", async () => { + const addition: Yun139Addition = { + authorization: "", + mail_cookies: "Os_SSo_Sid=test-sid; RMKEY=test-rmkey", + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) { + return new Response( + JSON.stringify({ var: { artifact: "test-artifact" } }), + { + status: 200, + }, + ) + } + if (url === SSO_LOGIN_URL) { + return new Response( + encryptedStep3Response({ + authToken: "test-token", + account: "13800138000", + userDomainId: "domain-fast", + }), + { status: 200 }, + ) + } + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const result = await loginWithCredentials(addition) + assert.equal(result.userDomainId, "domain-fast") + assert.equal(result.mailCookies, addition.mail_cookies) + assert.equal(calls.length, 2) + assert.equal(calls[1], SSO_LOGIN_URL) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 re-logins when the PC refresh fails and credentials are available", async () => { + const oldToken = `old-token|1|RCS|${Date.now() + 5 * 24 * 60 * 60 * 1000}|opaque` + const addition: Yun139Addition = { + authorization: Buffer.from(`pc:13800138000:${oldToken}`).toString("base64"), + user_domain_id: "old-domain", + username: "13800138000", + password: "secret", + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url === "https://user-njs.yun.139.com/user/auth/refreshToken") { + return new Response("refresh rejected", { status: 500 }) + } + if (url === MAIL_PASSWORD_URL) { + return new Response(null, { + status: 302, + headers: { + Location: "https://mail.10086.cn/default.html?sid=test-sid", + "Set-Cookie": "RMKEY=test-rmkey; Path=/", + }, + }) + } + if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) { + return new Response( + JSON.stringify({ var: { artifact: "test-artifact" } }), + { status: 200 }, + ) + } + if (url === SSO_LOGIN_URL) { + return new Response( + encryptedStep3Response({ + authToken: "test-token", + account: "13800138000", + userDomainId: "domain-reborn", + }), + { status: 200 }, + ) + } + if (url === ROUTE_POLICY_URL) return routePolicyResponse() + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const states: Array<{ authorization: string; userDomainId: string }> = [] + const client = new Yun139ApiClient(addition, undefined, async (state) => { + states.push({ + authorization: state.authorization, + userDomainId: state.userDomainId, + }) + }) + await client.init() + + assert.equal(addition.user_domain_id, "domain-reborn") + assert.equal( + addition.authorization, + Buffer.from("pc:13800138000:test-token").toString("base64"), + ) + assert.equal(states.length, 1) + assert.equal(states[0].userDomainId, "domain-reborn") + assert.equal( + calls[0], + "https://user-njs.yun.139.com/user/auth/refreshToken", + ) + assert.equal(calls.filter((url) => url === ROUTE_POLICY_URL).length, 1) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 keeps a manually configured user_domain_id when Authorization is present", async () => { + let fetchCalls = 0 + const result = await loginWithCredentials( + { + authorization: "existing-authorization", + user_domain_id: "manual-domain", + type: "personal_new", + }, + (async () => { + fetchCalls++ + return new Response("should not be called", { status: 500 }) + }) as typeof fetch, + ) + assert.equal(result.authorization, "existing-authorization") + assert.equal(result.userDomainId, "manual-domain") + assert.equal(fetchCalls, 0) +}) + +test("Yun139 rejects empty credentials with a clear error", async () => { + await assert.rejects( + () => + new Yun139ApiClient({ + authorization: "", + type: "personal_new", + }).init(), + /authorization is empty and credentials are not provided/, + ) +}) + +test("Yun139 init logs in and persists authorization plus user_domain_id", async () => { + const addition: Yun139Addition = { + authorization: "", + username: "13800138000", + password: "secret", + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url === MAIL_PASSWORD_URL) { + return new Response(null, { + status: 302, + headers: { + Location: "https://mail.10086.cn/default.html?sid=test-sid", + "Set-Cookie": "RMKEY=test-rmkey; Path=/", + }, + }) + } + if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) { + return new Response( + JSON.stringify({ var: { artifact: "test-artifact" } }), + { status: 200 }, + ) + } + if (url === SSO_LOGIN_URL) { + return new Response( + encryptedStep3Response({ + authToken: "test-token", + account: "13800138000", + userDomainId: "domain-123", + }), + { status: 200 }, + ) + } + if (url === ROUTE_POLICY_URL) return routePolicyResponse() + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const states: Array<{ authorization: string; userDomainId: string }> = [] + const client = new Yun139ApiClient(addition, undefined, async (state) => { + states.push({ + authorization: state.authorization, + userDomainId: state.userDomainId, + }) + }) + await client.init() + + assert.equal(addition.user_domain_id, "domain-123") + assert.equal( + addition.authorization, + Buffer.from("pc:13800138000:test-token").toString("base64"), + ) + assert.equal(states.length, 1) + assert.equal(states[0].userDomainId, "domain-123") + assert.equal(states[0].authorization, addition.authorization) + assert.equal(calls.filter((url) => url === ROUTE_POLICY_URL).length, 1) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/src/backend/drivers/139/login.ts b/src/backend/drivers/139/login.ts new file mode 100644 index 00000000..c031e1b6 --- /dev/null +++ b/src/backend/drivers/139/login.ts @@ -0,0 +1,893 @@ +import CryptoJS from "crypto-js" +import { Yun139Addition } from "./types" + +const KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" +const KEY_HEX_2 = "7150714477323633586746674c337538" +const MAIL_PUBLIC_KEY = + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnsOHTFtwW5rq/8gGhPlM5Z3RPdeN/d+FYIHb5JmcfBOCozXuT8c+0anvxtkjzghixwNlnmBuhN8OYfS789YuH/qReQbHC7OdlisLildNWPHRNUYcPa0W3lXSG3+81CXK7FDXPvXo5ubw2GqVbIsccMarI1dyfXdi4ITiCXvmM9wYBdUs9yXtoorhlpyYUI2GV8HNsQjWK9P5QZHT3ox5Qy+mjRmvv6RUFJLPOMkOS/pGZ0DwC1ypFZBxstW0/ftVupdOmGWvW7J2/e3dq3A/UvIkC4YUY/diL1wighJx1G9MiRROISjNMvNyUDSTqPJy516+l3sgHEbc067QIJx2NQIDAQAB" +const MAIL_PASSWORD_URL = "https://mail.10086.cn/Login/Login.ashx" +const MAIL_SMS_URL = "https://mail.10086.cn/s" +const SSO_LOGIN_URL = "https://user-njs.yun.139.com/user/thirdlogin" + +export interface Yun139LoginResult { + authorization: string + account: string + userDomainId: string + mailCookies: string + smsCode: string +} + +/** 邮箱登录触发了短信风控,需要用户填写 sms_code 后重新保存。 */ +export class Yun139SmsRequiredError extends Error { + constructor( + message: string, + public readonly mailCookies: string, + ) { + super(message) + this.name = "Yun139SmsRequiredError" + } +} + +type MailLoginResponse = { + code: string + summary: string + loginSuccessUrl: string +} + +export function sha1Hex(value: string): string { + return CryptoJS.SHA1(value).toString(CryptoJS.enc.Hex) +} + +export function sortedJsonStringify(value: any): string { + if (value === null || value === undefined) return "null" + if (typeof value === "string") { + try { + return sortedJsonStringify(JSON.parse(value)) + } catch { + return JSON.stringify(value) + } + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value) + } + if (Array.isArray(value)) { + return `[${value.map((item) => sortedJsonStringify(item)).join(",")}]` + } + if (typeof value === "object") { + const keys = Object.keys(value).sort() + return `{${keys + .map((key) => `${JSON.stringify(key)}:${sortedJsonStringify(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") +} + +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error("invalid hex string") + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + if (Number.isNaN(value)) throw new Error("invalid hex string") + bytes[i] = value + } + return bytes +} + +function bytesToWordArray(bytes: Uint8Array): CryptoJS.lib.WordArray { + return CryptoJS.enc.Hex.parse(bytesToHex(bytes)) +} + +function bytesToBase64(bytes: Uint8Array): string { + return CryptoJS.enc.Base64.stringify(bytesToWordArray(bytes)) +} + +function base64ToBytes(value: string): Uint8Array { + const hex = CryptoJS.enc.Base64.parse(value).toString(CryptoJS.enc.Hex) + return hexToBytes(hex) +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const result = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + result.set(part, offset) + offset += part.length + } + return result +} + +function randomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return bytes +} + +export function aesCbcEncrypt( + plaintext: string, + keyHex: string, +): { iv: Uint8Array; ciphertext: Uint8Array } { + const key = CryptoJS.enc.Hex.parse(keyHex) + const iv = randomBytes(16) + const encrypted = CryptoJS.AES.encrypt( + CryptoJS.enc.Utf8.parse(plaintext), + key, + { + iv: bytesToWordArray(iv), + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return { + iv, + ciphertext: hexToBytes(encrypted.ciphertext.toString(CryptoJS.enc.Hex)), + } +} + +export function aesCbcDecrypt( + ciphertext: Uint8Array, + keyHex: string, + iv: Uint8Array, +): string { + const decrypted = CryptoJS.AES.decrypt( + { ciphertext: bytesToWordArray(ciphertext) } as any, + CryptoJS.enc.Hex.parse(keyHex), + { + iv: bytesToWordArray(iv), + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return decrypted.toString(CryptoJS.enc.Utf8) +} + +export function aesEcbDecrypt(ciphertext: Uint8Array, keyHex: string): string { + const decrypted = CryptoJS.AES.decrypt( + { ciphertext: bytesToWordArray(ciphertext) } as any, + CryptoJS.enc.Hex.parse(keyHex), + { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return decrypted.toString(CryptoJS.enc.Utf8) +} + +export function aesEcbEncrypt(plaintext: string, keyHex: string): Uint8Array { + const encrypted = CryptoJS.AES.encrypt( + CryptoJS.enc.Utf8.parse(plaintext), + CryptoJS.enc.Hex.parse(keyHex), + { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return hexToBytes(encrypted.ciphertext.toString(CryptoJS.enc.Hex)) +} + +type DerElement = { tag: number; start: number; end: number } + +function readDer(bytes: Uint8Array, offset: number): DerElement { + if (offset + 2 > bytes.length) throw new Error("invalid DER") + const tag = bytes[offset] + let length = bytes[offset + 1] + offset += 2 + if (length & 0x80) { + const count = length & 0x7f + if (count === 0 || offset + count > bytes.length) { + throw new Error("invalid DER length") + } + length = 0 + for (let i = 0; i < count; i++) { + length = (length << 8) | bytes[offset + i] + } + offset += count + } + if (offset + length > bytes.length) throw new Error("invalid DER payload") + return { tag, start: offset, end: offset + length } +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + let hex = bytesToHex(bytes) + hex = hex.replace(/^0+/, "") + return hex ? BigInt(`0x${hex}`) : 0n +} + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n + let b = base % modulus + let e = exponent + while (e > 0n) { + if (e & 1n) result = (result * b) % modulus + b = (b * b) % modulus + e >>= 1n + } + return result +} + +export function parseRsaPublicKey(der: Uint8Array): { + modulus: bigint + exponent: bigint +} { + const outer = readDer(der, 0) + if (outer.tag !== 0x30) throw new Error("invalid RSA public key") + const algorithm = readDer(der, outer.start) + const bitString = readDer(der, algorithm.end) + if (bitString.tag !== 0x03) + throw new Error("invalid RSA public key bit string") + const inner = readDer(der, bitString.start + 1) // skip unused-bits byte + const modulusElement = readDer(der, inner.start) + const exponentElement = readDer(der, modulusElement.end) + if (modulusElement.tag !== 0x02 || exponentElement.tag !== 0x02) { + throw new Error("invalid RSA public key integers") + } + return { + modulus: bytesToBigInt(der.slice(modulusElement.start, modulusElement.end)), + exponent: bytesToBigInt( + der.slice(exponentElement.start, exponentElement.end), + ), + } +} + +/** RSAES-PKCS1-v1_5,对应 Go 的 rsa.EncryptPKCS1v15。 */ +export function rsaEncryptPKCS1v15( + message: Uint8Array, + publicKeyDerBase64: string, +): Uint8Array { + const der = base64ToBytes(publicKeyDerBase64) + const { modulus, exponent } = parseRsaPublicKey(der) + const keyLength = Math.ceil(modulus.toString(16).length / 2) + if (message.length > keyLength - 11) { + throw new Error("RSA message too long") + } + + const padding = randomBytes(keyLength - message.length - 3) + for (let i = 0; i < padding.length; i++) { + while (padding[i] === 0) padding[i] = randomBytes(1)[0] + } + + const encoded = new Uint8Array(keyLength) + encoded[0] = 0 + encoded[1] = 2 + encoded.set(padding, 2) + encoded[2 + padding.length] = 0 + encoded.set(message, 3 + padding.length) + + const ciphertext = modPow(bytesToBigInt(encoded), exponent, modulus).toString( + 16, + ) + return hexToBytes(ciphertext.padStart(keyLength * 2, "0")) +} + +export function parseCookies( + raw: string, +): Array<{ name: string; value: string }> { + const cookies: Array<{ name: string; value: string }> = [] + for (const part of (raw || "").split(";")) { + const item = part.trim() + if (!item) continue + const index = item.indexOf("=") + if (index <= 0) continue + const name = item.slice(0, index).trim() + const value = item.slice(index + 1).trim() + if (!name) continue + const existing = cookies.find((cookie) => cookie.name === name) + if (existing) existing.value = value + else cookies.push({ name, value }) + } + return cookies +} + +export function cookiesToString( + cookies: Array<{ name: string; value: string }>, +): string { + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join(";") +} + +export function setCookie(raw: string, name: string, value: string): string { + const cookies = parseCookies(raw) + const existing = cookies.find((cookie) => cookie.name === name) + if (existing) existing.value = value + else cookies.push({ name, value }) + return cookiesToString(cookies) +} + +export function getCookie(raw: string, name: string): string { + return parseCookies(raw).find((cookie) => cookie.name === name)?.value || "" +} + +export function hasCookiePair(raw: string): boolean { + return parseCookies(raw).some( + (cookie) => cookie.name !== "" && cookie.value !== "", + ) +} + +export function extractFastLoginCookies(mailCookies: string): { + sid: string + rmkey: string +} { + const cookies = parseCookies(mailCookies) + return { + sid: + cookies.find((cookie) => cookie.name === "Os_SSo_Sid")?.value || + cookies.find((cookie) => cookie.name === "sid")?.value || + "", + rmkey: cookies.find((cookie) => cookie.name === "RMKEY")?.value || "", + } +} + +function getSetCookieHeaders(response: Response): string[] { + const headers = response.headers as Headers & { + getAll?: (name: string) => string[] + getSetCookie?: () => string[] + } + // Cloudflare Workers exposes Set-Cookie through the non-standard getAll(). + if (typeof headers.getAll === "function") { + try { + return headers.getAll("Set-Cookie") + } catch { + // fall through to the standard accessors + } + } + if (typeof headers.getSetCookie === "function") { + return headers.getSetCookie() + } + const raw = response.headers.get("set-cookie") + if (!raw) return [] + // Fallback for runtimes without getSetCookie: the cookies used here do not + // contain commas in their values, so split before the next `name=`. + return raw.split(/,(?=\s*[^;,=\s]+=)/) +} + +export function mergeResponseCookies( + mailCookies: string, + response: Response, +): string { + let merged = mailCookies + for (const header of getSetCookieHeaders(response)) { + const pair = header.split(";")[0].trim() + const index = pair.indexOf("=") + if (index <= 0) continue + const name = pair.slice(0, index).trim() + const value = pair.slice(index + 1).trim() + if (!name) continue + merged = setCookie(merged, name, value) + } + return merged +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +function mailXmlField(name: string, value: string): string { + return `${escapeXml(value)}` +} + +function parseMailLoginResponse(text: string): MailLoginResponse { + try { + const json = JSON.parse(text) + const response = { + code: String(json?.code || ""), + summary: String(json?.summary || ""), + loginSuccessUrl: String(json?.var?.loginSuccessUrl || ""), + } + if (response.code || response.summary || response.loginSuccessUrl) { + return response + } + } catch { + // Fall back to the regex extraction used by the Go driver. + } + const pick = (name: string): string => + new RegExp(`['"]?${name}['"]?\\s*:\\s*['"]([^'"]+)`).exec(text)?.[1] || "" + return { + code: pick("code"), + summary: pick("summary"), + loginSuccessUrl: pick("loginSuccessUrl"), + } +} + +function smsSceneForRisk(riskCode: string): number | null { + switch (riskCode) { + case "PML401010062": + return 2 + case "MW0016": + return 4 + case "S025": + case "S035": + return 1 + default: + return null + } +} + +function mailRiskCode(location: string): string { + try { + return new URL(location, MAIL_PASSWORD_URL).searchParams.get("ec") || "" + } catch { + return "" + } +} + +function mailHeaders(cookie: string): Record { + const headers: Record = { + "Content-Type": "application/xml; charset=utf-8", + "Accept-Encoding": "gzip", + "User-Agent": "okhttp/4.12.0", + } + if (cookie) headers.Cookie = cookie + return headers +} + +function base64Of(value: string): string { + return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(value)) +} + +async function sendSmsCode( + username: string, + riskCode: string, + mailCookies: string, + fetchImpl: typeof fetch, +): Promise { + const scene = smsSceneForRisk(riskCode) + if (scene === null) { + throw new Error( + `139 Mail risk control does not support SMS verification: ${riskCode}`, + ) + } + const loginName = bytesToBase64( + rsaEncryptPKCS1v15(new TextEncoder().encode(username), MAIL_PUBLIC_KEY), + ) + const body = [ + "", + mailXmlField("loginName", loginName), + mailXmlField("fv", "4"), + mailXmlField("clientId", "1003"), + mailXmlField("eMode", "1"), + mailXmlField("loginFailureUrl", ""), + mailXmlField("loginSuccessUrl", ""), + mailXmlField("verifyCode", ""), + mailXmlField("version", "1.0"), + mailXmlField("scene", String(scene)), + "", + ].join("") + const url = `${MAIL_SMS_URL}?func=${encodeURIComponent("login:sendSmsCodeByScene")}&cguid=${Date.now()}` + const response = await fetchImpl(url, { + method: "POST", + headers: mailHeaders(mailCookies), + body, + }) + const merged = mergeResponseCookies(mailCookies, response) + const result = parseMailLoginResponse(await response.text()) + if (result.code === "S_OK") return merged + switch (result.code) { + case "PML401010021": + case "PML401010022": + throw new Error( + `139 Mail requires picture verification before SMS can be sent: ${result.code}`, + ) + case "PML404010001": + throw new Error( + "139 Mail SMS verification code was requested too frequently", + ) + case "PML401010002": + throw new Error("139 Mail rejected the SMS verification parameters") + default: + throw new Error( + `send 139 Mail SMS verification code failed: code=${result.code} summary=${result.summary}`, + ) + } +} + +async function verifySmsCode( + username: string, + riskCode: string, + smsCode: string, + mailCookies: string, + fetchImpl: typeof fetch, +): Promise<{ sid: string; mailCookies: string }> { + if (smsSceneForRisk(riskCode) === null) { + throw new Error( + `139 Mail risk control does not support SMS verification: ${riskCode}`, + ) + } + const loginName = bytesToBase64( + rsaEncryptPKCS1v15(new TextEncoder().encode(username), MAIL_PUBLIC_KEY), + ) + const fields = [ + "", + mailXmlField("clientId", "1003"), + mailXmlField("version", "4"), + mailXmlField("loginType", "0"), + mailXmlField("authType", "2"), + mailXmlField("loginName", loginName), + mailXmlField("eMode", "1"), + mailXmlField("loginPassword", sha1Hex(`fetion.com.cn:${smsCode}`)), + mailXmlField("createAutoLoginSecretKey", "1"), + mailXmlField("verifyCode", ""), + mailXmlField("verifyAgentId", ""), + mailXmlField("reqFrom", "3"), + mailXmlField("needWCookie", "1"), + ] + if (riskCode === "MW0016") fields.push(mailXmlField("pwdType", "1")) + fields.push("") + + const url = `${MAIL_SMS_URL}?func=${encodeURIComponent("/login/inlogin.action")}&cguid=${Date.now()}` + const response = await fetchImpl(url, { + method: "POST", + headers: mailHeaders(mailCookies), + body: fields.join(""), + }) + const merged = mergeResponseCookies(mailCookies, response) + const result = parseMailLoginResponse(await response.text()) + if (result.code !== "S_OK") { + throw new Error( + `verify 139 Mail SMS code failed: code=${result.code} summary=${result.summary}`, + ) + } + + let sid = "" + if (result.loginSuccessUrl) { + try { + sid = + new URL(result.loginSuccessUrl, MAIL_PASSWORD_URL).searchParams.get( + "sid", + ) || "" + } catch { + sid = "" + } + } + if (!sid) { + sid = getCookie(merged, "Os_SSo_Sid") || getCookie(merged, "sid") || "" + } + if (!sid) { + throw new Error( + "139 Mail SMS verification succeeded but did not return sid", + ) + } + return { sid, mailCookies: merged } +} + +async function step1PasswordLogin( + addition: Yun139Addition, + fetchImpl: typeof fetch, +): Promise<{ sid: string; mailCookies: string }> { + const username = (addition.username || "").trim() + const password = addition.password || "" + const cguid = String(Date.now()) + const referer = + `https://mail.10086.cn/default.html?&s=1&v=0&u=${base64Of(username)}` + + `&m=1&ec=S001&resource=indexLogin&clientid=1003&auto=on&cguid=${cguid}&mtime=45` + + const form = new URLSearchParams({ + UserName: username, + passOld: "", + auto: "on", + Password: sha1Hex(`fetion.com.cn:${password}`), + webIndexPagePwdLogin: "1", + pwdType: "1", + clientId: "1003", + authType: "2", + }) + + let mailCookies = (addition.mail_cookies || "").trim() + const headers: Record = { + accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept-language": + "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6,en-GB;q=0.5", + "cache-control": "max-age=0", + "content-type": "application/x-www-form-urlencoded", + dnt: "1", + origin: "https://mail.10086.cn", + priority: "u=0, i", + referer, + "sec-ch-ua": + '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0", + } + if (mailCookies) headers.Cookie = mailCookies + + const response = await fetchImpl(MAIL_PASSWORD_URL, { + method: "POST", + headers, + body: form.toString(), + redirect: "manual", + }) + mailCookies = mergeResponseCookies(mailCookies, response) + + const location = response.headers.get("location") || "" + const riskCode = mailRiskCode(location) + if (riskCode) { + if (smsSceneForRisk(riskCode) === null) { + throw new Error(`139 Mail risk control triggered: ${riskCode}`) + } + if (!(addition.sms_code || "").trim()) { + mailCookies = await sendSmsCode( + username, + riskCode, + mailCookies, + fetchImpl, + ) + throw new Yun139SmsRequiredError( + "139 Mail SMS verification code sent; fill sms_code and save the storage again", + mailCookies, + ) + } + const verified = await verifySmsCode( + username, + riskCode, + (addition.sms_code || "").trim(), + mailCookies, + fetchImpl, + ) + return { sid: verified.sid, mailCookies: verified.mailCookies } + } + + let sid = "" + if (location) { + try { + sid = new URL(location, MAIL_PASSWORD_URL).searchParams.get("sid") || "" + } catch { + sid = "" + } + } + if (!sid) { + sid = + getCookie(mailCookies, "Os_SSo_Sid") || + getCookie(mailCookies, "sid") || + "" + } + if (!sid) { + throw new Error("failed to extract sid from login response") + } + return { sid, mailCookies } +} + +async function step2GetArtifact( + sid: string, + mailCookies: string, + fetchImpl: typeof fetch, +): Promise { + const rmkey = getCookie(mailCookies, "RMKEY") + if (!rmkey) throw new Error("RMKEY not found in MailCookies") + const url = + "https://smsrebuild1.mail.10086.cn/setting/s?" + + `func=${encodeURIComponent("umc:getArtifact")}` + + `&sid=${encodeURIComponent(sid)}&cguid=${Date.now()}` + const response = await fetchImpl(url, { + method: "POST", + headers: mailHeaders(`RMKEY=${rmkey}`), + }) + const text = await response.text() + let artifact = "" + try { + artifact = String(JSON.parse(text)?.var?.artifact || "") + } catch { + artifact = "" + } + if (!artifact) + throw new Error("failed to extract dycpwd from artifact exchange response") + return artifact +} + +async function encryptedRequest( + url: string, + body: any, + headers: Record, + keyHex: string, + fetchImpl: typeof fetch, +): Promise { + const { iv, ciphertext } = aesCbcEncrypt(sortedJsonStringify(body), keyHex) + const payload = bytesToBase64(concatBytes(iv, ciphertext)) + const response = await fetchImpl(url, { + method: "POST", + headers, + body: payload, + }) + if (response.status !== 200) { + throw new Error( + `139 encrypted request failed (${response.status}): ${await response.text()}`, + ) + } + const text = (await response.text()).trim() + let jsonText: string + if (text.startsWith("{")) { + jsonText = text + } else { + const decoded = base64ToBytes(text) + if (decoded.length < 16) { + throw new Error( + `139 encrypted response is too short to contain an IV (${decoded.length})`, + ) + } + jsonText = aesCbcDecrypt(decoded.slice(16), keyHex, decoded.slice(0, 16)) + } + try { + return JSON.parse(jsonText) + } catch { + throw new Error("139 encrypted response is not valid JSON") + } +} + +async function step3ThirdPartyLogin( + username: string, + dycpwd: string, + fetchImpl: typeof fetch, +): Promise<{ authorization: string; account: string; userDomainId: string }> { + const body = { + clientkey_decrypt: "l3TryM&Q+X7@dzwk)qP", + clienttype: "886", + cpid: "507", + dycpwd, + extInfo: { ifOpenAccount: "0" }, + loginMode: "0", + msisdn: username, + pintype: "13", + secinfo: sha1Hex(`fetion.com.cn:${dycpwd}`).toUpperCase(), + version: "20250901", + } + const headers: Record = { + "hcy-cool-flag": "1", + "x-huawei-channelSrc": "10246600", + "x-sdk-channelSrc": "", + "x-MM-Source": "0", + "x-UserAgent": "android|23116PN5BC|android15|1.2.6|||1440x3200|10246600", + "x-DeviceInfo": + "4|127.0.0.1|5|1.2.6|Xiaomi|23116PN5BC||02-00-00-00-00-00|android 15|1440x3200|android|||", + "Content-Type": "text/plain;charset=UTF-8", + "Accept-Encoding": "gzip", + "User-Agent": "okhttp/3.12.2", + } + + const firstLayer = await encryptedRequest( + SSO_LOGIN_URL, + body, + headers, + KEY_HEX_1, + fetchImpl, + ) + const hexInner = String(firstLayer?.data || "") + if (!hexInner) { + throw new Error("missing data field in first layer decryption result") + } + const final = JSON.parse(aesEcbDecrypt(hexToBytes(hexInner), KEY_HEX_2)) + const authToken = String(final?.authToken || "") + const account = String(final?.account || "") + const userDomainId = String(final?.userDomainId || "") + if (!authToken || !account || !userDomainId) { + throw new Error( + "failed to extract authToken, account or userDomainId from final decryption result", + ) + } + return { + authorization: base64Of(`pc:${account}:${authToken}`), + account, + userDomainId, + } +} + +async function loginWithPassword( + addition: Yun139Addition, + fetchImpl: typeof fetch, +): Promise { + const username = (addition.username || "").trim() + const password = addition.password || "" + if (!username || !password) { + throw new Error("username or password is empty") + } + const step1 = await step1PasswordLogin(addition, fetchImpl) + const artifact = await step2GetArtifact( + step1.sid, + step1.mailCookies, + fetchImpl, + ) + const step3 = await step3ThirdPartyLogin(username, artifact, fetchImpl) + return { + authorization: step3.authorization, + account: step3.account, + userDomainId: step3.userDomainId, + mailCookies: step1.mailCookies, + smsCode: "", + } +} + +async function tryFastLoginWithCookies( + addition: Yun139Addition, + fetchImpl: typeof fetch, +): Promise { + const mailCookies = (addition.mail_cookies || "").trim() + const { sid, rmkey } = extractFastLoginCookies(mailCookies) + if (!sid || !rmkey) return null + try { + const artifact = await step2GetArtifact(sid, mailCookies, fetchImpl) + const step3 = await step3ThirdPartyLogin( + (addition.username || "").trim(), + artifact, + fetchImpl, + ) + return { + authorization: step3.authorization, + account: step3.account, + userDomainId: step3.userDomainId, + mailCookies, + smsCode: "", + } + } catch { + return null + } +} + +/** + * 对齐 Go 主项目的凭证初始化: + * - Authorization 非空:直接使用; + * - MailCookies 含 SID/RMKEY:先尝试快速登录; + * - Username + Password:完整密码登录(必要时走短信风控)。 + */ +export async function loginWithCredentials( + addition: Yun139Addition, + fetchImpl: typeof fetch = fetch, + options: { ignoreAuthorization?: boolean; skipFastLogin?: boolean } = {}, +): Promise { + const authorization = (addition.authorization || "").trim() + const username = (addition.username || "").trim() + const password = addition.password || "" + const mailCookies = (addition.mail_cookies || "").trim() + + if (authorization && !options.ignoreAuthorization) { + if (/^basic\s/i.test(authorization)) { + throw new Error("authorization should not include Basic prefix") + } + return { + authorization, + account: "", + userDomainId: (addition.user_domain_id || "").trim(), + mailCookies, + smsCode: (addition.sms_code || "").trim(), + } + } + + if (mailCookies && !hasCookiePair(mailCookies)) { + throw new Error( + "MailCookies format is invalid, please check your configuration", + ) + } + const hasUsername = username !== "" + const hasPassword = password.trim() !== "" + if (hasUsername !== hasPassword) { + throw new Error("username and password must be provided together") + } + + if (mailCookies && !options.skipFastLogin) { + const fast = await tryFastLoginWithCookies(addition, fetchImpl) + if (fast) return fast + } + if (hasUsername) { + return loginWithPassword(addition, fetchImpl) + } + if (mailCookies) { + throw new Error( + "fast login with cookies failed, and cannot fallback to password login (missing username/password)", + ) + } + throw new Error("authorization is empty and credentials are not provided") +} diff --git a/src/backend/drivers/139/types.ts b/src/backend/drivers/139/types.ts index dceeba76..10f8d19d 100644 --- a/src/backend/drivers/139/types.ts +++ b/src/backend/drivers/139/types.ts @@ -12,6 +12,7 @@ export interface Yun139Addition { authorization: string username?: string password?: string + sms_code?: string mail_cookies?: string root_folder_id?: string type?: "personal_new" | "family" | "group" | "personal" | "share" diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index edc6cca4..cb5f4466 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -1,4 +1,10 @@ import CryptoJS from "crypto-js" +import { DOMParser } from "@xmldom/xmldom" +import { + loginWithCredentials, + Yun139LoginResult, + Yun139SmsRequiredError, +} from "./login" import { Yun139Addition, QueryRoutePolicyResp, @@ -58,6 +64,26 @@ export function formatTime(d: Date): string { // host 就是硬失败,绝不允许静默回退到这个占位域名。 const YUN139_DEFAULT_HOST = "https://yun.139.com" +/** + * 旧版 token 刷新接口,主项目 Go 驱动仍在用:只需要当前 token 与账号, + * 不要求 user_domain_id。用于替代 PC refreshToken 接口不可用时的硬失败。 + */ +const YUN139_LEGACY_REFRESH_URL = + "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do" + +/** 旧接口失败或刚刷新后,至少隔这么久再试,避免每次文件操作都请求一次。 */ +const YUN139_LEGACY_REFRESH_COOLDOWN_MS = 30 * 60 * 1000 + +/** 读取旧版刷新接口 XML 响应中第一个同名标签的文本。 */ +function readXmlTag(xml: string, tag: string): string { + try { + const doc = new DOMParser().parseFromString(xml, "text/xml") + return (doc.getElementsByTagName(tag)[0]?.textContent || "").trim() + } catch { + return "" + } +} + /** 139 目录 id 的"根"写法:这些父 id 不做内容归属校验(根列表出现什么都不奇怪)。 */ const ROOT_PARENT_IDS = new Set(["/", "0", "root", "cloud139_root"]) @@ -110,13 +136,17 @@ export class Yun139ApiClient { public groupHost = YUN139_DEFAULT_HOST public account = "" private onAuthorizationRefresh?: (authorization: string) => Promise + private onLoginStateChange?: (state: Yun139LoginResult) => Promise + private legacyRefreshNextAttemptAt = 0 constructor( addition: Yun139Addition, onAuthorizationRefresh?: (authorization: string) => Promise, + onLoginStateChange?: (state: Yun139LoginResult) => Promise, ) { this.addition = addition this.onAuthorizationRefresh = onAuthorizationRefresh + this.onLoginStateChange = onLoginStateChange this.extractAccount() } @@ -401,20 +431,106 @@ export class Yun139ApiClient { if (pieces.length < 3) return const token = pieces.slice(2).join(":") - const tokenFields = token.split("|") - const expiresAt = Number(tokenFields[3]) - if (!Number.isFinite(expiresAt) || expiresAt <= 0) return + const expiresAt = this.tokenExpiresAt(token) + if (expiresAt <= 0) return const refreshWindowMs = 15 * 24 * 60 * 60 * 1000 if (expiresAt - Date.now() > refreshWindowMs) return const userDomainId = (this.addition.user_domain_id || "").trim() - if (!userDomainId) { - throw new Error( - "139 Cloud token is near expiry; configure user_domain_id to use the PC refreshToken API", + if (userDomainId) { + try { + await this.refreshWithPcApi(auth, pieces, userDomainId) + return + } catch (e) { + if (await this.tryReLogin()) return + throw e + } + } + + // user_domain_id 只是 PC refreshToken 接口的必填项,不是自动续期的前提。 + // 留空时改走主项目仍在使用的旧版刷新接口(只需要当前 token)。旧接口 + // 失败也静默保留现有 token:不再因为“即将过期”打断初始化或刷告警。 + if (Date.now() < this.legacyRefreshNextAttemptAt) return + try { + const newToken = await this.refreshWithLegacyApi(token, expiresAt) + await this.applyRefreshedAuthorization(pieces, newToken) + this.legacyRefreshNextAttemptAt = + Date.now() + YUN139_LEGACY_REFRESH_COOLDOWN_MS + } catch (e) { + this.legacyRefreshNextAttemptAt = + Date.now() + YUN139_LEGACY_REFRESH_COOLDOWN_MS + if (await this.tryReLogin()) return + console.debug( + "[139] automatic token refresh skipped:", + e instanceof Error ? e.message : String(e), + ) + } + } + + /** + * 对齐 Go 的 loginAfterAuthorizationFailure:刷新失败时,如果配置里还有 + * 邮箱 Cookie 或用户名/密码,就重新登录一次并保存新的 authorization 与 + * user_domain_id;没有可用凭证时返回 false,由调用方保持原有降级行为。 + */ + private async tryReLogin(): Promise { + const hasUsername = (this.addition.username || "").trim() !== "" + const hasPassword = (this.addition.password || "") !== "" + // 对齐 Go 的 loginAfterAuthorizationFailure:刷新失败时只走密码登录回退, + // 不重新尝试 Cookie 快速登录;mail_cookies 仅作为密码登录的设备上下文。 + if (!hasUsername || !hasPassword) return false + try { + const result = await loginWithCredentials(this.addition, fetch, { + ignoreAuthorization: true, + skipFastLogin: true, + }) + this.addition.authorization = result.authorization + this.addition.user_domain_id = result.userDomainId + this.addition.mail_cookies = result.mailCookies + this.addition.sms_code = "" + this.account = result.account || this.account + await this.onLoginStateChange?.(result) + return true + } catch (e) { + if (e instanceof Yun139SmsRequiredError) { + this.addition.mail_cookies = e.mailCookies + await this.onLoginStateChange?.({ + authorization: "", + account: this.account, + userDomainId: (this.addition.user_domain_id || "").trim(), + mailCookies: e.mailCookies, + smsCode: (this.addition.sms_code || "").trim(), + }) + } + console.debug( + "[139] re-login after refresh failure skipped:", + e instanceof Error ? e.message : String(e), ) + return false } + } + /** 从 `|...||...` 中读取毫秒时间戳,非法时返回 0。 */ + private tokenExpiresAt(token: string): number { + const expiresAt = Number(token.split("|")[3]) + return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : 0 + } + + private async applyRefreshedAuthorization( + pieces: string[], + newToken: string, + ): Promise { + this.addition.authorization = CryptoJS.enc.Base64.stringify( + CryptoJS.enc.Utf8.parse(`${pieces[0]}:${this.account}:${newToken}`), + ) + await this.onAuthorizationRefresh?.(this.addition.authorization) + } + + private async refreshWithPcApi( + auth: string, + pieces: string[], + userDomainId: string, + ): Promise { const authHeader = `Basic ${auth}` // Match the minimal header set from the successful PowerShell request. @@ -461,24 +577,106 @@ export class Yun139ApiClient { `139 Cloud PC token refresh failed: code=${result.code || "unknown"}, message=${result.message || "empty token"}`, ) } - const newExpiresAt = Number(newToken.split("|")[3]) - if (!Number.isFinite(newExpiresAt) || newExpiresAt <= Date.now()) { + if (this.tokenExpiresAt(newToken) <= Date.now()) { throw new Error( "139 Cloud PC refresh returned a token without a valid future expiry", ) } - this.addition.authorization = CryptoJS.enc.Base64.stringify( - CryptoJS.enc.Utf8.parse(`${pieces[0]}:${this.account}:${newToken}`), + await this.applyRefreshedAuthorization(pieces, newToken) + } + + /** + * 旧版刷新接口(对齐主项目 Go 驱动):使用 XML 请求,不需要 user_domain_id。 + * 响应里 `` 与 `` 都可能是新 token,只接受过期时间比 + * 旧 token 更晚的值,避免把原 token 或非 pipe 格式的字段写回 Authorization。 + */ + private async refreshWithLegacyApi( + token: string, + currentExpiresAt: number, + ): Promise { + const body = + `${token}${this.account}` + + "656" + + const response = await fetch(YUN139_LEGACY_REFRESH_URL, { + method: "POST", + headers: { + "Content-Type": "application/xml;charset=UTF-8", + Referer: "https://yun.139.com/", + }, + body, + }) + + const text = await response.text() + if (!response.ok) { + throw new Error( + `139 Cloud legacy token refresh failed (${response.status}): ${text}`, + ) + } + + const code = readXmlTag(text, "return") + if (code !== "0") { + throw new Error( + `139 Cloud legacy token refresh failed: return=${code || "unknown"}, ` + + `desc=${readXmlTag(text, "desc") || "empty"}`, + ) + } + + for (const candidate of [ + readXmlTag(text, "token"), + readXmlTag(text, "accessToken"), + ]) { + if ( + this.tokenExpiresAt(candidate) > Math.max(currentExpiresAt, Date.now()) + ) { + return candidate + } + } + + throw new Error( + "139 Cloud legacy refresh returned no token with a valid future expiry", ) - await this.onAuthorizationRefresh?.(this.addition.authorization) } - async init(): Promise { - if (!this.addition.authorization) { - throw new Error("139 Cloud Authorization is required") + /** + * 对齐主项目:Authorization 为空时用邮箱 Cookie / 用户名密码登录,并在登录 + * 成功后保存 Authorization、user_domain_id 与更新后的 MailCookies。 + */ + private async ensureAuthorization(): Promise { + const authorization = (this.addition.authorization || "").trim() + if (authorization) { + if (/^basic\s/i.test(authorization)) { + throw new Error("authorization should not include Basic prefix") + } + return } + try { + const result = await loginWithCredentials(this.addition) + this.addition.authorization = result.authorization + this.addition.user_domain_id = result.userDomainId + this.addition.mail_cookies = result.mailCookies + this.addition.sms_code = "" + this.account = result.account || this.account + await this.onLoginStateChange?.(result) + } catch (e) { + if (e instanceof Yun139SmsRequiredError) { + this.addition.mail_cookies = e.mailCookies + await this.onLoginStateChange?.({ + authorization: "", + account: this.account, + userDomainId: (this.addition.user_domain_id || "").trim(), + mailCookies: e.mailCookies, + smsCode: (this.addition.sms_code || "").trim(), + }) + } + throw e + } + } + + async init(): Promise { + await this.ensureAuthorization() await this.refreshAuthorizationIfNeeded() let routeError: unknown = null diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts index ef4d6f25..0fdeb406 100644 --- a/src/backend/internal/op/storage.ts +++ b/src/backend/internal/op/storage.ts @@ -1016,28 +1016,57 @@ async function createDriver( normDriver.includes("139") ) { const addition = parseAddition(storageConfig) - driver = new Yun139Driver(addition, async (authorization) => { - try { - const db = await getDb() - const st = (db.storages || []).find( - (s: any) => s.id === storageConfig?.id, - ) - if (!st) return - const stAddition = - typeof st.addition === "string" - ? JSON.parse(st.addition || "{}") - : st.addition || {} - stAddition.authorization = authorization - st.addition = JSON.stringify(stAddition) - // Keep the in-flight admin update object in sync so it cannot overwrite - // the newly refreshed authorization with the previous value. - storageConfig.addition = st.addition - if (deferredTokenPersistence.has(storageConfig)) return - await saveDb(db) - } catch (e) { - console.warn("[139] failed to persist refreshed authorization:", e) - } - }) + driver = new Yun139Driver( + addition, + async (authorization) => { + try { + const db = await getDb() + const st = (db.storages || []).find( + (s: any) => s.id === storageConfig?.id, + ) + if (!st) return + const stAddition = + typeof st.addition === "string" + ? JSON.parse(st.addition || "{}") + : st.addition || {} + stAddition.authorization = authorization + st.addition = JSON.stringify(stAddition) + // Keep the in-flight admin update object in sync so it cannot overwrite + // the newly refreshed authorization with the previous value. + storageConfig.addition = st.addition + if (deferredTokenPersistence.has(storageConfig)) return + await saveDb(db) + } catch (e) { + console.warn("[139] failed to persist refreshed authorization:", e) + } + }, + async (state) => { + try { + const db = await getDb() + const st = (db.storages || []).find( + (s: any) => s.id === storageConfig?.id, + ) + if (!st) return + const stAddition = + typeof st.addition === "string" + ? JSON.parse(st.addition || "{}") + : st.addition || {} + if (state.authorization) + stAddition.authorization = state.authorization + if (state.userDomainId) { + stAddition.user_domain_id = state.userDomainId + } + stAddition.mail_cookies = state.mailCookies + stAddition.sms_code = state.smsCode + st.addition = JSON.stringify(stAddition) + storageConfig.addition = st.addition + if (deferredTokenPersistence.has(storageConfig)) return + await saveDb(db) + } catch (e) { + console.warn("[139] failed to persist login state:", e) + } + }, + ) await driver.init?.() } else if ( normDriver === "mega" || diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index 5a4b236e..f6740e0b 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -3452,7 +3452,43 @@ const driverConfigs: Record = { name: "authorization", type: "text", default: "", - required: true, + required: false, + help: "Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login.", + }, + { + name: "username", + type: "string", + default: "", + required: false, + help: "Use together with password when Authorization is empty. mail_cookies may be left empty on the first login.", + }, + { + name: "password", + type: "string", + default: "", + required: false, + help: "Use together with username when Authorization is empty. mail_cookies may be left empty on the first login.", + }, + { + name: "sms_code", + type: "string", + default: "", + required: false, + help: "Fill this only after the driver reports that a 139 Mail SMS verification code was sent, then save the storage again.", + }, + { + name: "mail_cookies", + type: "text", + default: "", + required: false, + help: "Optional cookies from mail.10086.cn. Leave empty for a first username/password login; cookies created or updated by password/SMS login are persisted and reused as device context. Existing cookies may also be used alone for fast login.", + }, + { + name: "user_domain_id", + type: "string", + default: "", + required: false, + help: "Optional. ud_id cookie value; filled automatically after password/fast login. Used by the PC refreshToken API, otherwise the legacy refresh endpoint is used.", }, { name: "root_folder_id",