新增虎牙短信滑块自动验证
This commit is contained in:
@@ -400,20 +400,56 @@ export class HuyaNativeConnector {
|
||||
await sms.bootstrapMiddle()
|
||||
const device = sms.device()
|
||||
const behavior = smsBehavior()
|
||||
const response = await sms.call('/web/v2/smsCode', '60027', {
|
||||
let 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',
|
||||
let challenge: HuyaSmsChallenge | null = null
|
||||
let sessionData = ''
|
||||
for (let autoAttempt = 0; ; autoAttempt += 1) {
|
||||
logWarn('huya/sms/raw-response', '虎牙短信发码原始响应', response)
|
||||
const returnCode = extractInt(response.returnCode)
|
||||
if (returnCode === 0) {
|
||||
sessionData = readString(response.data?.sessionData)
|
||||
challenge = null
|
||||
break
|
||||
}
|
||||
if (returnCode !== 10030 && returnCode !== 10039) {
|
||||
throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
|
||||
statusCode: 502,
|
||||
errorCode: 'huya_sms_send_failed',
|
||||
})
|
||||
}
|
||||
challenge = await sms.prepareChallenge(response)
|
||||
if (challenge.type !== 'slider' || autoAttempt >= 2) break
|
||||
const detection = await sms.detectSliderDistance()
|
||||
if (!detection) {
|
||||
logWarn('huya/sms/slider-auto', '虎牙滑块自动识别置信度不足,转客户手动验证', {
|
||||
autoAttempt: autoAttempt + 1,
|
||||
})
|
||||
break
|
||||
}
|
||||
try {
|
||||
await sms.solveChallenge({ distance: detection.distance })
|
||||
logWarn('huya/sms/slider-auto', '虎牙滑块自动验证成功,重试发码', {
|
||||
autoAttempt: autoAttempt + 1,
|
||||
distance: detection.distance,
|
||||
score: Number(detection.score.toFixed(4)),
|
||||
})
|
||||
} catch (error) {
|
||||
logWarn('huya/sms/slider-auto', '虎牙滑块自动验证未通过,转客户手动验证', {
|
||||
autoAttempt: autoAttempt + 1,
|
||||
distance: detection.distance,
|
||||
score: Number(detection.score.toFixed(4)),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
break
|
||||
}
|
||||
response = await sms.call('/web/v2/smsCode', '60027', {
|
||||
phone,
|
||||
behavior: behavior.behavior,
|
||||
page: behavior.page,
|
||||
})
|
||||
}
|
||||
if (!challenge && !sessionData) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import sharp from 'sharp'
|
||||
|
||||
import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js'
|
||||
import { HuyaNativeConnector } from './huya-native-connector.js'
|
||||
import { detectHuyaSliderDistance } from './huya-sms-role.js'
|
||||
import { hasExistingHuyaBinding, parseBindStruct } from './huya-rpc.js'
|
||||
|
||||
const BIND_RESP_HEX =
|
||||
@@ -20,6 +21,126 @@ test('QIMEI x64hash128 与 SDK 向量一致', () => {
|
||||
assert.equal(x64hash128('abc', 31), '31844fd43df93041e52bcddd13312671')
|
||||
})
|
||||
|
||||
test('虎牙滑块自动识别按图片边缘定位缺口', async () => {
|
||||
const background = await sharp(
|
||||
Buffer.from(
|
||||
'<svg width="350" height="180"><rect width="350" height="180" fill="#dbeafe"/><circle cx="205" cy="95" r="24" fill="none" stroke="#111827" stroke-width="5"/><path d="M20 20 L100 30" stroke="#f97316" stroke-width="8"/></svg>',
|
||||
),
|
||||
)
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
const puzzle = await sharp(
|
||||
Buffer.from(
|
||||
'<svg width="60" height="60"><circle cx="30" cy="30" r="24" fill="none" stroke="#111827" stroke-width="5"/></svg>',
|
||||
),
|
||||
)
|
||||
.png()
|
||||
.toBuffer()
|
||||
const detected = await detectHuyaSliderDistance({
|
||||
image: `data:image/jpeg;base64,${background.toString('base64')}`,
|
||||
puzzleImage: `data:image/png;base64,${puzzle.toString('base64')}`,
|
||||
puzzleY: 65,
|
||||
puzzleWidth: 60,
|
||||
})
|
||||
assert.ok(detected)
|
||||
assert.ok(Math.abs(detected.distance - 175) <= 2)
|
||||
assert.ok(detected.score > 0.7)
|
||||
})
|
||||
|
||||
test('虎牙短信发码自动通过滑块并继续发送验证码', async () => {
|
||||
const background = await sharp(
|
||||
Buffer.from(
|
||||
'<svg width="350" height="180"><rect width="350" height="180" fill="#dbeafe"/><circle cx="205" cy="95" r="24" fill="none" stroke="#111827" stroke-width="5"/><path d="M20 20 L100 30" stroke="#f97316" stroke-width="8"/></svg>',
|
||||
),
|
||||
)
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
const puzzle = await sharp(
|
||||
Buffer.from(
|
||||
'<svg width="60" height="60"><circle cx="30" cy="30" r="24" fill="none" stroke="#111827" stroke-width="5"/></svg>',
|
||||
),
|
||||
)
|
||||
.png()
|
||||
.toBuffer()
|
||||
let smsCodeCalls = 0
|
||||
let verifyCalled = false
|
||||
const fetchImpl = (async (urlInput: string | URL | Request) => {
|
||||
const url = String(urlInput)
|
||||
if (url.endsWith('/web/df/token')) {
|
||||
return new Response(JSON.stringify({ data: { token: 'auto-token' } }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/web/df/collect')) {
|
||||
return new Response(JSON.stringify({ data: { sdid: 'auto-sdid', hdid: 'auto-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=auto-param&sceneType=4',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ returnCode: 0, data: { sessionData: 'auto-sms-session' } }),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/auth/client/config3')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
gurl: 'https://udbrtt.huya.com/auth/auto/get',
|
||||
vurl: 'https://udbrtt.huya.com/auth/auto/verify',
|
||||
js: 'window.UdbCipher={encrypt(){return [1,2,3]},decrypt(){return "{}"}}',
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.endsWith('/auth/auto/get')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
code: 'auto-code',
|
||||
huyapk: 'auto-key',
|
||||
dtuStr: background.toString('base64'),
|
||||
puzzleStr: puzzle.toString('base64'),
|
||||
location_y: 65,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (url.includes('/auth/auto/verify')) {
|
||||
verifyCalled = true
|
||||
return new Response(JSON.stringify({ data: { authId: 'auto-auth-id' } }), { status: 200 })
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`)
|
||||
}) as typeof fetch
|
||||
const connector = new HuyaNativeConnector({ fetchImpl })
|
||||
const result = await connector.sendSmsCode({
|
||||
taskId: 5,
|
||||
orderNo: 'ORDER-AUTO-SLIDER',
|
||||
mobile: '13800138000',
|
||||
})
|
||||
assert.equal(result.challenge, null)
|
||||
assert.equal(result.session.status, 'waiting_code')
|
||||
assert.equal(smsCodeCalls, 2)
|
||||
assert.equal(verifyCalled, true)
|
||||
})
|
||||
|
||||
test('登录 context 使用网页设备 Cookie 的 guiddata、yamid 和 guid', () => {
|
||||
const identity = ensureHuyaWebCookieIdentity({
|
||||
cookie:
|
||||
|
||||
@@ -85,6 +85,12 @@ export type HuyaChallengeSolution = {
|
||||
points?: unknown
|
||||
}
|
||||
|
||||
export type HuyaSliderDetection = {
|
||||
distance: number
|
||||
score: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type ChallengeState = {
|
||||
strategyUrl: string
|
||||
page: string
|
||||
@@ -184,6 +190,96 @@ function generateTrack(distance: number): number[][] {
|
||||
return points
|
||||
}
|
||||
|
||||
function edgeMap(data: Buffer, width: number, height: number, channels: number): Float32Array {
|
||||
const gray = new Float32Array(width * height)
|
||||
const at = (index: number) => gray[index] ?? 0
|
||||
for (let index = 0, offset = 0; index < gray.length; index += 1, offset += channels) {
|
||||
gray[index] =
|
||||
0.299 * Number(data[offset] || 0) +
|
||||
0.587 * Number(data[offset + 1] || 0) +
|
||||
0.114 * Number(data[offset + 2] || 0)
|
||||
}
|
||||
const edges = new Float32Array(width * height)
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
for (let x = 1; x < width - 1; x += 1) {
|
||||
const index = y * width + x
|
||||
const gx =
|
||||
-at(index - width - 1) -
|
||||
2 * at(index - 1) -
|
||||
at(index + width - 1) +
|
||||
at(index - width + 1) +
|
||||
2 * at(index + 1) +
|
||||
at(index + width + 1)
|
||||
const gy =
|
||||
-at(index - width - 1) -
|
||||
2 * at(index - width) -
|
||||
at(index - width + 1) +
|
||||
at(index + width - 1) +
|
||||
2 * at(index + width) +
|
||||
at(index + width + 1)
|
||||
edges[index] = Math.hypot(gx, gy)
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
export async function detectHuyaSliderDistance(input: {
|
||||
image: string
|
||||
puzzleImage: string
|
||||
puzzleY: number
|
||||
puzzleWidth: number
|
||||
}): Promise<HuyaSliderDetection | null> {
|
||||
const background = await sharp(imageBuffer(input.image))
|
||||
.grayscale()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
const puzzle = await sharp(imageBuffer(input.puzzleImage))
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
if (background.info.width <= puzzle.info.width || background.info.height <= puzzle.info.height) {
|
||||
return null
|
||||
}
|
||||
const backgroundEdges = edgeMap(background.data, background.info.width, background.info.height, 1)
|
||||
const puzzleEdges = edgeMap(puzzle.data, puzzle.info.width, puzzle.info.height, 4)
|
||||
const alpha = new Float32Array(puzzle.info.width * puzzle.info.height)
|
||||
for (let index = 0; index < alpha.length; index += 1) {
|
||||
alpha[index] = Number(puzzle.data[index * 4 + 3] || 0) / 255
|
||||
}
|
||||
|
||||
const yCenter = Math.max(
|
||||
0,
|
||||
Math.min(background.info.height - puzzle.info.height, Math.round(input.puzzleY)),
|
||||
)
|
||||
const yStart = Math.max(1, yCenter - 8)
|
||||
const yEnd = Math.min(background.info.height - puzzle.info.height - 1, yCenter + 8)
|
||||
let best: HuyaSliderDetection | null = null
|
||||
for (let x = 1; x <= background.info.width - puzzle.info.width - 1; x += 1) {
|
||||
for (let y = yStart; y <= yEnd; y += 1) {
|
||||
let dot = 0
|
||||
let puzzleEnergy = 0
|
||||
let backgroundEnergy = 0
|
||||
for (let pieceY = 0; pieceY < puzzle.info.height; pieceY += 1) {
|
||||
for (let pieceX = 0; pieceX < puzzle.info.width; pieceX += 1) {
|
||||
const pieceIndex = pieceY * puzzle.info.width + pieceX
|
||||
const mask = alpha[pieceIndex] ?? 0
|
||||
if (mask <= 0.2) continue
|
||||
const pieceEdge = (puzzleEdges[pieceIndex] ?? 0) * mask
|
||||
const backgroundEdge =
|
||||
backgroundEdges[(y + pieceY) * background.info.width + x + pieceX] ?? 0
|
||||
dot += pieceEdge * backgroundEdge
|
||||
puzzleEnergy += pieceEdge * pieceEdge
|
||||
backgroundEnergy += backgroundEdge * backgroundEdge
|
||||
}
|
||||
}
|
||||
if (puzzleEnergy <= 0 || backgroundEnergy <= 0) continue
|
||||
const score = dot / Math.sqrt(puzzleEnergy * backgroundEnergy)
|
||||
if (!best || score > best.score) best = { distance: x, score, y }
|
||||
}
|
||||
}
|
||||
return best && best.score >= 0.7 ? best : null
|
||||
}
|
||||
|
||||
function challengeTypeFromUrl(url: string): HuyaVerificationChallenge['type'] | 'qr' | 'sms' {
|
||||
const lower = url.toLowerCase()
|
||||
if (lower.includes('rotate_auth') || lower.includes('/auth/xz/')) return 'rotate'
|
||||
@@ -490,6 +586,17 @@ export class HuyaSmsRole {
|
||||
this.challengeState = null
|
||||
}
|
||||
|
||||
async detectSliderDistance(): Promise<HuyaSliderDetection | null> {
|
||||
const challenge = this.challengeState?.challenge
|
||||
if (!challenge || challenge.type !== 'slider') return null
|
||||
return detectHuyaSliderDistance({
|
||||
image: challenge.image,
|
||||
puzzleImage: challenge.puzzleImage,
|
||||
puzzleY: challenge.puzzleY,
|
||||
puzzleWidth: challenge.puzzleWidth,
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCookie(): Promise<void> {
|
||||
const response = await this.fetchImpl(`${UDB_BASE}/web/cookie/verify`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
|
||||
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
|
||||
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
|
||||
status: authoritative
|
||||
version: 2.14
|
||||
version: 2.15
|
||||
updated_at: 2026-11-14
|
||||
---
|
||||
|
||||
@@ -316,11 +316,12 @@ manual fulfillment
|
||||
“发送验证码”后触发,页面不得在加载、轮询或切换 Tab 时自动发码;短信登录会话有效期 `5` 分钟。
|
||||
登录成功后的 Connector 内存会话有效期 `24` 小时,账号级 CK 由内部凭证表保存并通过健康检查判定有效性;
|
||||
浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。
|
||||
虎牙发码触发图形安全验证时,应由客户在 order_site 页面完成:`pt_auth` 使用滑块拼图,
|
||||
`dx_auth` 使用按序点选,`rotate_auth` 使用旋转调整。服务端只接收客户最终位置、角度或点击坐标,
|
||||
生成对应交互轨迹并重试发码,不自动识别、伪造或绕过平台风控。发码原始响应必须记录到集成日志,
|
||||
但日志系统必须继续屏蔽 Cookie、令牌、验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示,
|
||||
不得伪装成可完成的图形验证。
|
||||
虎牙发码触发图形安全验证时,`pt_auth` 滑块允许服务端基于背景与拼图的边缘模板匹配自动定位缺口,
|
||||
自动生成交互轨迹并在验证通过后重试发码;自动识别置信度不足、提交失败或连续重试失败时必须
|
||||
回退到客户手动滑块,不能阻塞登录。`dx_auth` 点选和 `rotate_auth` 旋转仍由客户在 order_site
|
||||
页面完成,服务端接收客户最终角度或点击坐标后生成对应交互轨迹。所有自动化都只复现正常浏览器交互,
|
||||
不绕过平台风控。发码与挑战原始响应必须记录到集成日志,但日志系统必须继续屏蔽 Cookie、令牌、
|
||||
验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示,不得伪装成可完成的图形验证。
|
||||
- 虎牙客户登录成功后,无论账号此前是否已有角色,都立即保存 CK 并进入等待客服处理。
|
||||
角色绑定、换绑和角色状态查询在后续独立流程实现,不能阻塞当前 CK 获取。
|
||||
- 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展;
|
||||
|
||||
Reference in New Issue
Block a user