完善虎牙短信登录与图形安全验证
This commit is contained in:
@@ -3,13 +3,16 @@ import { Router } from 'express'
|
||||
import {
|
||||
getPublicWeiboCountryCodes,
|
||||
listPublicSemiAutoTasks,
|
||||
loginPublicHuyaSms,
|
||||
loginPublicWeiboSms,
|
||||
markCustomerLinkOpened,
|
||||
pollPublicLogin,
|
||||
pollPublicWeiboQrLogin,
|
||||
sendPublicHuyaSmsCode,
|
||||
sendPublicWeiboSmsCode,
|
||||
startPublicLogin,
|
||||
startPublicWeiboQrLogin,
|
||||
verifyPublicHuyaSmsChallenge,
|
||||
} from '../services/semi-auto/semi-auto-task-service.js'
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import { createRouteHandler } from '../utils/http.js'
|
||||
@@ -76,6 +79,45 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/huya/sms/send',
|
||||
writeLimit,
|
||||
createRouteHandler(
|
||||
(req) => sendPublicHuyaSmsCode(req.params.orderNo, req.params.taskId, req.body?.mobile),
|
||||
{
|
||||
successMessage: '验证码请求已处理',
|
||||
errorMessage: '发送虎牙短信验证码失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/huya/sms/send]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/huya/sms/login',
|
||||
writeLimit,
|
||||
createRouteHandler(
|
||||
(req) => loginPublicHuyaSms(req.params.orderNo, req.params.taskId, req.body || {}),
|
||||
{
|
||||
successMessage: '虎牙登录成功',
|
||||
errorMessage: '虎牙短信登录失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/huya/sms/login]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/huya/sms/verify',
|
||||
writeLimit,
|
||||
createRouteHandler(
|
||||
(req) => verifyPublicHuyaSmsChallenge(req.params.orderNo, req.params.taskId, req.body || {}),
|
||||
{
|
||||
successMessage: '安全验证已处理',
|
||||
errorMessage: '虎牙安全验证失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/huya/sms/verify]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:orderNo/tasks/:taskId/weibo/sms/send',
|
||||
writeLimit,
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
createMockLoginSession,
|
||||
getMockBinding,
|
||||
getMockLoginSession,
|
||||
loginMockHuyaSms,
|
||||
listMockSessions,
|
||||
resetMockSessions,
|
||||
revokeMockSession,
|
||||
sendMockHuyaSmsCode,
|
||||
setMockBindingStatus,
|
||||
setMockLoginStatus,
|
||||
startMockBinding,
|
||||
@@ -75,6 +77,30 @@ router.get(
|
||||
handle((req) => getMockLoginSession(req.params.sessionId)),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/api/internal/huya-semi-auto/sms/send',
|
||||
handle((req) => {
|
||||
const body = bodyOf(req)
|
||||
return sendMockHuyaSmsCode({
|
||||
taskId: body.task_id ?? body.taskId,
|
||||
orderNo: body.order_no ?? body.orderNo,
|
||||
mobile: body.mobile,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/api/internal/huya-semi-auto/sms/login',
|
||||
handle((req) => {
|
||||
const body = bodyOf(req)
|
||||
return loginMockHuyaSms({
|
||||
sessionId: body.session_id ?? body.sessionId,
|
||||
mobile: body.mobile,
|
||||
code: body.code,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/api/internal/huya-semi-auto/sessions/:sessionId',
|
||||
handle((req) => revokeMockSession(req.params.sessionId)),
|
||||
|
||||
@@ -368,9 +368,9 @@ export async function createSemiAutoMock(
|
||||
platform === 'weibo'
|
||||
? '真实登录:使用微博绑定手机号接收验证码登录,或使用微博 App 扫码登录。登录信息仅用于本次订单履约。'
|
||||
: connectorMode === 'mock'
|
||||
? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。'
|
||||
: '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。',
|
||||
...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}),
|
||||
? 'Mock 登录:使用手机号发送验证码后输入 123456,或点击获取二维码模拟登录。'
|
||||
: '真实登录:使用虎牙绑定手机号接收验证码登录,或使用虎牙 App 扫码登录。',
|
||||
loginMethods: ['sms', 'qr'],
|
||||
browserTargets: [
|
||||
{
|
||||
key: platform,
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
exportMockSessionCredential,
|
||||
getMockBinding,
|
||||
getMockLoginSession,
|
||||
loginMockHuyaSms,
|
||||
resetMockSessions,
|
||||
revokeMockSession,
|
||||
sendMockHuyaSmsCode,
|
||||
setMockBindingStatus,
|
||||
setMockLoginStatus,
|
||||
startMockBinding,
|
||||
@@ -87,3 +89,27 @@ test('Mock Connector 支持控制状态、撤销会话和重置', () => {
|
||||
assert.equal(reset.cleared, 1)
|
||||
assert.throws(() => getMockLoginSession(second.sessionId), /不存在或已失效/)
|
||||
})
|
||||
|
||||
test('Mock Connector 支持手动虎牙短信发码与验证码登录', () => {
|
||||
const sent = sendMockHuyaSmsCode({
|
||||
taskId: 303,
|
||||
orderNo: 'MOCK-SA-SMS',
|
||||
mobile: '13800138000',
|
||||
})
|
||||
assert.equal(sent.status, 'waiting_code')
|
||||
assert.equal(sent.qrContent, '')
|
||||
assert.equal(sent.hasCredential, false)
|
||||
assert.throws(
|
||||
() => loginMockHuyaSms({ sessionId: sent.sessionId, mobile: '13800138000', code: '000000' }),
|
||||
/验证码错误或已过期/,
|
||||
)
|
||||
|
||||
const authenticated = loginMockHuyaSms({
|
||||
sessionId: sent.sessionId,
|
||||
mobile: '13800138000',
|
||||
code: '123456',
|
||||
})
|
||||
assert.equal(authenticated.status, 'authenticated')
|
||||
assert.equal(authenticated.hasCredential, true)
|
||||
assert.ok(authenticated.huyaUid)
|
||||
})
|
||||
|
||||
@@ -40,6 +40,8 @@ type MockSession = {
|
||||
loginExpiresAtMs: number
|
||||
credentialExpiresAtMs: number
|
||||
qrContent: string
|
||||
smsPhone: string
|
||||
smsCode: string
|
||||
huyaUid: string
|
||||
nickname: string
|
||||
hasCredential: boolean
|
||||
@@ -144,6 +146,8 @@ function bindingView(session: MockSession): ConnectorBindingSession {
|
||||
function markAuthenticated(session: MockSession) {
|
||||
session.loginStatus = 'authenticated'
|
||||
session.hasCredential = true
|
||||
session.smsPhone = ''
|
||||
session.smsCode = ''
|
||||
session.huyaUid = buildMockUid(session.sessionId)
|
||||
session.nickname = 'Mock虎牙用户'
|
||||
session.credentialExpiresAtMs = Date.now() + CREDENTIAL_TTL_MS
|
||||
@@ -174,6 +178,8 @@ export function createMockLoginSession(input: {
|
||||
loginExpiresAtMs: now + LOGIN_QR_TTL_MS,
|
||||
credentialExpiresAtMs: 0,
|
||||
qrContent: `mock-huya-login://${sessionId}`,
|
||||
smsPhone: '',
|
||||
smsCode: '',
|
||||
huyaUid: '',
|
||||
nickname: '',
|
||||
hasCredential: false,
|
||||
@@ -190,6 +196,78 @@ export function createMockLoginSession(input: {
|
||||
return loginView(session)
|
||||
}
|
||||
|
||||
export function sendMockHuyaSmsCode(input: {
|
||||
taskId?: unknown
|
||||
orderNo?: unknown
|
||||
mobile?: unknown
|
||||
}): ConnectorLoginSession {
|
||||
const mobile = String(input.mobile || '').replace(/\D/g, '')
|
||||
if (!/^1\d{10}$/.test(mobile)) {
|
||||
throw createHttpError('请输入正确的手机号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'huya_sms_mobile_invalid',
|
||||
})
|
||||
}
|
||||
const sessionId = crypto.randomUUID()
|
||||
const now = Date.now()
|
||||
const session: MockSession = {
|
||||
sessionId,
|
||||
taskId: Number(input.taskId || 0),
|
||||
orderNo: String(input.orderNo || '').trim(),
|
||||
createdAtMs: now,
|
||||
loginStatus: 'waiting_code',
|
||||
loginPolls: 0,
|
||||
loginExpiresAtMs: now + LOGIN_QR_TTL_MS,
|
||||
credentialExpiresAtMs: 0,
|
||||
qrContent: '',
|
||||
smsPhone: mobile,
|
||||
smsCode: '123456',
|
||||
huyaUid: '',
|
||||
nickname: '',
|
||||
hasCredential: false,
|
||||
bindingStatus: 'not_started',
|
||||
bindingPolls: 0,
|
||||
bindingQrExpiresAtMs: 0,
|
||||
bindGameAccount: '',
|
||||
bindGameRole: '',
|
||||
bindGameChannel: '',
|
||||
message: 'Mock 短信验证码已发送,请输入 123456',
|
||||
tickets: new Map(),
|
||||
}
|
||||
sessions.set(sessionId, session)
|
||||
return loginView(session)
|
||||
}
|
||||
|
||||
export function loginMockHuyaSms(input: {
|
||||
sessionId?: unknown
|
||||
mobile?: unknown
|
||||
code?: unknown
|
||||
}): ConnectorLoginSession {
|
||||
const session = requireSession(input.sessionId)
|
||||
if (session.loginStatus === 'authenticated') return loginView(session)
|
||||
if (session.loginStatus !== 'waiting_code') {
|
||||
throw createHttpError('Mock 短信登录会话状态无效', {
|
||||
statusCode: 409,
|
||||
errorCode: 'mock_huya_sms_session_invalid',
|
||||
})
|
||||
}
|
||||
const mobile = String(input.mobile || '').replace(/\D/g, '')
|
||||
if (mobile !== session.smsPhone) {
|
||||
throw createHttpError('手机号与发送验证码时不一致', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_phone_mismatch',
|
||||
})
|
||||
}
|
||||
if (String(input.code || '').trim() !== session.smsCode) {
|
||||
throw createHttpError('验证码错误或已过期', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_login_failed',
|
||||
})
|
||||
}
|
||||
markAuthenticated(session)
|
||||
return loginView(session)
|
||||
}
|
||||
|
||||
export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSession {
|
||||
const session = requireSession(sessionIdInput)
|
||||
if (session.loginStatus === 'waiting_scan' || session.loginStatus === 'scanned') {
|
||||
@@ -371,7 +449,11 @@ export function setMockLoginStatus(
|
||||
): ConnectorLoginSession {
|
||||
const session = requireSession(sessionIdInput)
|
||||
const status = String(statusInput || '').trim() as MockLoginStatus
|
||||
if (!['waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes(status)) {
|
||||
if (
|
||||
!['waiting_code', 'waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes(
|
||||
status,
|
||||
)
|
||||
) {
|
||||
throw createHttpError('Mock 登录状态无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'mock_semi_auto_login_status_invalid',
|
||||
|
||||
@@ -45,6 +45,99 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async ()
|
||||
}
|
||||
})
|
||||
|
||||
test('虎牙短信发码和登录向外部 Connector 传递完整任务上下文', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: Array<{ url: string; body: Record<string, unknown> }> = []
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init?.body || '{}')) as Record<string, unknown>,
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sessionId: 'sms-session-1',
|
||||
status: 'waiting_code',
|
||||
qrContent: '',
|
||||
expiresAt: '2026-11-14T00:00:00Z',
|
||||
huyaUid: '',
|
||||
nickname: '',
|
||||
hasCredential: false,
|
||||
message: '验证码已发送',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch
|
||||
try {
|
||||
await connector.sendHuyaSmsCode(
|
||||
{ taskId: 123, orderNo: 'ORDER-1', mobile: '13800138000' },
|
||||
'external',
|
||||
)
|
||||
await connector.loginHuyaSms(
|
||||
{
|
||||
sessionId: 'sms-session-1',
|
||||
taskId: 123,
|
||||
orderNo: 'ORDER-1',
|
||||
mobile: '13800138000',
|
||||
code: '123456',
|
||||
},
|
||||
'external',
|
||||
)
|
||||
assert.equal(requests[0]?.url, 'http://connector.test/api/internal/huya-semi-auto/sms/send')
|
||||
assert.deepEqual(requests[0]?.body, {
|
||||
task_id: 123,
|
||||
order_no: 'ORDER-1',
|
||||
mobile: '13800138000',
|
||||
})
|
||||
assert.equal(requests[1]?.url, 'http://connector.test/api/internal/huya-semi-auto/sms/login')
|
||||
assert.deepEqual(requests[1]?.body, {
|
||||
session_id: 'sms-session-1',
|
||||
task_id: 123,
|
||||
order_no: 'ORDER-1',
|
||||
mobile: '13800138000',
|
||||
code: '123456',
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('外部虎牙 Connector 接收客户完成的滑块距离', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestUrl = ''
|
||||
let requestBody: Record<string, unknown> = {}
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
requestUrl = String(url)
|
||||
requestBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
session: {
|
||||
sessionId: 'sms-session-1',
|
||||
status: 'waiting_code',
|
||||
qrContent: '',
|
||||
expiresAt: '2026-11-14T00:00:00Z',
|
||||
huyaUid: '',
|
||||
nickname: '',
|
||||
hasCredential: false,
|
||||
message: '验证码已发送',
|
||||
},
|
||||
challenge: null,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch
|
||||
try {
|
||||
const result = await connector.verifyHuyaSmsChallenge(
|
||||
{ sessionId: 'sms-session-1', distance: 126 },
|
||||
'external',
|
||||
)
|
||||
assert.equal(result.session.status, 'waiting_code')
|
||||
assert.equal(requestUrl, 'http://connector.test/api/internal/huya-semi-auto/sms/verify')
|
||||
assert.deepEqual(requestBody, { session_id: 'sms-session-1', distance: 126 })
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('外部虎牙 Connector 支持导出账号级 CK', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestUrl = ''
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
devMockConnectorBaseUrl,
|
||||
} from '../dev-mock/connector-endpoint.js'
|
||||
import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js'
|
||||
import { exportMockSessionCredential } from '../dev-mock/mock-semi-auto-connector.js'
|
||||
import {
|
||||
exportMockSessionCredential,
|
||||
loginMockHuyaSms,
|
||||
sendMockHuyaSmsCode,
|
||||
} from '../dev-mock/mock-semi-auto-connector.js'
|
||||
import {
|
||||
HUYA_NATIVE_TICKET_PREFIX,
|
||||
huyaNativeConnector,
|
||||
@@ -15,7 +19,14 @@ import {
|
||||
|
||||
export type ConnectorLoginSession = {
|
||||
sessionId: string
|
||||
status: 'waiting_scan' | 'scanned' | 'authenticated' | 'expired' | 'failed'
|
||||
status:
|
||||
| 'verification_pending'
|
||||
| 'waiting_code'
|
||||
| 'waiting_scan'
|
||||
| 'scanned'
|
||||
| 'authenticated'
|
||||
| 'expired'
|
||||
| 'failed'
|
||||
qrContent: string
|
||||
qrImage?: string
|
||||
expiresAt: string
|
||||
@@ -25,6 +36,53 @@ export type ConnectorLoginSession = {
|
||||
message: string
|
||||
}
|
||||
|
||||
type HuyaSmsChallengeBase = {
|
||||
image: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type HuyaSmsChallenge =
|
||||
| (HuyaSmsChallengeBase & {
|
||||
type: 'slider'
|
||||
puzzleImage: string
|
||||
puzzleWidth: number
|
||||
puzzleY: number
|
||||
})
|
||||
| (HuyaSmsChallengeBase & {
|
||||
type: 'rotate'
|
||||
puzzleImage: string
|
||||
puzzleWidth: number
|
||||
puzzleHeight: number
|
||||
puzzleX: number
|
||||
puzzleY: number
|
||||
})
|
||||
| (HuyaSmsChallengeBase & {
|
||||
type: 'click'
|
||||
tipImage: string
|
||||
question: string
|
||||
wordSize: number
|
||||
})
|
||||
|
||||
export type HuyaChallengeSolution = {
|
||||
distance?: number
|
||||
angle?: number
|
||||
points?: Array<{ x: number; y: number }>
|
||||
}
|
||||
|
||||
export type HuyaSmsChallengeInput = HuyaChallengeSolution & {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type HuyaSmsSendResult = {
|
||||
session: ConnectorLoginSession
|
||||
challenge: HuyaSmsChallenge | null
|
||||
}
|
||||
|
||||
/*
|
||||
* Connector 的登录会话由运行期实现返回,具体挑战结构见上方联合类型。
|
||||
*/
|
||||
|
||||
export type ConnectorBindingSession = {
|
||||
sessionId: string
|
||||
status:
|
||||
@@ -220,6 +278,108 @@ export async function getHuyaLoginSession(
|
||||
)
|
||||
}
|
||||
|
||||
export async function sendHuyaSmsCode(
|
||||
input: {
|
||||
taskId: number
|
||||
orderNo: string
|
||||
mobile: string
|
||||
},
|
||||
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
|
||||
): Promise<HuyaSmsSendResult> {
|
||||
if (mode === 'embedded') return huyaNativeConnector.sendSmsCode(input)
|
||||
if (mode === 'mock') {
|
||||
return {
|
||||
session: sendMockHuyaSmsCode({
|
||||
taskId: input.taskId,
|
||||
orderNo: input.orderNo,
|
||||
mobile: input.mobile,
|
||||
}),
|
||||
challenge: null,
|
||||
}
|
||||
}
|
||||
const payload = await callConnector<HuyaSmsSendResult | ConnectorLoginSession>(
|
||||
'/api/internal/huya-semi-auto/sms/send',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
task_id: input.taskId,
|
||||
order_no: input.orderNo,
|
||||
mobile: input.mobile,
|
||||
},
|
||||
},
|
||||
mode,
|
||||
)
|
||||
return 'session' in payload ? payload : { session: payload, challenge: null }
|
||||
}
|
||||
|
||||
export async function verifyHuyaSmsChallenge(
|
||||
input: HuyaSmsChallengeInput,
|
||||
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
|
||||
): Promise<HuyaSmsSendResult> {
|
||||
if (mode === 'embedded') {
|
||||
return huyaNativeConnector.verifySmsChallenge(input.sessionId, input)
|
||||
}
|
||||
if (mode === 'mock') {
|
||||
throw createHttpError('Mock Connector 不使用虎牙安全验证', {
|
||||
statusCode: 409,
|
||||
errorCode: 'mock_huya_sms_challenge_unsupported',
|
||||
})
|
||||
}
|
||||
return callConnector<HuyaSmsSendResult>(
|
||||
'/api/internal/huya-semi-auto/sms/verify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
session_id: input.sessionId,
|
||||
distance: input.distance,
|
||||
angle: input.angle,
|
||||
points: input.points,
|
||||
},
|
||||
},
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
export async function loginHuyaSms(
|
||||
input: {
|
||||
sessionId: string
|
||||
taskId: number
|
||||
orderNo: string
|
||||
mobile: string
|
||||
code: string
|
||||
},
|
||||
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
|
||||
): Promise<ConnectorLoginSession> {
|
||||
if (mode === 'embedded') {
|
||||
return huyaNativeConnector.loginWithSms({
|
||||
sessionId: input.sessionId,
|
||||
mobile: input.mobile,
|
||||
code: input.code,
|
||||
})
|
||||
}
|
||||
if (mode === 'mock') {
|
||||
return loginMockHuyaSms({
|
||||
sessionId: input.sessionId,
|
||||
mobile: input.mobile,
|
||||
code: input.code,
|
||||
})
|
||||
}
|
||||
return callConnector<ConnectorLoginSession>(
|
||||
'/api/internal/huya-semi-auto/sms/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
session_id: input.sessionId,
|
||||
task_id: input.taskId,
|
||||
order_no: input.orderNo,
|
||||
mobile: input.mobile,
|
||||
code: input.code,
|
||||
},
|
||||
},
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
export async function startHuyaBinding(
|
||||
sessionId: string,
|
||||
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* order_site 内置虎牙半自动履约 Connector。
|
||||
*
|
||||
* 登录、角色绑定、CK 兑换和一次性浏览器注入票据全部在本进程完成。原始 CK
|
||||
* 只保存在内存会话中,不落库、不写日志、不出现在票据字符串里。
|
||||
* 扫码/短信登录、角色绑定、CK 兑换和一次性浏览器注入票据全部在本进程完成。
|
||||
* 原始 CK 只保存在内存会话中,不写日志、不出现在票据字符串里。
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
@@ -11,14 +11,18 @@ import path from 'node:path'
|
||||
|
||||
import { runtimeConfig } from '../../../config/runtime.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import type {
|
||||
ConnectorBindingSession,
|
||||
ConnectorBrowserTicket,
|
||||
ConnectorInjectionPayload,
|
||||
ConnectorLoginSession,
|
||||
HuyaSmsChallenge,
|
||||
HuyaSmsSendResult,
|
||||
} from '../huya-connector-client.js'
|
||||
import {
|
||||
cookiePairs,
|
||||
cookieValue,
|
||||
ensureHuyaWebCookieIdentity,
|
||||
fillWebCookieFields,
|
||||
mergeHuyaWebCookie,
|
||||
@@ -41,8 +45,10 @@ import {
|
||||
type HuyaBindResponse,
|
||||
type HuyaGameAccountData,
|
||||
} from './huya-rpc.js'
|
||||
import { HuyaSmsRole, type HuyaChallengeSolution } from './huya-sms-role.js'
|
||||
|
||||
const LOGIN_QR_TTL_MS = 5 * 60 * 1000
|
||||
const SMS_LOGIN_TTL_MS = 5 * 60 * 1000
|
||||
const CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const BINDING_QR_TTL_MS = 5 * 60 * 1000
|
||||
const BROWSER_TICKET_TTL_MS = 2 * 60 * 1000
|
||||
@@ -82,7 +88,7 @@ type NativeSession = {
|
||||
stateDir: string
|
||||
createdAtMs: number
|
||||
expiresAtMs: number
|
||||
qr: HuyaQrRole
|
||||
role: HuyaQrRole | HuyaSmsRole
|
||||
behavior: string
|
||||
page: string
|
||||
qrid: string
|
||||
@@ -91,6 +97,9 @@ type NativeSession = {
|
||||
hdid: string
|
||||
deviceInfo: Record<string, string>
|
||||
status: NativeLoginStatus
|
||||
smsSessionData: string
|
||||
smsPhone: string
|
||||
smsChallenge: HuyaSmsChallenge | null
|
||||
uid: number
|
||||
nickname: string
|
||||
cookie: string
|
||||
@@ -152,6 +161,61 @@ function readString(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function normalizeMainlandMobile(value: unknown): string {
|
||||
const digits = String(value ?? '').replace(/\D/g, '')
|
||||
const national = digits.startsWith('086')
|
||||
? digits.slice(3)
|
||||
: digits.startsWith('86') && digits.length === 13
|
||||
? digits.slice(2)
|
||||
: digits
|
||||
if (!/^1\d{10}$/.test(national)) {
|
||||
throw createHttpError('请输入正确的手机号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'huya_sms_mobile_invalid',
|
||||
})
|
||||
}
|
||||
return `086${national}`
|
||||
}
|
||||
|
||||
function smsBehavior(page = 'https://www.huya.com/g'): { behavior: string; page: string } {
|
||||
let now = Date.now() - (8000 + Math.floor(Math.random() * 10001))
|
||||
let elapsed = 1200 + Math.floor(Math.random() * 1201)
|
||||
const actions: Array<Record<string, unknown>> = [
|
||||
{
|
||||
id: '12',
|
||||
x: 520 + Math.floor(Math.random() * 101),
|
||||
y: 45 + Math.floor(Math.random() * 31),
|
||||
d: elapsed,
|
||||
time: now,
|
||||
},
|
||||
]
|
||||
for (const actionId of ['17', '17']) {
|
||||
const step = 1800 + Math.floor(Math.random() * 2401)
|
||||
now += step
|
||||
elapsed += step
|
||||
actions.push({ id: actionId, d: elapsed, time: now })
|
||||
}
|
||||
now += 80 + Math.floor(Math.random() * 161)
|
||||
elapsed += 80 + Math.floor(Math.random() * 161)
|
||||
actions.push({
|
||||
id: '18',
|
||||
x: 610 + Math.floor(Math.random() * 51),
|
||||
y: 165 + Math.floor(Math.random() * 31),
|
||||
d: elapsed,
|
||||
time: now,
|
||||
})
|
||||
return {
|
||||
behavior: encodeURIComponent(
|
||||
JSON.stringify({
|
||||
furl: page,
|
||||
curl: page,
|
||||
user_action: actions,
|
||||
}),
|
||||
),
|
||||
page: encodeURIComponent(page),
|
||||
}
|
||||
}
|
||||
|
||||
function parseRole(response: HuyaBindResponse | null): {
|
||||
account: string
|
||||
role: string
|
||||
@@ -242,7 +306,7 @@ export class HuyaNativeConnector {
|
||||
stateDir,
|
||||
createdAtMs,
|
||||
expiresAtMs: createdAtMs + LOGIN_QR_TTL_MS,
|
||||
qr,
|
||||
role: qr,
|
||||
behavior: behavior.behavior,
|
||||
page: behavior.page,
|
||||
qrid,
|
||||
@@ -257,6 +321,9 @@ export class HuyaNativeConnector {
|
||||
udb_appid: '5002',
|
||||
},
|
||||
status: 'waiting_scan',
|
||||
smsSessionData: '',
|
||||
smsPhone: '',
|
||||
smsChallenge: null,
|
||||
uid: 0,
|
||||
nickname: '',
|
||||
cookie: '',
|
||||
@@ -283,16 +350,20 @@ export class HuyaNativeConnector {
|
||||
if (
|
||||
session.status === 'authenticated' ||
|
||||
session.status === 'failed' ||
|
||||
session.status === 'expired'
|
||||
session.status === 'expired' ||
|
||||
session.status === 'verification_pending'
|
||||
) {
|
||||
return this.loginView(session)
|
||||
}
|
||||
if (session.expiresAtMs <= this.now()) {
|
||||
session.status = 'expired'
|
||||
session.message = '登录二维码已过期,请重新获取'
|
||||
session.message = session.smsSessionData
|
||||
? '短信验证码已过期,请重新获取'
|
||||
: '登录二维码已过期,请重新获取'
|
||||
return this.loginView(session)
|
||||
}
|
||||
const response = await session.qr.call('/qrLgn/tryQrLogin', '70003', {
|
||||
if (session.smsSessionData) return this.loginView(session)
|
||||
const response = await session.role.call('/qrLgn/tryQrLogin', '70003', {
|
||||
qrId: session.qrid,
|
||||
remember: '1',
|
||||
domainList: '',
|
||||
@@ -314,6 +385,222 @@ export class HuyaNativeConnector {
|
||||
})
|
||||
}
|
||||
|
||||
async sendSmsCode(input: {
|
||||
taskId: number
|
||||
orderNo: string
|
||||
mobile: string
|
||||
}): Promise<HuyaSmsSendResult> {
|
||||
this.prune()
|
||||
const sessionId = crypto.randomBytes(24).toString('base64url')
|
||||
const stateDir = path.join(this.stateRoot, sessionId)
|
||||
const phone = normalizeMainlandMobile(input.mobile)
|
||||
try {
|
||||
const sms = new HuyaSmsRole(this.fetchImpl)
|
||||
await sms.prepareDevice()
|
||||
await sms.bootstrapMiddle()
|
||||
const device = sms.device()
|
||||
const behavior = smsBehavior()
|
||||
const response = await sms.call('/web/v2/smsCode', '60027', {
|
||||
phone,
|
||||
behavior: behavior.behavior,
|
||||
page: behavior.page,
|
||||
})
|
||||
logWarn('huya/sms/raw-response', '虎牙短信发码原始响应', response)
|
||||
const returnCode = extractInt(response.returnCode)
|
||||
const sessionData = readString(response.data?.sessionData)
|
||||
const challenge =
|
||||
returnCode === 10030 || returnCode === 10039 ? await sms.prepareChallenge(response) : null
|
||||
if (returnCode !== 0 && returnCode !== 10030 && returnCode !== 10039) {
|
||||
throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
|
||||
statusCode: 502,
|
||||
errorCode: 'huya_sms_send_failed',
|
||||
})
|
||||
}
|
||||
if (!challenge && !sessionData) {
|
||||
throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
|
||||
statusCode: 502,
|
||||
errorCode: 'huya_sms_send_failed',
|
||||
})
|
||||
}
|
||||
const createdAtMs = this.now()
|
||||
const session: NativeSession = {
|
||||
sessionId,
|
||||
orderNo: input.orderNo,
|
||||
taskId: input.taskId,
|
||||
stateDir,
|
||||
createdAtMs,
|
||||
expiresAtMs: createdAtMs + SMS_LOGIN_TTL_MS,
|
||||
role: sms,
|
||||
behavior: behavior.behavior,
|
||||
page: behavior.page,
|
||||
qrid: '',
|
||||
qrImage: '',
|
||||
sdid: device.sdid,
|
||||
hdid: device.hdid,
|
||||
deviceInfo: {
|
||||
...Object.fromEntries(cookiePairs(sms.cookieHeader())),
|
||||
sdid: device.sdid,
|
||||
hdid: device.hdid,
|
||||
udb_appid: '5002',
|
||||
},
|
||||
status: challenge ? 'verification_pending' : 'waiting_code',
|
||||
smsSessionData: sessionData,
|
||||
smsPhone: phone,
|
||||
smsChallenge: challenge,
|
||||
uid: 0,
|
||||
nickname: '',
|
||||
cookie: '',
|
||||
message: challenge ? '请完成安全验证后发送验证码' : '验证码已发送,请注意查收',
|
||||
binding: defaultBindingState(),
|
||||
tickets: new Map(),
|
||||
}
|
||||
this.sessions.set(sessionId, session)
|
||||
this.prune()
|
||||
return {
|
||||
session: this.loginView(session),
|
||||
challenge,
|
||||
}
|
||||
} catch (error) {
|
||||
await removeFingerprintState(stateDir)
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
throw createHttpError(
|
||||
`发送虎牙短信验证码失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
{ statusCode: 502, errorCode: 'huya_sms_send_failed' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async verifySmsChallenge(
|
||||
sessionId: string,
|
||||
solution: HuyaChallengeSolution,
|
||||
): Promise<HuyaSmsSendResult> {
|
||||
return this.withLock(sessionId, async () => {
|
||||
const session = this.requireSession(sessionId)
|
||||
if (session.expiresAtMs <= this.now()) {
|
||||
session.status = 'expired'
|
||||
session.message = '安全验证已过期,请重新发送验证码'
|
||||
throw createHttpError(session.message, {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_verification_expired',
|
||||
})
|
||||
}
|
||||
if (!session.smsChallenge || !(session.role instanceof HuyaSmsRole)) {
|
||||
throw createHttpError('当前没有待完成的虎牙安全验证', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_verification_not_found',
|
||||
})
|
||||
}
|
||||
try {
|
||||
await session.role.solveChallenge(solution)
|
||||
} catch (error) {
|
||||
throw createHttpError(
|
||||
error instanceof Error ? error.message : '虎牙安全验证未通过,请重新拖动',
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_verification_failed',
|
||||
},
|
||||
)
|
||||
}
|
||||
const behavior = smsBehavior()
|
||||
const response = await session.role.call('/web/v2/smsCode', '60027', {
|
||||
phone: session.smsPhone,
|
||||
behavior: behavior.behavior,
|
||||
page: behavior.page,
|
||||
})
|
||||
const returnCode = extractInt(response.returnCode)
|
||||
const sessionData = readString(response.data?.sessionData)
|
||||
if (returnCode === 10030 || returnCode === 10039) {
|
||||
const challenge = await session.role.prepareChallenge(response)
|
||||
session.smsChallenge = challenge
|
||||
session.status = 'verification_pending'
|
||||
session.expiresAtMs = this.now() + SMS_LOGIN_TTL_MS
|
||||
session.message = '请继续完成安全验证'
|
||||
return {
|
||||
session: this.loginView(session),
|
||||
challenge,
|
||||
}
|
||||
}
|
||||
if (returnCode !== 0 || !sessionData) {
|
||||
throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
|
||||
statusCode: 502,
|
||||
errorCode: 'huya_sms_send_failed',
|
||||
})
|
||||
}
|
||||
session.smsSessionData = sessionData
|
||||
session.smsChallenge = null
|
||||
session.status = 'waiting_code'
|
||||
session.expiresAtMs = this.now() + SMS_LOGIN_TTL_MS
|
||||
session.message = '验证码已发送,请注意查收'
|
||||
return {
|
||||
session: this.loginView(session),
|
||||
challenge: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async loginWithSms(input: {
|
||||
sessionId: string
|
||||
mobile: string
|
||||
code: string
|
||||
}): Promise<ConnectorLoginSession> {
|
||||
return this.withLock(input.sessionId, async () => {
|
||||
const session = this.requireSession(input.sessionId)
|
||||
if (session.status === 'authenticated' && session.cookie) return this.loginView(session)
|
||||
if (!session.smsSessionData) {
|
||||
throw createHttpError('请先获取虎牙短信验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_code_required',
|
||||
})
|
||||
}
|
||||
if (session.expiresAtMs <= this.now()) {
|
||||
session.status = 'expired'
|
||||
session.message = '短信验证码已过期,请重新获取'
|
||||
return this.loginView(session)
|
||||
}
|
||||
const phone = normalizeMainlandMobile(input.mobile)
|
||||
if (phone !== session.smsPhone) {
|
||||
throw createHttpError('手机号与发送验证码时不一致,请重新获取验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_phone_mismatch',
|
||||
})
|
||||
}
|
||||
const code = readString(input.code)
|
||||
if (!/^\d{4,8}$/.test(code)) {
|
||||
throw createHttpError('请输入正确的虎牙短信验证码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'huya_sms_code_invalid',
|
||||
})
|
||||
}
|
||||
const behavior = smsBehavior()
|
||||
const response = await session.role.call('/web/v2/smsLogin', '60025', {
|
||||
phone,
|
||||
authId: '',
|
||||
authcode: code,
|
||||
sessionData: session.smsSessionData,
|
||||
domainList: '',
|
||||
remember: '1',
|
||||
behavior: behavior.behavior,
|
||||
page: behavior.page,
|
||||
})
|
||||
const returnCode = extractInt(response.returnCode)
|
||||
if (returnCode === 10030 || returnCode === 10039) {
|
||||
throw createHttpError('虎牙登录要求重新完成安全验证,请重新发送验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_verification_required',
|
||||
})
|
||||
}
|
||||
if (returnCode !== 0) {
|
||||
throw createHttpError('验证码错误或已过期', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_login_failed',
|
||||
})
|
||||
}
|
||||
const data = response.data && typeof response.data === 'object' ? response.data : {}
|
||||
await this.finishLogin(session, data)
|
||||
return this.loginView(session)
|
||||
})
|
||||
}
|
||||
|
||||
async startBinding(
|
||||
sessionId: string,
|
||||
options: { forceRebind?: boolean } = {},
|
||||
@@ -619,6 +906,9 @@ export class HuyaNativeConnector {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.cookie = ''
|
||||
session.smsSessionData = ''
|
||||
session.smsPhone = ''
|
||||
session.smsChallenge = null
|
||||
session.tickets.clear()
|
||||
this.sessions.delete(sessionId)
|
||||
await removeFingerprintState(session.stateDir)
|
||||
@@ -626,20 +916,32 @@ export class HuyaNativeConnector {
|
||||
}
|
||||
|
||||
private async finishLogin(session: NativeSession, data: Record<string, unknown>): Promise<void> {
|
||||
const biztoken = readString(data.biztoken)
|
||||
const uid = extractInt(data.uid ?? data.yyuid ?? data.lUid)
|
||||
const responseCookie = session.role.cookieHeader()
|
||||
const biztoken =
|
||||
readString(data.biztoken) ||
|
||||
cookieValue(responseCookie, 'udb_biztoken') ||
|
||||
cookieValue(responseCookie, 'udb_cred')
|
||||
const uid =
|
||||
extractInt(data.uid ?? data.yyuid ?? data.lUid) ||
|
||||
extractInt(cookieValue(responseCookie, 'udb_uid') || cookieValue(responseCookie, 'yyuid'))
|
||||
if (!biztoken || !uid) {
|
||||
session.status = 'failed'
|
||||
session.message = '虎牙扫码已确认,但未返回完整登录凭据'
|
||||
session.message = '虎牙登录已确认,但未返回完整登录凭据'
|
||||
return
|
||||
}
|
||||
const username = readString(data.passport ?? data.udb_passport ?? data.username)
|
||||
const nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? username)
|
||||
const username =
|
||||
readString(
|
||||
data.passport ??
|
||||
data.udb_passport ??
|
||||
data.username ??
|
||||
cookieValue(responseCookie, 'udb_passport'),
|
||||
) || ''
|
||||
const nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? username) || username
|
||||
session.deviceInfo.sdid = session.deviceInfo.sdid || session.sdid
|
||||
session.deviceInfo.hdid = session.deviceInfo.hdid || session.hdid
|
||||
try {
|
||||
let merged = mergeHuyaWebCookie({
|
||||
cookie: session.qr.cookieHeader(),
|
||||
cookie: session.role.cookieHeader(),
|
||||
biztoken,
|
||||
sdid: session.sdid,
|
||||
hdid: session.hdid,
|
||||
@@ -648,10 +950,10 @@ export class HuyaNativeConnector {
|
||||
username: username.startsWith('hy_') ? username : '',
|
||||
includeTransient: false,
|
||||
})
|
||||
session.qr.setCookies(merged)
|
||||
await session.qr.verifyCookie()
|
||||
session.role.setCookies(merged)
|
||||
await session.role.verifyCookie()
|
||||
merged = mergeHuyaWebCookie({
|
||||
cookie: session.qr.cookieHeader(),
|
||||
cookie: session.role.cookieHeader(),
|
||||
biztoken,
|
||||
sdid: session.sdid,
|
||||
hdid: session.hdid,
|
||||
@@ -669,6 +971,9 @@ export class HuyaNativeConnector {
|
||||
session.uid = uid
|
||||
session.nickname = nickname
|
||||
session.status = 'authenticated'
|
||||
session.smsSessionData = ''
|
||||
session.smsPhone = ''
|
||||
session.smsChallenge = null
|
||||
session.expiresAtMs = this.now() + CREDENTIAL_TTL_MS
|
||||
session.message = '虎牙登录成功'
|
||||
} catch (error) {
|
||||
@@ -721,6 +1026,9 @@ export class HuyaNativeConnector {
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (session.expiresAtMs <= now) {
|
||||
session.cookie = ''
|
||||
session.smsSessionData = ''
|
||||
session.smsPhone = ''
|
||||
session.smsChallenge = null
|
||||
session.tickets.clear()
|
||||
this.sessions.delete(sessionId)
|
||||
void removeFingerprintState(session.stateDir)
|
||||
@@ -735,6 +1043,9 @@ export class HuyaNativeConnector {
|
||||
const overflow = this.sessions.size - MAX_SESSIONS
|
||||
for (const [sessionId, session] of Array.from(this.sessions.entries()).slice(0, overflow)) {
|
||||
session.cookie = ''
|
||||
session.smsSessionData = ''
|
||||
session.smsPhone = ''
|
||||
session.smsChallenge = null
|
||||
session.tickets.clear()
|
||||
this.sessions.delete(sessionId)
|
||||
void removeFingerprintState(session.stateDir)
|
||||
@@ -746,7 +1057,7 @@ export class HuyaNativeConnector {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
status: session.status,
|
||||
qrContent: `https://aq.huya.com/r/confirm.html?k=${session.qrid}&id=5002`,
|
||||
qrContent: session.qrid ? `https://aq.huya.com/r/confirm.html?k=${session.qrid}&id=5002` : '',
|
||||
qrImage: session.qrImage,
|
||||
expiresAt: iso(session.expiresAtMs),
|
||||
huyaUid: session.uid ? String(session.uid) : '',
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js'
|
||||
import { HuyaNativeConnector } from './huya-native-connector.js'
|
||||
@@ -196,3 +197,366 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK
|
||||
await connector.revokeSession(created.sessionId)
|
||||
await assert.rejects(() => connector.getLoginSession(created.sessionId), /不存在或已过期/)
|
||||
})
|
||||
|
||||
test('内置 Connector 手动发码并完成短信登录后保存账号 CK', async () => {
|
||||
const requests: Array<{ url: string; body: string }> = []
|
||||
const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(urlInput)
|
||||
const body = typeof init?.body === 'string' ? init.body : ''
|
||||
requests.push({ url, body })
|
||||
if (url.endsWith('/web/df/token')) {
|
||||
return new Response(JSON.stringify({ data: { token: 'sms-df-token' } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/web/df/collect')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
sdid: 'sms-web-sdid',
|
||||
hdid: 'sms-web-hdid',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
if (url.includes('/web/middle/')) {
|
||||
return new Response('<html></html>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/web/v2/smsCode')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
returnCode: 0,
|
||||
data: { sessionData: 'sms-session-secret' },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/web/v2/smsLogin')) {
|
||||
const headers = new Headers({ 'content-type': 'application/json' })
|
||||
headers.append('set-cookie', 'udb_cred=server-issued-sms-cred; Path=/; Domain=huya.com')
|
||||
headers.append('set-cookie', 'udb_uid=987654; Path=/; Domain=huya.com')
|
||||
headers.append('set-cookie', 'yyuid=987654; Path=/; Domain=huya.com')
|
||||
headers.append('set-cookie', 'udb_passport=hy_987654; Path=/; Domain=huya.com')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
returnCode: 0,
|
||||
data: {
|
||||
uid: 987654,
|
||||
passport: 'hy_987654',
|
||||
nickname: '短信登录用户',
|
||||
biztoken: 'sms-biztoken',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/web/cookie/verify')) {
|
||||
return new Response(JSON.stringify({ returnCode: 0, data: null }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`)
|
||||
}) as typeof fetch
|
||||
|
||||
const connector = new HuyaNativeConnector({
|
||||
fetchImpl,
|
||||
getFingerprint: async () => ({
|
||||
sdid: 'sms-fingerprint-sdid',
|
||||
hdid: 'sms-fingerprint-hdid',
|
||||
cookies: {
|
||||
udb_appid: '5008',
|
||||
game_did: 'sms-game-did',
|
||||
_qimei_uuid42: '1ab09100209100ee4d631bc545468c6addc489a95a',
|
||||
__yamid_new: 'CBE2807148F00001332411CB1E296A00',
|
||||
udb_guiddata: '838ed427bbdb491d8e6f6782addb0364',
|
||||
guid: '0a7dee10017ef16aad0229302a3029e3',
|
||||
},
|
||||
source: 'fingerprint',
|
||||
}),
|
||||
stateRoot: path.join(os.tmpdir(), 'huya-native-sms-test'),
|
||||
})
|
||||
|
||||
const sent = await connector.sendSmsCode({
|
||||
taskId: 2,
|
||||
orderNo: 'ORDER-SMS-1',
|
||||
mobile: '13800138000',
|
||||
})
|
||||
assert.equal(sent.challenge, null)
|
||||
assert.equal(sent.session.status, 'waiting_code')
|
||||
assert.equal(sent.session.hasCredential, false)
|
||||
assert.equal(JSON.stringify(sent).includes('sms-session-secret'), false)
|
||||
assert.equal((await connector.getLoginSession(sent.session.sessionId)).status, 'waiting_code')
|
||||
|
||||
const smsCodeRequest = requests.find((item) => item.url.endsWith('/web/v2/smsCode'))
|
||||
assert.ok(smsCodeRequest)
|
||||
const smsCodeEnvelope = JSON.parse(smsCodeRequest.body) as {
|
||||
data?: { phone?: string }
|
||||
}
|
||||
assert.equal(smsCodeEnvelope.data?.phone, '08613800138000')
|
||||
|
||||
const authenticated = await connector.loginWithSms({
|
||||
sessionId: sent.session.sessionId,
|
||||
mobile: '13800138000',
|
||||
code: '123456',
|
||||
})
|
||||
assert.equal(authenticated.status, 'authenticated')
|
||||
assert.equal(authenticated.huyaUid, '987654')
|
||||
assert.equal(authenticated.nickname, '短信登录用户')
|
||||
assert.equal(JSON.stringify(authenticated).includes('sms-session-secret'), false)
|
||||
|
||||
const credential = connector.exportSessionCredential(sent.session.sessionId)
|
||||
assert.equal(credential.platformUid, '987654')
|
||||
assert.equal(
|
||||
credential.cookies.find((item) => item.name === 'udb_cred')?.value,
|
||||
'server-issued-sms-cred',
|
||||
)
|
||||
})
|
||||
|
||||
test('内置 Connector 将虎牙滑块验证交给客户完成后再发码', async () => {
|
||||
let smsCodeCalls = 0
|
||||
const background = await sharp({
|
||||
create: { width: 52, height: 180, channels: 3, background: '#dbeafe' },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
const puzzle = await sharp({
|
||||
create: { width: 20, height: 20, channels: 4, background: '#f97316' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(urlInput)
|
||||
const body = typeof init?.body === 'string' ? init.body : ''
|
||||
if (url.endsWith('/web/df/token')) {
|
||||
return new Response(JSON.stringify({ data: { token: 'challenge-token' } }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/web/df/collect')) {
|
||||
return new Response(
|
||||
JSON.stringify({ data: { sdid: 'challenge-sdid', hdid: 'challenge-hdid' } }),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.includes('/web/middle/')) {
|
||||
return new Response('<html></html>', { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/web/v2/smsCode')) {
|
||||
smsCodeCalls += 1
|
||||
if (smsCodeCalls === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
returnCode: 10030,
|
||||
data: {
|
||||
strategys: [
|
||||
{
|
||||
data: 'https://aq.huya.com/p/safe_auth/pt_auth.html?param=captcha-param&sceneType=4',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ returnCode: 0, data: { sessionData: 'sms-after-slider' } }),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/auth/client/config3')) {
|
||||
assert.equal(JSON.parse(body).data.urlParamMap.param, 'captcha-param')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
returnCode: 0,
|
||||
data: {
|
||||
gurl: 'https://udbrtt.huya.com/auth/test/get',
|
||||
vurl: 'https://udbrtt.huya.com/auth/test/verify',
|
||||
js: `
|
||||
window.UdbCipher = {
|
||||
decrypt() { return btoa(JSON.stringify({ ord: [1, 0] })) },
|
||||
encrypt(value) { return 'encrypted-' + value }
|
||||
}
|
||||
`,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/auth/test/get')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
returnCode: 0,
|
||||
data: {
|
||||
code: 'captcha-code',
|
||||
huyapk: 'captcha-key',
|
||||
segment: Array.from(Buffer.from('captcha-segment')),
|
||||
dtuStr: background.toString('base64'),
|
||||
puzzleStr: puzzle.toString('base64'),
|
||||
location_y: 40,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.includes('/auth/test/verify')) {
|
||||
const payload = JSON.parse(body)
|
||||
assert.equal(payload.data.act.startsWith('encrypted-'), true)
|
||||
return new Response(JSON.stringify({ data: { authId: 'captcha-auth-id' } }), {
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`)
|
||||
}) as typeof fetch
|
||||
const connector = new HuyaNativeConnector({
|
||||
fetchImpl,
|
||||
stateRoot: path.join(os.tmpdir(), 'huya-native-slider-test'),
|
||||
})
|
||||
|
||||
const pending = await connector.sendSmsCode({
|
||||
taskId: 3,
|
||||
orderNo: 'ORDER-SMS-CHALLENGE',
|
||||
mobile: '13800138000',
|
||||
})
|
||||
assert.equal(pending.session.status, 'verification_pending')
|
||||
assert.equal(pending.challenge?.type, 'slider')
|
||||
assert.match(pending.challenge?.image || '', /^data:image\/png;base64,/)
|
||||
assert.equal(JSON.stringify(pending).includes('captcha-key'), false)
|
||||
assert.equal(JSON.stringify(pending).includes('captcha-code'), false)
|
||||
|
||||
const verified = await connector.verifySmsChallenge(pending.session.sessionId, { distance: 20 })
|
||||
assert.equal(verified.session.status, 'waiting_code')
|
||||
assert.equal(verified.challenge, null)
|
||||
assert.equal(smsCodeCalls, 2)
|
||||
})
|
||||
|
||||
test('内置 Connector 支持客户完成虎牙点选和旋转验证', async () => {
|
||||
const background = await sharp({
|
||||
create: { width: 350, height: 268, channels: 3, background: '#e2e8f0' },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
const puzzle = await sharp({
|
||||
create: { width: 180, height: 180, channels: 4, background: '#fb923c' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
async function run(type: 'click' | 'rotate') {
|
||||
let smsCodeCalls = 0
|
||||
const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(urlInput)
|
||||
const body = typeof init?.body === 'string' ? init.body : ''
|
||||
if (url.endsWith('/web/df/token')) {
|
||||
return new Response(JSON.stringify({ data: { token: 'challenge-token' } }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/web/df/collect')) {
|
||||
return new Response(
|
||||
JSON.stringify({ data: { sdid: 'challenge-sdid', hdid: 'challenge-hdid' } }),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.includes('/web/middle/')) return new Response('<html></html>', { status: 200 })
|
||||
if (url.endsWith('/web/v2/smsCode')) {
|
||||
smsCodeCalls += 1
|
||||
if (smsCodeCalls === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
returnCode: 10039,
|
||||
data: {
|
||||
strategys: [
|
||||
{
|
||||
data:
|
||||
type === 'click'
|
||||
? 'https://aq.huya.com/p/safe_auth/dx_auth.html?param=captcha-param&sceneType=4'
|
||||
: 'https://aq.huya.com/p/safe_auth/rotate_auth.html?param=captcha-param&sceneType=4',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ returnCode: 0, data: { sessionData: 'sms-after-challenge' } }),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/auth/client/config3')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
gurl: `https://udbrtt.huya.com/auth/${type}/get`,
|
||||
vurl: `https://udbrtt.huya.com/auth/${type}/verify`,
|
||||
js: `
|
||||
window.UdbCipher = {
|
||||
decrypt() { return btoa(JSON.stringify({ ord: [0, 1] })) },
|
||||
encrypt() { return [1, 2, 3] }
|
||||
}
|
||||
`,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.endsWith(`/auth/${type}/get`)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
code: `${type}-code`,
|
||||
huyapk: `${type}-key`,
|
||||
...(type === 'rotate'
|
||||
? {
|
||||
segment: Array.from(Buffer.from(`${type}-segment`)),
|
||||
dTuStr: background.toString('base64'),
|
||||
puzzleStr: puzzle.toString('base64'),
|
||||
}
|
||||
: {
|
||||
dTuStr: background.toString('base64'),
|
||||
hZiStr: puzzle.toString('base64'),
|
||||
question: '请依次点击目标',
|
||||
wordSize: 2,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.includes(`/auth/${type}/verify`)) {
|
||||
const payload = JSON.parse(body)
|
||||
assert.deepEqual(payload.data.act, [1, 2, 3])
|
||||
return new Response(JSON.stringify({ data: { authId: `${type}-auth-id` } }), {
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`)
|
||||
}) as typeof fetch
|
||||
const connector = new HuyaNativeConnector({ fetchImpl })
|
||||
const pending = await connector.sendSmsCode({
|
||||
taskId: 4,
|
||||
orderNo: `ORDER-${type}`,
|
||||
mobile: '13800138000',
|
||||
})
|
||||
assert.equal(pending.session.status, 'verification_pending')
|
||||
assert.equal(pending.challenge?.type, type)
|
||||
const result = await connector.verifySmsChallenge(
|
||||
pending.session.sessionId,
|
||||
type === 'click'
|
||||
? {
|
||||
points: [
|
||||
{ x: 40, y: 60 },
|
||||
{ x: 180, y: 120 },
|
||||
],
|
||||
}
|
||||
: { angle: 90 },
|
||||
)
|
||||
assert.equal(result.session.status, 'waiting_code')
|
||||
assert.equal(result.challenge, null)
|
||||
}
|
||||
|
||||
await run('click')
|
||||
await run('rotate')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
/**
|
||||
* 虎牙网页短信登录协议。
|
||||
*
|
||||
* 短信登录使用独立的网页设备上下文:df/token -> df/collect -> UDB middle,
|
||||
* 不能复用扫码链路的 App 设备指纹,否则 sdid 与 appId 语义不一致。
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import vm from 'node:vm'
|
||||
import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib'
|
||||
|
||||
import sharp from 'sharp'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { HuyaCookieJar, UDB_BASE } from './huya-rpc.js'
|
||||
|
||||
const DF_TOKEN_URL = 'https://df.huya.com/web/df/token'
|
||||
const DF_COLLECT_URL = 'https://df.huya.com/web/df/collect'
|
||||
const APP_VERSION = '2.6'
|
||||
const APP_ID = '5002'
|
||||
const APP_SIGN = '1ce3bf682483d03f146f58232ec10635'
|
||||
const LCID = '2052'
|
||||
const AUTH_BASE = 'https://udbrtt.huya.com'
|
||||
const SMS_WEB_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
||||
'(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
export type HuyaSmsResponse = JsonRecord & {
|
||||
data?: JsonRecord
|
||||
returnCode?: number | string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type HuyaChallengeBase = {
|
||||
image: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type HuyaSliderChallenge = HuyaChallengeBase & {
|
||||
type: 'slider'
|
||||
puzzleImage: string
|
||||
puzzleWidth: number
|
||||
puzzleY: number
|
||||
}
|
||||
|
||||
export type HuyaRotateChallenge = HuyaChallengeBase & {
|
||||
type: 'rotate'
|
||||
puzzleImage: string
|
||||
puzzleWidth: number
|
||||
puzzleHeight: number
|
||||
puzzleX: number
|
||||
puzzleY: number
|
||||
}
|
||||
|
||||
export type HuyaClickChallenge = HuyaChallengeBase & {
|
||||
type: 'click'
|
||||
tipImage: string
|
||||
question: string
|
||||
wordSize: number
|
||||
}
|
||||
|
||||
export type HuyaVerificationChallenge =
|
||||
| HuyaSliderChallenge
|
||||
| HuyaRotateChallenge
|
||||
| HuyaClickChallenge
|
||||
|
||||
export type HuyaSmsSendResult =
|
||||
| {
|
||||
kind: 'sent'
|
||||
sessionData: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
kind: 'challenge'
|
||||
challenge: HuyaVerificationChallenge
|
||||
message: string
|
||||
}
|
||||
|
||||
export type HuyaChallengeSolution = {
|
||||
distance?: unknown
|
||||
angle?: unknown
|
||||
points?: unknown
|
||||
}
|
||||
|
||||
type ChallengeState = {
|
||||
strategyUrl: string
|
||||
page: string
|
||||
urlParamMap: Record<string, string>
|
||||
sceneType: string
|
||||
gurl: string
|
||||
vurl: string
|
||||
code: string
|
||||
huyapk: string
|
||||
jsContext: vm.Context
|
||||
challenge: HuyaVerificationChallenge
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
const now = new Date()
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
return String(Math.max(0, now.getTime() - midnight))
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function readSegment(value: unknown): string | number[] {
|
||||
if (!Array.isArray(value)) return readString(value)
|
||||
return value
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item))
|
||||
.map((item) => Math.max(0, Math.min(255, Math.trunc(item))))
|
||||
}
|
||||
|
||||
function commonHeaders(): Record<string, string> {
|
||||
return {
|
||||
Accept: '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
Origin: 'https://aq.huya.com',
|
||||
Pragma: 'no-cache',
|
||||
Referer: 'https://aq.huya.com/',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-site',
|
||||
'User-Agent': SMS_WEB_UA,
|
||||
'sec-ch-ua': '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
}
|
||||
}
|
||||
|
||||
function imageDataUrl(value: unknown, mime = 'image/png'): string {
|
||||
const raw = readString(value)
|
||||
if (!raw) return ''
|
||||
return raw.startsWith('data:image/') ? raw : `data:${mime};base64,${raw}`
|
||||
}
|
||||
|
||||
function imageBuffer(value: unknown): Buffer {
|
||||
const raw = readString(value)
|
||||
const payload = raw.includes(';base64,') ? raw.split(';base64,').pop() || '' : raw
|
||||
return Buffer.from(payload, 'base64')
|
||||
}
|
||||
|
||||
function encodeBehavior(events: unknown[]): string {
|
||||
return encodeURIComponent(JSON.stringify(events))
|
||||
}
|
||||
|
||||
function authPageUrl(): string {
|
||||
return `https://aq.huya.com/p/udb_login.html?v=${String(Date.now()).slice(0, 8)}00000`
|
||||
}
|
||||
|
||||
function formatTrack(points: number[][]): string {
|
||||
return points.map(([x, y, time]) => `${x},${y},${time}`).join(';')
|
||||
}
|
||||
|
||||
function easeOutExpo(value: number): number {
|
||||
return value === 1 ? 1 : 1 - 2 ** (-10 * value)
|
||||
}
|
||||
|
||||
function generateTrack(distance: number): number[][] {
|
||||
const total = distance + 10 + Math.floor(Math.random() * 31)
|
||||
let currentTime = Date.now() - (1500 + Math.floor(Math.random() * 501))
|
||||
const startX = 56 + Math.floor(Math.random() * 8)
|
||||
const startY = 210 + Math.floor(Math.random() * 4)
|
||||
const points: number[][] = [[startX, startY, currentTime]]
|
||||
const count = 40 + Math.floor(Math.random() * 21)
|
||||
let previous = 0
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const x = Math.round(easeOutExpo(index / count) * total)
|
||||
currentTime += 10 + Math.floor(Math.random() * 11)
|
||||
if (x === previous) continue
|
||||
points.push([x + startX, startY - 1 + Math.floor(Math.random() * 3), currentTime])
|
||||
previous = x
|
||||
}
|
||||
const lastPoint = points[points.length - 1]
|
||||
if (lastPoint) points.push([...lastPoint])
|
||||
return points
|
||||
}
|
||||
|
||||
function challengeTypeFromUrl(url: string): HuyaVerificationChallenge['type'] | 'qr' | 'sms' {
|
||||
const lower = url.toLowerCase()
|
||||
if (lower.includes('rotate_auth') || lower.includes('/auth/xz/')) return 'rotate'
|
||||
if (lower.includes('dx_auth') || lower.includes('/auth/dx/')) return 'click'
|
||||
if (lower.includes('qr_auth')) return 'qr'
|
||||
if (lower.includes('sms_auth')) return 'sms'
|
||||
return 'slider'
|
||||
}
|
||||
|
||||
export class HuyaSmsRole {
|
||||
private readonly jar = new HuyaCookieJar()
|
||||
private readonly fetchImpl: FetchLike
|
||||
private readonly deviceId = crypto.randomBytes(16).toString('hex')
|
||||
private readonly mid = crypto.randomBytes(16).toString('hex').toUpperCase()
|
||||
private readonly reqId = requestId()
|
||||
private readonly context = `WB-${this.deviceId}-${this.mid}-`
|
||||
private readonly middleUrl = `${UDB_BASE}/web/middle/${APP_VERSION}/${this.reqId}/https/${this.deviceId}`
|
||||
private sdid = ''
|
||||
private hdid = ''
|
||||
private challengeState: ChallengeState | null = null
|
||||
|
||||
constructor(fetchImpl: FetchLike = fetch) {
|
||||
this.fetchImpl = fetchImpl
|
||||
}
|
||||
|
||||
cookieHeader(): string {
|
||||
return this.jar.header()
|
||||
}
|
||||
|
||||
setCookies(cookie: string): void {
|
||||
this.jar.clear()
|
||||
this.jar.set(cookie)
|
||||
}
|
||||
|
||||
async prepareDevice(): Promise<void> {
|
||||
const tokenResponse = await this.fetchImpl(DF_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: commonHeaders(),
|
||||
body: JSON.stringify({
|
||||
encryptVersion: '1.0.1',
|
||||
fingerprintVersion: '1.2.41',
|
||||
}),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
this.jar.absorb(tokenResponse.headers)
|
||||
if (!tokenResponse.ok) throw new Error(`虎牙 df token 获取失败:HTTP ${tokenResponse.status}`)
|
||||
const tokenPayload = (await tokenResponse.json().catch(() => ({}))) as {
|
||||
data?: { token?: string }
|
||||
}
|
||||
const token = readString(tokenPayload.data?.token)
|
||||
if (!token) throw new Error('虎牙 df token 为空')
|
||||
|
||||
const collectResponse = await this.fetchImpl(DF_COLLECT_URL, {
|
||||
method: 'POST',
|
||||
headers: commonHeaders(),
|
||||
body: JSON.stringify({ token }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
this.jar.absorb(collectResponse.headers)
|
||||
if (!collectResponse.ok) {
|
||||
throw new Error(`虎牙 sdid 获取失败:HTTP ${collectResponse.status}`)
|
||||
}
|
||||
const collectPayload = (await collectResponse.json().catch(() => ({}))) as {
|
||||
data?: { sdid?: string; hdid?: string }
|
||||
}
|
||||
this.sdid = readString(collectPayload.data?.sdid)
|
||||
this.hdid = readString(collectPayload.data?.hdid)
|
||||
if (!this.sdid) throw new Error('虎牙 sdid 为空')
|
||||
}
|
||||
|
||||
async bootstrapMiddle(): Promise<void> {
|
||||
for (const host of ['udblgn.huya.com', 'udb3lgn.huya.com', 'udbreg.huya.com']) {
|
||||
const url = `https://${host}/web/middle/${APP_VERSION}/${this.reqId}/https/${this.deviceId}`
|
||||
try {
|
||||
const response = await this.fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...commonHeaders(),
|
||||
...(this.jar.header() ? { cookie: this.jar.header() } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
this.jar.absorb(response.headers)
|
||||
await response.arrayBuffer().catch(() => undefined)
|
||||
} catch {
|
||||
// UDB middle 是上下文初始化,个别备用域名失败不影响后续主接口。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async call(path: string, uri: string, data: JsonRecord): Promise<HuyaSmsResponse> {
|
||||
const response = await this.fetchImpl(`${UDB_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...commonHeaders(),
|
||||
Origin: UDB_BASE,
|
||||
Referer: this.middleUrl,
|
||||
Context: this.context,
|
||||
Lcid: LCID,
|
||||
Reqid: this.reqId,
|
||||
Uri: uri,
|
||||
...(this.jar.header() ? { cookie: this.jar.header() } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uri,
|
||||
version: APP_VERSION,
|
||||
context: this.context,
|
||||
appId: APP_ID,
|
||||
appSign: APP_SIGN,
|
||||
authId: '',
|
||||
sdid: this.sdid,
|
||||
lcid: LCID,
|
||||
byPass: '3',
|
||||
requestId: this.reqId,
|
||||
data,
|
||||
}),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
this.jar.absorb(response.headers)
|
||||
if (!response.ok) throw new Error(`虎牙短信登录接口失败:HTTP ${response.status}`)
|
||||
const text = await response.text()
|
||||
try {
|
||||
return (text ? JSON.parse(text) : {}) as HuyaSmsResponse
|
||||
} catch {
|
||||
throw new Error('虎牙短信登录接口返回了无法解析的响应')
|
||||
}
|
||||
}
|
||||
|
||||
async prepareChallenge(response: HuyaSmsResponse): Promise<HuyaVerificationChallenge> {
|
||||
const page = authPageUrl()
|
||||
const strategyUrl = this.strategyUrl(response)
|
||||
if (!strategyUrl) throw new Error('虎牙安全验证缺少策略地址')
|
||||
const challengeType = challengeTypeFromUrl(strategyUrl)
|
||||
if (challengeType === 'qr') {
|
||||
throw new Error('虎牙要求使用已登录 App 扫码确认')
|
||||
}
|
||||
if (challengeType === 'sms') {
|
||||
throw new Error('虎牙要求完成额外的短信安全验证')
|
||||
}
|
||||
const parsed = new URL(strategyUrl)
|
||||
const urlParamMap = Object.fromEntries(parsed.searchParams.entries())
|
||||
if (!urlParamMap.param) throw new Error('虎牙安全验证缺少 param')
|
||||
let sceneType = urlParamMap.sceneType || ''
|
||||
let config = await this.requestConfig3(urlParamMap, sceneType, page)
|
||||
if ((!config.gurl || !config.vurl || !config.js) && sceneType !== '4') {
|
||||
urlParamMap.sceneType = '4'
|
||||
sceneType = '4'
|
||||
config = await this.requestConfig3(urlParamMap, sceneType, page)
|
||||
}
|
||||
if (!config.gurl || !config.vurl || !config.js) {
|
||||
throw new Error('虎牙安全验证未下发图形验证配置')
|
||||
}
|
||||
const jsContext = this.compileCipher(config.js)
|
||||
let data: JsonRecord = {}
|
||||
let code = ''
|
||||
let huyapk = ''
|
||||
let segment: string | number[] = ''
|
||||
let backgroundRaw: unknown = ''
|
||||
let background: Buffer<ArrayBufferLike> = Buffer.alloc(0)
|
||||
let puzzleRaw = ''
|
||||
let restoredImage = ''
|
||||
let pointError: unknown = null
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const pointResponse = await this.requestJson(config.gurl, {
|
||||
appId: APP_ID,
|
||||
data: {
|
||||
urlParamMap,
|
||||
wupData: '',
|
||||
page,
|
||||
behavior: encodeBehavior([]),
|
||||
info: this.sdid,
|
||||
ver: 5,
|
||||
},
|
||||
})
|
||||
data = pointResponse.data || {}
|
||||
logWarn('huya/sms/challenge-point', '虎牙图形验证原始响应', {
|
||||
returnCode: pointResponse.returnCode,
|
||||
data: {
|
||||
code: data.code,
|
||||
segment: data.segment,
|
||||
huyapk: data.huyapk,
|
||||
location_y: data.location_y,
|
||||
question: data.question,
|
||||
wordSize: data.wordSize,
|
||||
title: data.title,
|
||||
expire: data.expire,
|
||||
dtuLength: readString(data.dtuStr ?? data.dTuStr ?? data.bgStr).length,
|
||||
puzzleLength: readString(data.puzzleStr).length,
|
||||
tipLength: readString(data.hZiStr ?? data.hziStr).length,
|
||||
},
|
||||
})
|
||||
code = readString(data.code)
|
||||
huyapk = readString(data.huyapk)
|
||||
segment = readSegment(data.segment)
|
||||
backgroundRaw = data.dtuStr ?? data.dTuStr ?? data.bgStr
|
||||
background = imageBuffer(backgroundRaw)
|
||||
puzzleRaw = readString(data.puzzleStr)
|
||||
if (!code || !huyapk || !background.length) {
|
||||
pointError = new Error('虎牙图形验证参数不完整')
|
||||
} else if (challengeType === 'click') {
|
||||
pointError = null
|
||||
break
|
||||
} else if (!puzzleRaw) {
|
||||
pointError = new Error('虎牙拼图验证参数不完整')
|
||||
} else if (!segment) {
|
||||
restoredImage = imageDataUrl(backgroundRaw)
|
||||
pointError = null
|
||||
break
|
||||
} else {
|
||||
try {
|
||||
restoredImage = await this.restoreScrambledBackground(
|
||||
background,
|
||||
segment,
|
||||
huyapk,
|
||||
jsContext,
|
||||
)
|
||||
pointError = null
|
||||
break
|
||||
} catch (error) {
|
||||
pointError = error
|
||||
}
|
||||
}
|
||||
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
if (pointError) throw pointError
|
||||
let challenge: HuyaVerificationChallenge
|
||||
if (challengeType === 'click') {
|
||||
const metadata = await sharp(background).metadata()
|
||||
challenge = {
|
||||
type: 'click',
|
||||
image: imageDataUrl(backgroundRaw),
|
||||
tipImage: imageDataUrl(data.hZiStr ?? data.hziStr ?? data.puzzleStr),
|
||||
question: readString(data.question),
|
||||
wordSize: Math.max(1, Number(data.wordSize || 3)),
|
||||
width: Number(metadata.width || 350),
|
||||
height: Number(metadata.height || 268),
|
||||
}
|
||||
} else {
|
||||
const metadata = await sharp(background).metadata()
|
||||
const puzzleMetadata = await sharp(imageBuffer(puzzleRaw)).metadata()
|
||||
const image = restoredImage || imageDataUrl(backgroundRaw)
|
||||
challenge =
|
||||
challengeType === 'rotate'
|
||||
? {
|
||||
type: 'rotate',
|
||||
image,
|
||||
puzzleImage: imageDataUrl(puzzleRaw),
|
||||
width: Number(metadata.width || 350),
|
||||
height: Number(metadata.height || 180),
|
||||
puzzleWidth: Number(puzzleMetadata.width || 180),
|
||||
puzzleHeight: Number(puzzleMetadata.height || 180),
|
||||
puzzleX: 105,
|
||||
puzzleY: 0,
|
||||
}
|
||||
: {
|
||||
type: 'slider',
|
||||
image,
|
||||
puzzleImage: imageDataUrl(puzzleRaw),
|
||||
width: Number(metadata.width || 350),
|
||||
height: Number(metadata.height || 180),
|
||||
puzzleWidth: Number(puzzleMetadata.width || 0),
|
||||
puzzleY: Number(data.location_y || 0),
|
||||
}
|
||||
}
|
||||
this.challengeState = {
|
||||
strategyUrl,
|
||||
page,
|
||||
urlParamMap,
|
||||
sceneType,
|
||||
gurl: config.gurl,
|
||||
vurl: config.vurl,
|
||||
code,
|
||||
huyapk,
|
||||
jsContext,
|
||||
challenge,
|
||||
}
|
||||
return challenge
|
||||
}
|
||||
|
||||
async solveChallenge(solution: HuyaChallengeSolution): Promise<void> {
|
||||
const state = this.challengeState
|
||||
if (!state) {
|
||||
throw new Error('虎牙安全验证会话不存在或已过期')
|
||||
}
|
||||
const answer = this.buildChallengeAnswer(state, solution)
|
||||
const actData = answer.actData
|
||||
const act = this.encryptAct(state.jsContext, actData, state.huyapk)
|
||||
const verifyUrl = new URL(state.vurl)
|
||||
verifyUrl.searchParams.set('lock', 'true')
|
||||
const result = await this.requestJson(verifyUrl.toString(), {
|
||||
appId: APP_ID,
|
||||
data: {
|
||||
urlParamMap: state.urlParamMap,
|
||||
wupData: '',
|
||||
page: state.page,
|
||||
behavior: encodeBehavior(answer.behaviorEvents),
|
||||
info: this.sdid,
|
||||
act,
|
||||
ver: 5,
|
||||
},
|
||||
})
|
||||
const authId = readString(result.data?.authId)
|
||||
if (!authId) throw new Error('虎牙图形验证未通过,请重新完成')
|
||||
this.challengeState = null
|
||||
}
|
||||
|
||||
async verifyCookie(): Promise<void> {
|
||||
const response = await this.fetchImpl(`${UDB_BASE}/web/cookie/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'user-agent': SMS_WEB_UA,
|
||||
origin: 'https://www.huya.com',
|
||||
referer: 'https://www.huya.com/',
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
cookie: this.jar.header(),
|
||||
},
|
||||
body: JSON.stringify({ appId: 5002 }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
this.jar.absorb(response.headers)
|
||||
if (!response.ok) throw new Error(`虎牙 Cookie 兑换失败:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
device(): { sdid: string; hdid: string } {
|
||||
return { sdid: this.sdid, hdid: this.hdid }
|
||||
}
|
||||
|
||||
private strategyUrl(response: HuyaSmsResponse): string {
|
||||
const data = response.data || {}
|
||||
const strategies = Array.isArray(data.strategys) ? data.strategys : []
|
||||
const strategy = strategies[0]
|
||||
return strategy && typeof strategy === 'object'
|
||||
? readString((strategy as Record<string, unknown>).data)
|
||||
: ''
|
||||
}
|
||||
|
||||
private async requestConfig3(
|
||||
urlParamMap: Record<string, string>,
|
||||
sceneType: string,
|
||||
page: string,
|
||||
): Promise<{ gurl: string; vurl: string; js: string }> {
|
||||
const response = await this.requestJson(`${AUTH_BASE}/auth/client/config3`, {
|
||||
appId: APP_ID,
|
||||
data: {
|
||||
urlParamMap: { ...urlParamMap, sceneType },
|
||||
wupData: '',
|
||||
page,
|
||||
behavior: encodeBehavior([]),
|
||||
info: this.sdid,
|
||||
cTuIndex: 0,
|
||||
},
|
||||
})
|
||||
return {
|
||||
gurl: readString(response.data?.gurl),
|
||||
vurl: readString(response.data?.vurl),
|
||||
js: readString(response.data?.js),
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson(url: string, payload: Record<string, unknown>) {
|
||||
const response = await this.fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...commonHeaders(),
|
||||
Origin: 'https://aq.huya.com',
|
||||
Referer: 'https://aq.huya.com/',
|
||||
...(this.jar.header() ? { cookie: this.jar.header() } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
this.jar.absorb(response.headers)
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
let requestUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
requestUrl = `${parsed.origin}${parsed.pathname}?${Array.from(parsed.searchParams.keys()).join(',')}`
|
||||
} catch {
|
||||
requestUrl = url.split('?', 1)[0] || url
|
||||
}
|
||||
logWarn('huya/sms/http-error', '虎牙安全验证请求返回错误', {
|
||||
requestUrl,
|
||||
status: response.status,
|
||||
responseBody: text.slice(0, 800),
|
||||
})
|
||||
throw new Error(`虎牙安全验证请求失败:HTTP ${response.status}`)
|
||||
}
|
||||
try {
|
||||
return (text ? JSON.parse(text) : {}) as HuyaSmsResponse
|
||||
} catch {
|
||||
throw new Error('虎牙安全验证接口返回了无法解析的响应')
|
||||
}
|
||||
}
|
||||
|
||||
private compileCipher(js: string): vm.Context {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
atob: (value: string) => Buffer.from(value, 'base64').toString('binary'),
|
||||
btoa: (value: string) => Buffer.from(value, 'binary').toString('base64'),
|
||||
navigator: { userAgent: SMS_WEB_UA },
|
||||
document: {},
|
||||
location: { href: 'https://aq.huya.com/' },
|
||||
crypto: crypto.webcrypto,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
})
|
||||
Object.assign(context, { window: context, self: context, globalThis: context })
|
||||
new vm.Script(js, { filename: 'huya-sms-captcha.js' }).runInContext(context, {
|
||||
timeout: 3000,
|
||||
})
|
||||
const available = vm.runInContext('Boolean(window.UdbCipher)', context, { timeout: 1000 })
|
||||
if (!available) throw new Error('虎牙图形验证加密组件初始化失败')
|
||||
return context
|
||||
}
|
||||
|
||||
private buildChallengeAnswer(
|
||||
state: ChallengeState,
|
||||
solution: HuyaChallengeSolution,
|
||||
): { actData: Record<string, unknown>; behaviorEvents: unknown[] } {
|
||||
const challenge = state.challenge
|
||||
if (challenge.type === 'click') {
|
||||
const rawPoints = Array.isArray(solution.points) ? solution.points : []
|
||||
const points = rawPoints
|
||||
.map((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return [Number(value[0]), Number(value[1])] as [number, number]
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const point = value as Record<string, unknown>
|
||||
return [Number(point.x), Number(point.y)] as [number, number]
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((value): value is [number, number] => Boolean(value))
|
||||
.filter(
|
||||
([x, y]) =>
|
||||
Number.isFinite(x) &&
|
||||
Number.isFinite(y) &&
|
||||
x >= 0 &&
|
||||
x <= challenge.width &&
|
||||
y >= 0 &&
|
||||
y <= challenge.height,
|
||||
)
|
||||
.slice(0, Math.max(1, challenge.wordSize))
|
||||
if (points.length === 0) throw new Error('虎牙点选验证坐标无效')
|
||||
let currentTime = Date.now() - (5000 + Math.floor(Math.random() * 5001))
|
||||
const clickData = points.map(([x, y]) => {
|
||||
currentTime += 1000 + Math.floor(Math.random() * 1001)
|
||||
return [Math.round(x), Math.round(y), currentTime] as [number, number, number]
|
||||
})
|
||||
const intervalTimeList = clickData.map((item) => item[2])
|
||||
return {
|
||||
actData: {
|
||||
clickData: points.map(([x, y]) => `${Math.round(x)}_${Math.round(y)}`).join(','),
|
||||
intervalTime: intervalTimeList.join('_'),
|
||||
endTime:
|
||||
Number(intervalTimeList[intervalTimeList.length - 1] || 0) -
|
||||
Number(intervalTimeList[0] || 0),
|
||||
sceneType: state.sceneType,
|
||||
code: state.code,
|
||||
},
|
||||
behaviorEvents: clickData.map(([x, y, time], index) => ({
|
||||
action: 'mousedown',
|
||||
id: '',
|
||||
txt: ['', '1', '1\\n2'][index] || '',
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
clientWidth: challenge.width,
|
||||
clientHeight: challenge.height,
|
||||
isTrusted: true,
|
||||
timeStamp: 2000 + Math.floor(Math.random() * 3001),
|
||||
nowTime: time,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const value =
|
||||
challenge.type === 'rotate'
|
||||
? Math.round(Number(solution.angle))
|
||||
: Math.round(Number(solution.distance))
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
value < 0 ||
|
||||
(challenge.type === 'rotate' ? value >= 360 : value > challenge.width)
|
||||
) {
|
||||
throw new Error(challenge.type === 'rotate' ? '虎牙旋转角度无效' : '虎牙滑块位置无效')
|
||||
}
|
||||
const dragDistance =
|
||||
challenge.type === 'rotate' ? Math.max(5, Math.round((value / 360) * 260)) : value
|
||||
const track = generateTrack(dragDistance)
|
||||
const firstPoint = track[0]
|
||||
const lastPoint = track[track.length - 1]
|
||||
if (!firstPoint || !lastPoint) throw new Error('虎牙拖动轨迹生成失败')
|
||||
return {
|
||||
actData: {
|
||||
point: value,
|
||||
travel: formatTrack(track),
|
||||
code: state.code,
|
||||
sceneType: state.sceneType,
|
||||
endTime: Number(lastPoint[2] || 0) - Number(firstPoint[2] || 0),
|
||||
},
|
||||
behaviorEvents: [
|
||||
{
|
||||
action: 'mousedown',
|
||||
id: '',
|
||||
txt: '',
|
||||
clientX: firstPoint[0],
|
||||
clientY: firstPoint[1],
|
||||
clientWidth: challenge.width,
|
||||
clientHeight: challenge.height,
|
||||
isTrusted: true,
|
||||
timeStamp: 1800 + Math.floor(Math.random() * 3701),
|
||||
nowTime: Date.now(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreScrambledBackground(
|
||||
background: Buffer,
|
||||
segment: string | number[],
|
||||
huyapk: string,
|
||||
context: vm.Context,
|
||||
): Promise<string> {
|
||||
context.__segment = segment
|
||||
context.__huyapk = huyapk
|
||||
const decrypted = vm.runInContext('window.UdbCipher.decrypt(__segment, __huyapk)', context, {
|
||||
timeout: 3000,
|
||||
})
|
||||
const regionIndexes = this.parseRegionIndexes(decrypted)
|
||||
if (!Array.isArray(regionIndexes) || regionIndexes.length === 0) {
|
||||
throw new Error('虎牙拼图背景解密失败')
|
||||
}
|
||||
const metadata = await sharp(background).metadata()
|
||||
const width = Number(metadata.width || 350)
|
||||
const height = Number(metadata.height || 180)
|
||||
const composites: Array<{ input: Buffer; left: number; top: number }> = []
|
||||
for (let index = 0; index < regionIndexes.length; index += 1) {
|
||||
const sourceLeft = Math.max(0, Math.min(width - 1, Number(regionIndexes[index]) * 26))
|
||||
const stripWidth = Math.max(1, Math.min(26, width - sourceLeft, width - index * 26))
|
||||
const strip = await sharp(background)
|
||||
.extract({ left: sourceLeft, top: 0, width: stripWidth, height })
|
||||
.png()
|
||||
.toBuffer()
|
||||
composites.push({ input: strip, left: index * 26, top: 0 })
|
||||
}
|
||||
const restored = await sharp({
|
||||
create: { width, height, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } },
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer()
|
||||
return `data:image/png;base64,${restored.toString('base64')}`
|
||||
}
|
||||
|
||||
private parseRegionIndexes(decrypted: unknown): number[] {
|
||||
const candidates = new Set<string>()
|
||||
const buffers: Buffer[] = []
|
||||
if (ArrayBuffer.isView(decrypted)) {
|
||||
buffers.push(Buffer.from(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength))
|
||||
} else if (Object.prototype.toString.call(decrypted) === '[object ArrayBuffer]') {
|
||||
buffers.push(Buffer.from(decrypted as ArrayBuffer))
|
||||
} else {
|
||||
const text = readString(decrypted)
|
||||
if (text) {
|
||||
candidates.add(text)
|
||||
buffers.push(Buffer.from(text, 'binary'), Buffer.from(text, 'utf8'))
|
||||
const normalizedBase64 = text.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalizedBase64.padEnd(Math.ceil(normalizedBase64.length / 4) * 4, '=')
|
||||
buffers.push(Buffer.from(padded, 'base64'))
|
||||
if (/^[0-9a-f]+$/i.test(text) && text.length % 2 === 0) {
|
||||
buffers.push(Buffer.from(text, 'hex'))
|
||||
}
|
||||
try {
|
||||
candidates.add(decodeURIComponent(text))
|
||||
} catch {
|
||||
// 非 URL 编码时继续尝试其他形式。
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const buffer of buffers) {
|
||||
candidates.add(buffer.toString('utf8'))
|
||||
candidates.add(buffer.toString('latin1'))
|
||||
for (const decompress of [gunzipSync, inflateSync, brotliDecompressSync]) {
|
||||
try {
|
||||
candidates.add(decompress(buffer).toString('utf8'))
|
||||
} catch {
|
||||
// 当前字节流不是该压缩格式。
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate) as { ord?: unknown }
|
||||
if (Array.isArray(parsed.ord)) {
|
||||
return parsed.ord.map((value) => Number(value)).filter((value) => Number.isFinite(value))
|
||||
}
|
||||
} catch {
|
||||
// 当前候选不是 JSON,继续尝试下一种编码。
|
||||
}
|
||||
}
|
||||
const decryptedType = ArrayBuffer.isView(decrypted)
|
||||
? 'bytes'
|
||||
: Object.prototype.toString.call(decrypted) === '[object ArrayBuffer]'
|
||||
? 'arraybuffer'
|
||||
: typeof decrypted
|
||||
const decryptedLength = ArrayBuffer.isView(decrypted)
|
||||
? decrypted.byteLength
|
||||
: Object.prototype.toString.call(decrypted) === '[object ArrayBuffer]'
|
||||
? (decrypted as ArrayBuffer).byteLength
|
||||
: readString(decrypted).length
|
||||
const decryptedPrefix = ArrayBuffer.isView(decrypted)
|
||||
? Array.from(
|
||||
Buffer.from(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).subarray(0, 16),
|
||||
)
|
||||
: Array.from(Buffer.from(readString(decrypted), 'binary').subarray(0, 16))
|
||||
throw new Error(
|
||||
`虎牙拼图背景解密结果无法解析(type=${decryptedType}, length=${decryptedLength}, prefix=${decryptedPrefix.join(',')})`,
|
||||
)
|
||||
}
|
||||
|
||||
private encryptAct(
|
||||
context: vm.Context,
|
||||
actData: Record<string, unknown>,
|
||||
huyapk: string,
|
||||
): string | number[] {
|
||||
context.__act = Buffer.from(encodeURI(JSON.stringify(actData))).toString('base64')
|
||||
context.__huyapk = huyapk
|
||||
const encrypted = vm.runInContext('window.UdbCipher.encrypt(__act, __huyapk)', context, {
|
||||
timeout: 3000,
|
||||
})
|
||||
if (Array.isArray(encrypted) || ArrayBuffer.isView(encrypted)) {
|
||||
return Array.from(encrypted as ArrayLike<unknown>)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.map((value) => Math.max(0, Math.min(255, Math.trunc(value))))
|
||||
}
|
||||
const value = readString(encrypted)
|
||||
if (!value) throw new Error('虎牙验证轨迹加密失败')
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
exportHuyaSessionCredential,
|
||||
getHuyaBinding,
|
||||
getHuyaLoginSession,
|
||||
loginHuyaSms,
|
||||
revokeHuyaSession,
|
||||
sendHuyaSmsCode,
|
||||
startHuyaBinding,
|
||||
consumeHuyaBrowserTicket,
|
||||
verifyHuyaSmsChallenge,
|
||||
type ConnectorBindingSession,
|
||||
type SemiAutoConnectorMode,
|
||||
type ConnectorLoginSession,
|
||||
@@ -342,6 +345,15 @@ function assertWeiboTask(task: TaskRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertHuyaTask(task: TaskRow) {
|
||||
if (task.platform !== 'huya') {
|
||||
throw createHttpError('当前任务不是虎牙任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'semi_auto_platform_mismatch',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createLoginSessionForTask(
|
||||
task: TaskRow,
|
||||
orderNo: string,
|
||||
@@ -639,6 +651,8 @@ export async function markCustomerLinkOpened(orderNoInput: unknown, taskIdInput:
|
||||
|
||||
function mapLoginUpdate(session: ConnectorLoginSession) {
|
||||
const statusMap: Record<ConnectorLoginSession['status'], LoginStatus> = {
|
||||
verification_pending: 'waiting_login',
|
||||
waiting_code: 'waiting_login',
|
||||
waiting_scan: 'qr_pending',
|
||||
scanned: 'scanned',
|
||||
authenticated: 'authenticated',
|
||||
@@ -658,6 +672,7 @@ async function updateLoginTask(
|
||||
task: TaskRow,
|
||||
session: ConnectorLoginSession,
|
||||
actorType: TaskActorType,
|
||||
options: { credentialSource?: 'qr' | 'sms'; method?: 'qr' | 'sms' } = {},
|
||||
) {
|
||||
const mapped = mapLoginUpdate(session)
|
||||
const nextExecutionStatus: ExecutionStatus =
|
||||
@@ -678,7 +693,7 @@ async function updateLoginTask(
|
||||
const credentialId = storedCredential
|
||||
? await upsertSemiAutoCredentialWithClient(client, {
|
||||
platform: task.platform,
|
||||
source: 'qr',
|
||||
source: options.credentialSource || 'qr',
|
||||
payload: storedCredential,
|
||||
})
|
||||
: null
|
||||
@@ -715,6 +730,7 @@ async function updateLoginTask(
|
||||
detail: {
|
||||
hasCredential: mapped.hasCredential,
|
||||
message: mapped.message,
|
||||
method: options.method || 'qr',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -831,6 +847,14 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
|
||||
await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
if (
|
||||
reusableSession?.status === 'waiting_code' ||
|
||||
reusableSession?.status === 'verification_pending'
|
||||
) {
|
||||
await revokeSessionForTask(task, reusableSession.sessionId).catch(() => undefined)
|
||||
reusableSession = null
|
||||
}
|
||||
|
||||
if (reusableSession) {
|
||||
await updateLoginTask(task, reusableSession, 'customer')
|
||||
const refreshedTask = await loadTask(task.id)
|
||||
@@ -978,6 +1002,216 @@ export async function confirmPublicBinding(orderNoInput: unknown, taskIdInput: u
|
||||
return { task: mapTask(updated), binding }
|
||||
}
|
||||
|
||||
export async function sendPublicHuyaSmsCode(
|
||||
orderNoInput: unknown,
|
||||
taskIdInput: unknown,
|
||||
mobileInput: unknown,
|
||||
) {
|
||||
const orderNo = normalizeOrderNo(orderNoInput)
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const task = await loadTaskForOrder(orderNo, taskId)
|
||||
assertHuyaTask(task)
|
||||
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
|
||||
throw createHttpError('当前订单已结束,不能重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'semi_auto_task_closed',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await sendHuyaSmsCode(
|
||||
{
|
||||
taskId: task.id,
|
||||
orderNo,
|
||||
mobile: String(mobileInput ?? '').trim(),
|
||||
},
|
||||
task.connector_mode,
|
||||
)
|
||||
const session = result.session
|
||||
const previousSessionId = task.connector_session_id
|
||||
try {
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE semi_auto_tasks
|
||||
SET connector_session_id = $2,
|
||||
login_status = 'waiting_login',
|
||||
execution_status = 'waiting_customer',
|
||||
auth_started_at = COALESCE(auth_started_at, NOW()),
|
||||
last_error = '',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[task.id, session.sessionId],
|
||||
)
|
||||
await appendTaskEvent(client, task.id, {
|
||||
eventType: result.challenge ? 'sms_verification_required' : 'sms_code_sent',
|
||||
actorType: 'customer',
|
||||
fromLoginStatus: task.login_status,
|
||||
toLoginStatus: 'waiting_login',
|
||||
fromExecutionStatus: task.execution_status,
|
||||
toExecutionStatus: 'waiting_customer',
|
||||
detail: {
|
||||
method: 'sms',
|
||||
expiresAt: session.expiresAt,
|
||||
...(result.challenge ? { verificationType: result.challenge.type } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
await revokeHuyaSession(session.sessionId, task.connector_mode).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
if (previousSessionId && previousSessionId !== session.sessionId) {
|
||||
await revokeHuyaSession(previousSessionId, task.connector_mode).catch(() => undefined)
|
||||
}
|
||||
return {
|
||||
ok: !result.challenge,
|
||||
message: session.message || '验证码已发送,请注意查收',
|
||||
expiresAt: session.expiresAt,
|
||||
challenge: result.challenge,
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyPublicHuyaSmsChallenge(
|
||||
orderNoInput: unknown,
|
||||
taskIdInput: unknown,
|
||||
input: { distance?: unknown; angle?: unknown; points?: unknown } = {},
|
||||
) {
|
||||
const orderNo = normalizeOrderNo(orderNoInput)
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const task = await loadTaskForOrder(orderNo, taskId)
|
||||
assertHuyaTask(task)
|
||||
if (!task.connector_session_id) {
|
||||
throw createHttpError('虎牙安全验证会话不存在,请重新发送验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_verification_not_found',
|
||||
})
|
||||
}
|
||||
const distance =
|
||||
input.distance === undefined || input.distance === null ? undefined : Number(input.distance)
|
||||
const angle = input.angle === undefined || input.angle === null ? undefined : Number(input.angle)
|
||||
const points = Array.isArray(input.points)
|
||||
? input.points.map((value) => {
|
||||
const point = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
return { x: Number(point.x), y: Number(point.y) }
|
||||
})
|
||||
: undefined
|
||||
if (
|
||||
(distance !== undefined && (!Number.isInteger(distance) || distance < 0)) ||
|
||||
(angle !== undefined && (!Number.isInteger(angle) || angle < 0 || angle > 359)) ||
|
||||
(points &&
|
||||
(points.length === 0 ||
|
||||
points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))))
|
||||
) {
|
||||
throw createHttpError('安全验证参数无效,请重新完成', {
|
||||
statusCode: 400,
|
||||
errorCode: 'huya_sms_verification_solution_invalid',
|
||||
})
|
||||
}
|
||||
const result = await verifyHuyaSmsChallenge(
|
||||
{
|
||||
sessionId: task.connector_session_id,
|
||||
...(distance !== undefined ? { distance } : {}),
|
||||
...(angle !== undefined ? { angle } : {}),
|
||||
...(points ? { points } : {}),
|
||||
},
|
||||
task.connector_mode,
|
||||
)
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE semi_auto_tasks
|
||||
SET connector_session_id = $2,
|
||||
login_status = 'waiting_login',
|
||||
execution_status = 'waiting_customer',
|
||||
last_error = '',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[task.id, result.session.sessionId],
|
||||
)
|
||||
await appendTaskEvent(client, task.id, {
|
||||
eventType: result.challenge ? 'sms_verification_required' : 'sms_code_sent',
|
||||
actorType: 'customer',
|
||||
fromLoginStatus: task.login_status,
|
||||
toLoginStatus: 'waiting_login',
|
||||
fromExecutionStatus: task.execution_status,
|
||||
toExecutionStatus: 'waiting_customer',
|
||||
detail: {
|
||||
method: 'sms',
|
||||
verificationCompleted: !result.challenge,
|
||||
},
|
||||
})
|
||||
})
|
||||
return {
|
||||
ok: !result.challenge,
|
||||
message: result.session.message || '验证码已发送,请注意查收',
|
||||
expiresAt: result.session.expiresAt,
|
||||
challenge: result.challenge,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginPublicHuyaSms(
|
||||
orderNoInput: unknown,
|
||||
taskIdInput: unknown,
|
||||
input: { mobile?: unknown; code?: unknown } = {},
|
||||
) {
|
||||
const orderNo = normalizeOrderNo(orderNoInput)
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const task = await loadTaskForOrder(orderNo, taskId)
|
||||
assertHuyaTask(task)
|
||||
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') {
|
||||
throw createHttpError('当前订单已结束,不能重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'semi_auto_task_closed',
|
||||
})
|
||||
}
|
||||
if (!task.connector_session_id) {
|
||||
throw createHttpError('请先发送虎牙短信验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_sms_code_required',
|
||||
})
|
||||
}
|
||||
|
||||
const session = await loginHuyaSms(
|
||||
{
|
||||
sessionId: task.connector_session_id,
|
||||
taskId: task.id,
|
||||
orderNo,
|
||||
mobile: String(input.mobile ?? '').trim(),
|
||||
code: String(input.code ?? '').trim(),
|
||||
},
|
||||
task.connector_mode,
|
||||
)
|
||||
const mapped = mapLoginUpdate(session)
|
||||
if (session.status !== 'authenticated') {
|
||||
await updateLoginTask(task, session, 'customer', {
|
||||
credentialSource: 'sms',
|
||||
method: 'sms',
|
||||
})
|
||||
throw createHttpError(mapped.message || '虎牙短信登录失败,请重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'huya_login_failed',
|
||||
})
|
||||
}
|
||||
|
||||
await updateLoginTask(task, session, 'customer', {
|
||||
credentialSource: 'sms',
|
||||
method: 'sms',
|
||||
})
|
||||
const updated = await loadTask(task.id)
|
||||
return {
|
||||
task: mapTask(updated),
|
||||
login: {
|
||||
status: mapped.status,
|
||||
qrContent: '',
|
||||
qrImage: '',
|
||||
expiresAt: session.expiresAt,
|
||||
message: session.message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPublicWeiboSmsCode(
|
||||
orderNoInput: unknown,
|
||||
taskIdInput: unknown,
|
||||
|
||||
@@ -594,3 +594,84 @@ test(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'客户手动触发虎牙短信登录后保存 CK 并进入等待客服',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
process.env.ENABLE_DEV_MOCK = '1'
|
||||
const { closeDb, query } = await import('../../db/client.js')
|
||||
const { createSemiAutoMock } = await import('../dev-mock/dev-mock-service.js')
|
||||
const { resetMockSessions } = await import('../dev-mock/mock-semi-auto-connector.js')
|
||||
const { listPublicSemiAutoTasks, loginPublicHuyaSms, sendPublicHuyaSmsCode } =
|
||||
await import('./semi-auto-task-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const orderNo = `IT-HUYA-SMS-${suffix}`
|
||||
let orderId = 0
|
||||
let configId = 0
|
||||
let credentialId = 0
|
||||
try {
|
||||
const result = await createSemiAutoMock({
|
||||
orderNo,
|
||||
skuName: `虎牙短信登录-${suffix}`,
|
||||
quantity: 1,
|
||||
connectorMode: 'mock',
|
||||
})
|
||||
orderId = result.orderId
|
||||
configId = result.configId
|
||||
|
||||
const sent = await sendPublicHuyaSmsCode(orderNo, result.taskId, '13800138000')
|
||||
assert.equal(sent.ok, true)
|
||||
const waiting = await query<{
|
||||
connector_session_id: string
|
||||
login_status: string
|
||||
execution_status: string
|
||||
}>(
|
||||
`SELECT connector_session_id, login_status, execution_status
|
||||
FROM semi_auto_tasks WHERE id = $1`,
|
||||
[result.taskId],
|
||||
)
|
||||
assert.ok(waiting.rows[0]?.connector_session_id)
|
||||
assert.equal(waiting.rows[0]?.login_status, 'waiting_login')
|
||||
assert.equal(waiting.rows[0]?.execution_status, 'waiting_customer')
|
||||
|
||||
await loginPublicHuyaSms(orderNo, result.taskId, {
|
||||
mobile: '13800138000',
|
||||
code: '123456',
|
||||
})
|
||||
const tasks = await listPublicSemiAutoTasks(orderNo)
|
||||
assert.equal(tasks[0]?.loginStatus, 'authenticated')
|
||||
assert.equal(tasks[0]?.executionStatus, 'waiting_operator')
|
||||
assert.equal(tasks[0]?.credentialSource, 'sms')
|
||||
|
||||
const stored = await query<{
|
||||
id: number
|
||||
credential_source: string
|
||||
credential_payload_json: string
|
||||
}>(
|
||||
`SELECT credential.id, credential.credential_source,
|
||||
credential.credential_payload_json
|
||||
FROM semi_auto_tasks task
|
||||
JOIN semi_auto_credentials credential ON credential.id = task.credential_id
|
||||
WHERE task.id = $1`,
|
||||
[result.taskId],
|
||||
)
|
||||
credentialId = Number(stored.rows[0]?.id || 0)
|
||||
assert.ok(credentialId > 0)
|
||||
assert.equal(stored.rows[0]?.credential_source, 'sms')
|
||||
assert.match(stored.rows[0]?.credential_payload_json || '', /mock_huya_session/)
|
||||
} finally {
|
||||
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
|
||||
if (configId > 0) {
|
||||
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
|
||||
}
|
||||
if (credentialId > 0) {
|
||||
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
|
||||
}
|
||||
resetMockSessions()
|
||||
delete process.env.ENABLE_DEV_MOCK
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user