完善半自动履约凭证与客户重新授权流程

统一虎牙和微博账号级 CK 保存与导出;CK 失效后回到客户重新登录,后台支持重发授权;客户授权页改为统一深色布局并收敛开发迁移配置。
This commit is contained in:
yml
2026-11-14 00:13:00 +08:00
parent e1d16f3a53
commit 8a54c41476
29 changed files with 1788 additions and 827 deletions
@@ -9,6 +9,7 @@ import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js'
import {
HUYA_NATIVE_TICKET_PREFIX,
huyaNativeConnector,
type HuyaStoredCredential,
} from './huya-native/huya-native-connector.js'
export type ConnectorLoginSession = {
@@ -275,6 +276,19 @@ export async function createHuyaBrowserTicket(
)
}
export function exportHuyaSessionCredential(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): HuyaStoredCredential {
if (mode !== 'embedded') {
throw createHttpError('当前虎牙 Connector 模式不支持直接导出 CK', {
statusCode: 409,
errorCode: 'semi_auto_credential_export_unsupported',
})
}
return huyaNativeConnector.exportSessionCredential(sessionId)
}
export function inferSemiAutoConnectorModeFromTicket(ticket: string): SemiAutoConnectorMode {
const normalized = ticket.trim()
if (normalized.startsWith('mock-ticket-')) return 'mock'
@@ -107,6 +107,21 @@ export type HuyaNativeConnectorOptions = {
rpcTimeoutMs?: number
}
export type HuyaStoredCredential = {
platformUid: string
nickname: string
cookies: Array<{
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
hostOnly?: boolean
expiresAt: string | null
}>
}
function defaultBindingState(): NativeBindState {
return {
status: 'not_started',
@@ -545,6 +560,26 @@ export class HuyaNativeConnector {
})
}
exportSessionCredential(sessionIdInput: string): HuyaStoredCredential {
const session = this.requireAuthenticatedSession(sessionIdInput)
return {
platformUid: String(session.uid),
nickname: session.nickname,
cookies: cookiePairs(session.cookie)
.filter(([name, value]) => name && value)
.map(([name, value]) => ({
name,
value,
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: false,
hostOnly: false,
expiresAt: null,
})),
}
}
async consumeBrowserTicket(ticketInput: string): Promise<ConnectorInjectionPayload> {
this.prune()
const ticket = readString(ticketInput)
@@ -167,6 +167,14 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK
assert.equal(JSON.stringify(authenticated).includes('secret-biztoken'), false)
assert.equal(JSON.stringify(authenticated).includes('server-issued-cred'), false)
const credential = connector.exportSessionCredential(created.sessionId)
assert.equal(credential.platformUid, '123456')
assert.equal(credential.nickname, '测试用户')
assert.equal(
credential.cookies.find((item) => item.name === 'udb_cred')?.value,
'server-issued-cred',
)
const ticket = await connector.createBrowserTicket({
sessionId: created.sessionId,
targetUrl: 'https://www.huya.com/',
@@ -1,94 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
decryptSemiAutoCredential,
encryptSemiAutoCredential,
isEncryptedSemiAutoCredential,
} from './semi-auto-credential-crypto.js'
import { buildSemiAutoCredentialCookieHeader } from './semi-auto-credential-service.js'
const SECRET = 'test-semi-auto-credential-encryption-key-123456'
test('半自动履约凭证使用 AES-GCM 加密并可还原', () => {
const plaintext = JSON.stringify({
cookies: [{ name: 'SUB', value: 'secret-cookie', domain: '.weibo.com' }],
})
const encrypted = encryptSemiAutoCredential(plaintext, SECRET)
assert.equal(isEncryptedSemiAutoCredential(encrypted), true)
assert.notEqual(encrypted, plaintext)
assert.equal(encrypted.includes('secret-cookie'), false)
assert.equal(decryptSemiAutoCredential(encrypted, SECRET), plaintext)
})
test('半自动履约凭证密文被篡改时解密失败', () => {
const encrypted = encryptSemiAutoCredential('{"cookies":[]}', SECRET)
const tampered = `${encrypted.slice(0, -1)}${encrypted.endsWith('A') ? 'B' : 'A'}`
assert.throws(() => decryptSemiAutoCredential(tampered, SECRET))
})
test('错误密钥不能解密半自动履约凭证', () => {
const encrypted = encryptSemiAutoCredential('{"cookies":[]}', SECRET)
assert.throws(() =>
decryptSemiAutoCredential(encrypted, 'another-semi-auto-credential-encryption-key-123'),
)
})
test('账号 Cookie Header 按域名、路径和安全协议筛选', () => {
const header = buildSemiAutoCredentialCookieHeader(
[
{
name: 'SUB',
value: 'weibo-session',
domain: '.weibo.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
{
name: 'SUB',
value: 'passport-session',
domain: 'passport.weibo.com',
path: '/sso',
secure: true,
httpOnly: true,
hostOnly: true,
expiresAt: null,
},
{
name: 'INSECURE',
value: 'must-not-leak',
domain: '.weibo.com',
path: '/',
secure: false,
httpOnly: false,
hostOnly: false,
expiresAt: null,
},
],
'https://weibo.com/',
)
assert.equal(header, 'SUB=weibo-session; INSECURE=must-not-leak')
assert.equal(
buildSemiAutoCredentialCookieHeader(
[
{
name: 'SUB',
value: 'weibo-session',
domain: '.weibo.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
],
'http://weibo.com/',
),
'',
)
})
@@ -1,56 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
const CIPHERTEXT_PREFIX = 'enc:v1:'
function resolveEncryptionSecret(override?: string): string {
const explicit = String(override ?? runtimeConfig.semiAuto?.credentialEncryptionKey ?? '').trim()
const fallback = String(runtimeConfig.admin?.sessionSecret || '').trim()
const secret = explicit || fallback
if (!secret) {
throw new Error('缺少 SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY,无法加密半自动履约平台凭证')
}
return secret
}
function deriveKey(secret: string): Buffer {
return crypto.createHash('sha256').update(`order-site:semi-auto-credential:${secret}`).digest()
}
export function isEncryptedSemiAutoCredential(value: unknown): boolean {
return String(value || '').startsWith(CIPHERTEXT_PREFIX)
}
export function encryptSemiAutoCredential(plaintext: string, secretOverride?: string): string {
const text = String(plaintext || '')
if (!text) return ''
const secret = resolveEncryptionSecret(secretOverride)
const nonce = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), nonce)
const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${CIPHERTEXT_PREFIX}${Buffer.concat([nonce, tag, ciphertext]).toString('base64url')}`
}
export function decryptSemiAutoCredential(ciphertext: string, secretOverride?: string): string {
const value = String(ciphertext || '')
if (!value) return ''
if (!value.startsWith(CIPHERTEXT_PREFIX)) {
throw new Error('半自动履约凭证不是受支持的加密格式')
}
const raw = Buffer.from(value.slice(CIPHERTEXT_PREFIX.length), 'base64url')
if (raw.length < 29) {
throw new Error('半自动履约凭证密文长度无效')
}
const nonce = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const ciphertextBytes = raw.subarray(28)
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
deriveKey(resolveEncryptionSecret(secretOverride)),
nonce,
)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ciphertextBytes), decipher.final()]).toString('utf8')
}
@@ -1,10 +1,6 @@
import type { PoolClient } from 'pg'
import { query } from '../../db/client.js'
import {
decryptSemiAutoCredential,
encryptSemiAutoCredential,
} from './semi-auto-credential-crypto.js'
export type SemiAutoCredentialCookie = {
name: string
@@ -38,7 +34,7 @@ type CredentialRow = {
platform: 'huya' | 'weibo'
platform_uid: string
platform_nickname: string
credential_ciphertext: string
credential_payload_json: string
credential_source: SemiAutoCredentialSource | ''
credential_status: 'unknown' | 'valid' | 'invalid' | 'error'
credential_updated_at: string
@@ -102,8 +98,7 @@ export function buildSemiAutoCredentialCookieHeader(
}
function decodeCredential(row: CredentialRow): SemiAutoCredentialRecord {
const decrypted = decryptSemiAutoCredential(row.credential_ciphertext)
const parsed = JSON.parse(decrypted) as Record<string, unknown>
const parsed = JSON.parse(row.credential_payload_json) as Record<string, unknown>
return {
id: Number(row.id),
platform: row.platform,
@@ -132,15 +127,11 @@ export async function upsertSemiAutoCredentialWithClient(
if (cookies.length === 0) {
throw new Error('平台 Cookie 为空,无法保存半自动履约凭证')
}
const encrypted = encryptSemiAutoCredential(
JSON.stringify({
cookies,
}),
)
const payloadJson = JSON.stringify({ cookies })
const result = await client.query<{ id: number }>(
`
INSERT INTO semi_auto_credentials (
platform, platform_uid, platform_nickname, credential_ciphertext,
platform, platform_uid, platform_nickname, credential_payload_json,
credential_source, credential_status, credential_updated_at,
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
@@ -148,7 +139,7 @@ export async function upsertSemiAutoCredentialWithClient(
)
ON CONFLICT (platform, platform_uid) DO UPDATE
SET platform_nickname = EXCLUDED.platform_nickname,
credential_ciphertext = EXCLUDED.credential_ciphertext,
credential_payload_json = EXCLUDED.credential_payload_json,
credential_source = EXCLUDED.credential_source,
credential_status = 'valid',
credential_updated_at = NOW(),
@@ -163,7 +154,7 @@ export async function upsertSemiAutoCredentialWithClient(
String(input.payload.nickname || '')
.trim()
.slice(0, 120),
encrypted,
payloadJson,
input.source,
],
)
@@ -178,7 +169,7 @@ export async function loadSemiAutoCredential(
const result = await query<CredentialRow>(
`
SELECT
id, platform, platform_uid, platform_nickname, credential_ciphertext,
id, platform, platform_uid, platform_nickname, credential_payload_json,
credential_source, credential_status, credential_updated_at
FROM semi_auto_credentials
WHERE id = $1
@@ -19,6 +19,7 @@ import {
import {
createHuyaBrowserTicket,
createHuyaLoginSession,
exportHuyaSessionCredential,
getHuyaBinding,
getHuyaLoginSession,
revokeHuyaSession,
@@ -164,6 +165,9 @@ function normalizeOrderNo(value: unknown): string {
function displayStatus(task: TaskRow) {
if (task.execution_status === 'completed') return { key: 'completed', label: '已完成' }
if (task.execution_status === 'cancelled') return { key: 'cancelled', label: '已取消' }
if (task.credential_status === 'invalid' || task.credential_status === 'error') {
return { key: 'credential_expired', label: '登录已失效,需重新授权' }
}
if (
task.execution_status === 'failed' ||
task.login_status === 'failed' ||
@@ -384,6 +388,66 @@ async function getBindingForTask(
: getHuyaBinding(sessionId, task.connector_mode)
}
function errorCodeOf(error: unknown) {
return error && typeof error === 'object'
? String((error as { errorCode?: unknown }).errorCode || '').trim()
: ''
}
function isInvalidCredentialError(error: unknown) {
return ['weibo_credential_invalid', 'weibo_credential_account_mismatch'].includes(
errorCodeOf(error),
)
}
async function resetTaskForCustomerLogin(
task: TaskRow,
input: {
actorType: TaskActorType
actorId?: string
reason: string
eventType: string
},
) {
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
return task
}
await withTransaction(async (client) => {
await client.query(
`
UPDATE semi_auto_tasks
SET connector_session_id = '',
login_status = 'waiting_login',
binding_status = 'not_started',
execution_status = 'waiting_customer',
customer_link_opened_at = NULL,
auth_started_at = NULL,
authenticated_at = NULL,
binding_started_at = NULL,
bound_at = NULL,
last_error = $2,
updated_at = NOW()
WHERE id = $1
`,
[task.id, input.reason],
)
await appendTaskEvent(client, task.id, {
eventType: input.eventType,
actorType: input.actorType,
...(input.actorId ? { actorId: input.actorId } : {}),
fromLoginStatus: task.login_status,
toLoginStatus: 'waiting_login',
fromBindingStatus: task.binding_status,
toBindingStatus: 'not_started',
fromExecutionStatus: task.execution_status,
toExecutionStatus: 'waiting_customer',
detail: { reason: input.reason },
})
})
publishAdminOrderRealtimeChange(Number(task.order_id))
return loadTask(task.id)
}
async function ensureConnectorSessionForTask(task: TaskRow): Promise<TaskRow> {
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') return task
if (task.connector_session_id) {
@@ -434,10 +498,18 @@ async function ensureConnectorSessionForTask(task: TaskRow): Promise<TaskRow> {
})
return loadTask(task.id)
} catch (error) {
const credentialInvalid = isInvalidCredentialError(error)
await markSemiAutoCredentialCheck(task.credential_id, {
status: 'invalid',
status: credentialInvalid ? 'invalid' : 'error',
error: error instanceof Error ? error.message : String(error),
}).catch(() => undefined)
if (credentialInvalid) {
return resetTaskForCustomerLogin(task, {
actorType: 'connector',
reason: '平台登录凭证已失效,需客户重新登录',
eventType: 'credential_invalidated',
})
}
throw error
}
}
@@ -613,13 +685,17 @@ async function updateLoginTask(
: 'waiting_customer'
: task.execution_status
const storedCredential =
task.platform === 'weibo' && mapped.status === 'authenticated' && mapped.hasCredential
? exportWeiboSessionCredential(session.sessionId, task.connector_mode)
mapped.status === 'authenticated' && mapped.hasCredential
? task.platform === 'weibo'
? exportWeiboSessionCredential(session.sessionId, task.connector_mode)
: task.connector_mode === 'embedded'
? exportHuyaSessionCredential(session.sessionId, task.connector_mode)
: null
: null
await withTransaction(async (client) => {
const credentialId = storedCredential
? await upsertSemiAutoCredentialWithClient(client, {
platform: 'weibo',
platform: task.platform,
source: 'qr',
payload: storedCredential,
})
@@ -1259,7 +1335,7 @@ export async function exportAdminSemiAutoCredential(taskIdInput: unknown, sessio
? configuredTarget
: credential.platform === 'weibo'
? 'https://weibo.com/'
: ''
: 'https://www.huya.com/'
const cookieHeader = targetUrl
? buildSemiAutoCredentialCookieHeader(credential.cookies, targetUrl)
: ''
@@ -1323,6 +1399,43 @@ export async function syncAdminSemiAutoTask(taskIdInput: unknown) {
return getAdminSemiAutoTask(taskId)
}
export async function requestAdminSemiAutoCustomerReauth(
taskIdInput: unknown,
session: AdminSession,
) {
const taskId = normalizeId(taskIdInput)
const task = await loadTask(taskId)
if (task.execution_status === 'completed') {
throw createHttpError('已完成任务不能重新授权', {
statusCode: 409,
errorCode: 'semi_auto_task_completed',
})
}
if (task.execution_status === 'cancelled') {
throw createHttpError('已取消任务不能重新授权', {
statusCode: 409,
errorCode: 'semi_auto_task_cancelled',
})
}
const adminUserId = Number(session.userId || 0)
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
throw createHttpError('该任务已被其他客服领取', {
statusCode: 409,
errorCode: 'semi_auto_task_claimed',
})
}
if (task.connector_session_id) {
await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined)
}
await resetTaskForCustomerLogin(task, {
actorType: 'admin',
actorId: String(adminUserId),
reason: '客服已重新发送授权链接,等待客户重新登录',
eventType: 'customer_reauth_requested',
})
return getAdminSemiAutoTask(taskId)
}
export async function createAdminBrowserSession(
taskIdInput: unknown,
targetUrlInput: unknown,
@@ -15,13 +15,18 @@ test(
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { ensureSemiAutoTasksForOrder, listAdminSemiAutoTasks, listPublicSemiAutoTasks } =
await import('./semi-auto-task-service.js')
const {
ensureSemiAutoTasksForOrder,
listAdminSemiAutoTasks,
listPublicSemiAutoTasks,
requestAdminSemiAutoCustomerReauth,
} = await import('./semi-auto-task-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-SEMI-AUTO-${suffix}`
const skuKey = `半自动测试-${suffix}`
let orderId = 0
let configId = 0
let credentialId = 0
try {
const order = await query<{ id: number }>(
`
@@ -102,8 +107,61 @@ test(
assert.equal(tasks[0]?.orderNo, orderNo)
assert.equal(tasks[0]?.quantity, 1)
assert.equal(tasks[0]?.semiAutoConfig.pageTitle, '虎牙授权测试')
const taskId = Number(tasks[0]?.id || 0)
const credential = await query<{ id: number }>(
`
INSERT INTO semi_auto_credentials (
platform, platform_uid, platform_nickname, credential_payload_json,
credential_source, credential_status, credential_updated_at,
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
'huya', $1, '集成测试虎牙', '{"cookies":[]}',
'qr', 'invalid', NOW(), NOW(), 'CK 已失效', NOW(), NOW()
) RETURNING id
`,
[`it-huya-${suffix}`],
)
credentialId = Number(credential.rows[0]?.id || 0)
await query(
`
UPDATE semi_auto_tasks
SET credential_id = $2,
connector_session_id = $3,
login_status = 'authenticated',
binding_status = 'confirmed',
execution_status = 'waiting_operator',
customer_link_opened_at = NOW(),
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, credentialId, `it-huya-session-${suffix}`],
)
const reauthorized = await requestAdminSemiAutoCustomerReauth(taskId, {
userId: 1,
role: 'support',
displayName: '集成测试客服',
} as never)
assert.equal(reauthorized.task.loginStatus, 'waiting_login')
assert.equal(reauthorized.task.bindingStatus, 'not_started')
assert.equal(reauthorized.task.executionStatus, 'waiting_customer')
assert.equal(reauthorized.task.customerLinkOpenedAt, null)
const retainedCredential = await query<{ credential_id: number }>(
'SELECT credential_id FROM semi_auto_tasks WHERE id = $1',
[taskId],
)
assert.equal(Number(retainedCredential.rows[0]?.credential_id), credentialId)
assert.ok(
reauthorized.events.some((event) => event.eventType === 'customer_reauth_requested'),
)
} finally {
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
if (credentialId > 0) {
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
}
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
@@ -341,12 +399,10 @@ test(
)
test(
'微博账号 CK 以账号级密文保存并可解密加载',
'微博账号 CK 以账号级 Cookie JSON 保存并可加载',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY =
'integration-test-semi-auto-credential-key-123456'
const { closeDb, query, withTransaction } = await import('../../db/client.js')
const { loadSemiAutoCredential, upsertSemiAutoCredentialWithClient } =
await import('./semi-auto-credential-service.js')
@@ -374,18 +430,17 @@ test(
}),
)
const raw = await query<{
credential_ciphertext: string
credential_payload_json: string
credential_source: string
credential_status: string
}>(
`SELECT credential_ciphertext, credential_source, credential_status
`SELECT credential_payload_json, credential_source, credential_status
FROM semi_auto_credentials WHERE id = $1`,
[credentialId],
)
assert.equal(raw.rows[0]?.credential_source, 'sms')
assert.equal(raw.rows[0]?.credential_status, 'valid')
assert.ok(raw.rows[0]?.credential_ciphertext.startsWith('enc:v1:'))
assert.equal(raw.rows[0]?.credential_ciphertext.includes(`secret-sub-${suffix}`), false)
assert.match(raw.rows[0]?.credential_payload_json || '', /"cookies"/)
const credential = await loadSemiAutoCredential(credentialId)
assert.equal(credential?.platformUid, `it-weibo-${suffix}`)
@@ -394,7 +449,6 @@ test(
if (credentialId > 0) {
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
}
delete process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY
await closeDb()
}
},
@@ -8,12 +8,13 @@
* - 一次性浏览器注入票据
*
* 手机号和验证码只保存在短期内存会话中;登录成功后的账号 Cookie 由任务服务
* 导出为账号级凭证,经 AES-256-GCM 加密后落库,不写日志。
* 导出为账号级凭证写入内部凭证表,不写日志。
*/
import crypto from 'node:crypto'
import { createHttpError } from '../../../utils/http.js'
import { logWarn } from '../../../utils/logger.js'
import type {
ConnectorBindingSession,
ConnectorBrowserTicket,
@@ -23,8 +24,10 @@ import type {
const PASSPORT_BASE = 'https://passport.weibo.com'
const WEIBO_BASE = 'https://weibo.com'
const WEIBO_RETURN_URL = 'https://weibo.com/'
const WEIBO_LOGIN_URL =
'https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0&url=https%3A%2F%2Fweibo.com%2F'
`https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0` +
`&url=${WEIBO_RETURN_URL}`
const WEIBO_LOGIN_VER = '20250520'
const SIGNIN_REFERER =
`${PASSPORT_BASE}/sso/signin?entry=miniblog&source=miniblog&disp=popup` +
@@ -312,6 +315,7 @@ type SmsLoginState = {
mobile: string
cookies: WeiboCookieJar
mfaId: string
rid: string
createdAtMs: number
expiresAtMs: number
}
@@ -536,6 +540,7 @@ export class WeiboNativeConnector {
mobile,
cookies: await this.createBootstrapSession(),
mfaId: '',
rid: crypto.randomBytes(22).toString('base64url'),
createdAtMs: this.now(),
expiresAtMs: this.now() + SMS_COOKIE_TTL_MS,
}
@@ -629,7 +634,7 @@ export class WeiboNativeConnector {
scode: encodeSsoField(code),
el: '2',
disp: 'popup',
rid: String(input.rid || '').trim() || 'norid',
rid: String(input.rid || '').trim() || state.rid,
})
const loginUrl = `${PASSPORT_BASE}/sso/v2/login`
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/login`, {
@@ -642,34 +647,49 @@ export class WeiboNativeConnector {
const payload = (await response.json().catch(() => null)) as {
retcode?: number
msg?: string
data?: { data?: { act?: string; location?: string; mfa_id?: string } }
data?: {
act?: string
location?: string
mfa_id?: string
data?: { act?: string; location?: string; mfa_id?: string }
}
} | null
if (!response.ok || !payload) {
logWarn('[weibo/sms-login]', '微博短信登录请求失败', {
taskId: Number(input.taskId) || 0,
status: response.status,
hasPayload: Boolean(payload),
})
throw createHttpError(`微博短信登录失败:HTTP ${response.status}`, {
statusCode: 502,
errorCode: 'weibo_sms_login_failed',
})
}
const act = String(payload.data?.data?.act || '')
const act = String(payload.data?.data?.act || payload.data?.act || '')
if (act === 'mfa_1' || act === 'mfa_2') {
throw createHttpError('微博要求二次验证,请在微博 App 完成验证后重试', {
statusCode: 409,
errorCode: 'weibo_sms_mfa_required',
})
}
if (act !== 'goto') {
const location = String(payload.data?.data?.location || payload.data?.location || '').trim()
if (!location) {
logWarn('[weibo/sms-login]', '微博短信登录未通过', {
taskId: Number(input.taskId) || 0,
status: response.status,
retcode: Number(payload.retcode) || 0,
act,
message: String(payload.msg || '')
.trim()
.slice(0, 200),
dataKeys: payload.data ? Object.keys(payload.data) : [],
nestedDataKeys: payload.data?.data ? Object.keys(payload.data.data) : [],
})
throw createHttpError(payload.msg || '微博短信登录失败,请检查验证码', {
statusCode: 409,
errorCode: 'weibo_sms_login_failed',
})
}
const location = String(payload.data?.data?.location || '').trim()
if (!location) {
throw createHttpError('微博登录成功但缺少跳转地址', {
statusCode: 502,
errorCode: 'weibo_sms_login_failed',
})
}
const nowMs = this.now()
const session: WeiboSession = {
sessionId: crypto.randomBytes(18).toString('base64url'),
@@ -832,14 +852,33 @@ export class WeiboNativeConnector {
}
private async finishLogin(session: WeiboSession, loginUrl: string): Promise<void> {
await this.followWithCookies(loginUrl, session.cookies)
const { finalUrl, body } = await this.followWithCookies(loginUrl, session.cookies)
if (!session.cookies.has(WEIBO_BASE, 'SUB')) {
session.status = 'failed'
session.message = '微博登录成功但未获取到登录 Cookie'
return
}
session.account = await this.fetchAccount(session.cookies.headerFor(WEIBO_BASE))
let homeStatus = 0
let homeBody = ''
session.account = this.parseAccountFromHome(body)
if (!session.account) {
const home = await this.fetchHomePage(session.cookies)
homeStatus = home.status
homeBody = home.body
session.account = this.parseAccountFromHome(homeBody)
}
if (!session.account) {
session.account = await this.fetchAccount(session.cookies.headerFor(WEIBO_BASE))
}
if (!session.account.uid) {
logWarn('[weibo/account]', '微博账号信息读取失败', {
finalUrl,
followedBodyLength: body.length,
followedHasConfig: body.includes('window.$CONFIG'),
homeStatus,
homeBodyLength: homeBody.length,
homeHasConfig: homeBody.includes('window.$CONFIG'),
})
session.status = 'failed'
session.message = '微博登录成功但账号信息读取失败'
return
@@ -851,9 +890,10 @@ export class WeiboNativeConnector {
private async followWithCookies(
url: string,
cookies: WeiboCookieJar,
): Promise<{ finalUrl: string }> {
): Promise<{ finalUrl: string; body: string }> {
let currentUrl = url
for (let index = 0; index < 8; index += 1) {
let body = ''
for (let index = 0; index < 12; index += 1) {
const response = await this.fetchImpl(currentUrl, {
method: 'GET',
headers: {
@@ -871,10 +911,96 @@ export class WeiboNativeConnector {
currentUrl = new URL(location, currentUrl).toString()
continue
}
await response.arrayBuffer().catch(() => undefined)
body = await response.text().catch(() => '')
break
}
return { finalUrl: currentUrl }
return { finalUrl: currentUrl, body }
}
private async fetchHomePage(cookies: WeiboCookieJar): Promise<{ status: number; body: string }> {
try {
const response = await this.fetchImpl(`${WEIBO_BASE}/`, {
method: 'GET',
headers: {
'user-agent': PC_UA,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'zh-CN,zh;q=0.9',
referer: WEIBO_BASE,
cookie: cookies.headerFor(WEIBO_BASE),
},
redirect: 'follow',
signal: AbortSignal.timeout(this.timeoutMs),
})
cookies.addFromResponse(response.headers, response.url || `${WEIBO_BASE}/`)
return {
status: response.status,
body: await response.text().catch(() => ''),
}
} catch {
return { status: 0, body: '' }
}
}
private parseAccountFromHome(htmlInput: string): WeiboAccount | null {
const html = String(htmlInput || '')
const markerIndex = html.indexOf('window.$CONFIG')
if (markerIndex < 0) return null
const start = html.indexOf('{', markerIndex)
if (start < 0) return null
let depth = 0
let inString = false
let escaped = false
let end = -1
for (let index = start; index < html.length; index += 1) {
const char = html[index]
if (inString) {
if (escaped) {
escaped = false
} else if (char === '\\') {
escaped = true
} else if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
continue
}
if (char === '{') depth += 1
if (char === '}') {
depth -= 1
if (depth === 0) {
end = index + 1
break
}
}
}
if (end <= start) return null
try {
const config = JSON.parse(html.slice(start, end)) as {
uid?: number | string
user?: {
id?: number | string
idstr?: number | string
screen_name?: string
avatar_large?: string
avatar_hd?: string
}
}
const user = config.user
const uid = String(user?.idstr || user?.id || config.uid || '')
if (!uid) return null
return {
uid,
nickname: String(user?.screen_name || ''),
avatar: String(user?.avatar_large || user?.avatar_hd || ''),
}
} catch {
return null
}
}
private async fetchAccount(cookie: string): Promise<WeiboAccount> {
@@ -102,10 +102,21 @@ function defaultHandler(url: string): Response {
})
}
if (url === 'https://weibo.com/') {
return new Response('<html></html>', {
status: 200,
headers: { 'content-type': 'text/html' },
})
return new Response(
`<html><script>window.$CONFIG = ${JSON.stringify({
uid: 123456,
user: {
id: 123456,
idstr: '123456',
screen_name: '微博测试账号',
avatar_large: 'https://tvax1.sinaimg.cn/demo.jpg',
},
})};</script></html>`,
{
status: 200,
headers: { 'content-type': 'text/html' },
},
)
}
if (url.includes('/ajax/side/info')) {
return jsonResponse({
@@ -226,6 +237,10 @@ test('微博短信登录建立会话并可生成一次性注入票据', async ()
assert.equal(session.status, 'authenticated')
assert.equal(session.huyaUid, '123456')
assert.equal(session.nickname, '微博测试账号')
assert.equal(
calls.some((call) => call.url.includes('/ajax/side/info')),
false,
)
const loginCall = calls.find((item) => item.url.includes('/sso/v2/login'))
assert.ok(loginCall)
@@ -233,6 +248,19 @@ test('微博短信登录建立会话并可生成一次性注入票据', async ()
assert.equal(form.get('type'), '2')
assert.equal(form.get('username'), 'T3Gs3iR_9fN7A9t')
assert.equal(form.get('scode'), 'T3r81iMS')
assert.equal(
form.get('url'),
'https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0&url=https://weibo.com/',
)
assert.equal(form.get('rid')?.length, 30)
assert.notEqual(form.get('rid'), 'norid')
const loginHeaders = loginCall.init.headers as Record<string, string>
assert.equal(
loginHeaders.referer,
'https://passport.weibo.com/sso/signin?entry=miniblog&source=miniblog&disp=popup' +
'&url=https%3A%2F%2Fweibo.com%2Fnewlogin%3Ftabtype%3Dweibo%26gid%3D102803' +
'%26openLoginLayer%3D0%26url%3Dhttps%3A%2F%2Fweibo.com%2F&from=weibopro',
)
const ticket = await connector.createBrowserTicket({
sessionId,
@@ -248,6 +276,63 @@ test('微博短信登录建立会话并可生成一次性注入票据', async ()
await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /票据不存在或已过期/)
})
test('微博短信登录兼容成功响应省略 act、直接返回 data.location', async () => {
const { fetchImpl } = createFetchMock((url) => {
if (url.includes('/sso/v2/login')) {
return jsonResponse({
retcode: 20000000,
msg: '',
data: {
location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo-flat',
},
})
}
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB-FLAT',
mobile: '13800138000',
code: '123456',
})
assert.equal(session.status, 'authenticated')
assert.equal(session.hasCredential, true)
})
test('微博登录落地页无账号配置时会显式请求首页读取账号', async () => {
const { fetchImpl, calls } = createFetchMock((url) => {
if (url.includes('/sso/v2/goto')) {
return new Response(null, {
status: 302,
headers: { location: 'https://weibo.com/intermediate' },
})
}
if (url === 'https://weibo.com/intermediate') {
return new Response('<html>intermediate</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
})
}
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB-HOME',
mobile: '13800138000',
code: '123456',
})
assert.equal(session.status, 'authenticated')
assert.equal(session.huyaUid, '123456')
assert.ok(calls.some((call) => call.url === 'https://weibo.com/intermediate'))
assert.ok(calls.some((call) => call.url === 'https://weibo.com/'))
})
test('微博短信发送遇到 mfa_1 时返回易盾挑战,完成挑战后可用 mfa_id 重发', async () => {
let sendCount = 0
const { fetchImpl, calls } = createFetchMock((url, init) => {