完善半自动履约凭证与客户重新授权流程
统一虎牙和微博账号级 CK 保存与导出;CK 失效后回到客户重新登录,后台支持重发授权;客户授权页改为统一深色布局并收敛开发迁移配置。
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user