Files
live-hub-py/core/huya/app_login.py
T
yml2213 239ef91c56 fix(web): 设备绑定页『最后登录』修复 — 登录时间记录下沉到 App 登录本体
- device_profile.record_login(): 登录成功/失败均写 last_login (bound_at 首次绑定)
- HuyaAppPasswordLogin.login() 包装 _login_impl, GUI 批量/行内/CLI 全覆盖
  (此前只有 account_env CLI 路径写元数据, GUI 登录该列全为 '-')
- 修复 _enrich_profile 原地修改导致回写判断失效 (存量 hebe/guid32 被旧代码
  整体回写冲掉后无法自动补齐) — 改为返回新字典
- 存量 6 账号画像已全部补齐 hebe/guid32
2026-08-29 16:59:16 +08:00

586 lines
21 KiB
Python

"""虎牙 App 渠道协议登录模块。
流程:
1. 零设备注册链生成随机 dfpReport,获取新 safedeviceid/device_id
2. 账号+密码+新注册字段 -> WUP 密码登录 (POST wup.huya.com)
3. safe_auth 滑块自动过验 -> 提取 fresh cred 与真实 uid
4. 本地 XXTEA 算 nonce -> 铸造登录证书 (cert_forge) -> 补丁 WUP 信封 (envelope_forge)
5. 模拟扫码绑定四步流 (getQrId -> scanQrPicNotify -> bindQrLoginUser -> tryQrLogin) 获取 biztoken
6. POST /web/cookie/verify 兑换获取全套网页 Cookie
注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚,
当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。
注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``),
不读取画像里的旧固定值,也不静默回退旧链。
⚠ 风险与边界(详见 ``docs/HUYA_APP_OVERVIEW.md``):
- hdid 全账号固定共享 -> 服务端可设备维度关联账号;
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
"""
from __future__ import annotations
import base64
import hashlib
import json
import random
import re
import struct
import time
import uuid
from typing import Mapping
from urllib.parse import parse_qs, quote, urlparse
import requests
from loguru import logger
from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
from .cookie_utils import normalize_huya_cookie
from .device_fingerprint import account_state_dir, get_huya_sdid
from .device_profile import get_profile
from .dfp_register import DfpRegistrationError, register_device
from .envelope_forge import Envelope
from .login import HuyaCredentialError, HuyaLoginError, HuyaLoginResult
from .nonce_forge import K1_DEFAULT, gen_nonce
from .wup_encoder import build_password_login_wup
WUP_URL = "https://wup.huya.com"
UDB_BASE = "https://udblgn.huya.com"
APP_SIGN_WEB = "1ce3bf682483d03f146f58232ec10635"
APP_SIGN_H5 = "0ba67962ab9e12387648efeae2750777"
UA_PC = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
)
APP_UA_MOBILE = (
"Mozilla/5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) "
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
"Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30"
)
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
_URL_TAIL_KEEP = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
)
DEFAULT_GOLDEN_DEV = {
"app_version": "13.4.22",
"sdk_version": "1.0.80138",
"vendor": "xiaomi",
"model": "M2102J2SC",
"os": "android",
"ip": "127.0.0.1",
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
"screen": "M2102J2SC,30,11",
"width": "1080",
"height": "2120",
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
"hdid": "ed0db8334cadd236c00cadf7e11ab5a5", # HDID32 登录t1.t0 (勿与GUID32混)
}
class HuyaAppLoginError(HuyaLoginError):
"""虎牙 App 登录失败。"""
class HuyaAppQrAuthRequiredError(HuyaAppLoginError):
"""风控要求扫码验证 (qr_auth)。"""
def _golden_session_assets() -> tuple[dict, str, str]:
mj = {
"associationId": 8193,
"funcName": "hypasswordLogin",
"group": 1,
"id": 4097,
"session": 3251699,
"step": 1,
"stillLogin": False,
"traceId": "0b8f098ff64a5bdc-23473-94783833787595172451",
"type": 3,
"uid": 0,
"userContext": "",
}
ua = (
'{"curl":"登录页","furl":"我的","latitude":"-1.0","longitude":"-1.0","ssid":"",'
'"user_action":[{"id":"24","time":"1787595171950","x":"277","y":"1057"},'
'{"id":"11","time":"1787595172444","x":"296","y":"952"}]}'
)
sd = (
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
)
return mj, ua, sd
def wup_password_login_raw(
account: str,
password: str,
timeout: int = 15,
device_info: dict | None = None,
safedeviceid: str | None = None,
hdid: str | None = None,
proxies: dict | None = None,
) -> bytes:
"""发送 WUP 密码登录,返回原始响应字节。
未显式传入 ``safedeviceid`` 时会先执行新设备注册链,不再回退旧的
固定 action/device_id。风控重试调用方应显式复用同一注册结果。
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
"""
uid_str = account[3:] if account.startswith("hy_") else account
mj, ua, _old_sd = _golden_session_assets()
dev = dict(device_info or DEFAULT_GOLDEN_DEV)
if not safedeviceid:
try:
_t1, safedeviceid, registered_device_id = register_device(
fingerprint=dev.get("fingerprint"),
proxies=proxies,
timeout=timeout,
)
except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id
pkt = build_password_login_wup(
uid_str,
hashlib.sha1(password.encode()).hexdigest(),
safedeviceid,
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5",
mj["session"],
mj["traceId"],
ua,
dev,
)
r = requests.post(
WUP_URL,
data=pkt,
headers={
"Content-Type": "application/multipart-formdata; charset=UTF-8",
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 11)",
"Accept-Encoding": "gzip",
},
proxies=proxies,
timeout=timeout,
)
if r.status_code != 200:
raise HuyaAppLoginError(f"登录 HTTP 状态异常: {r.status_code}")
return r.content
def parse_cred(resp: bytes) -> bytes | None:
"""从 WUP 响应中提取 114 字节的 cred。"""
s = resp.find(b"\x0a\x0a", 0x40)
e = resp.find(b"_wup_header")
if s < 0 or e < 0:
return None
d = resp[s:e - 6]
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
if not m:
return None
ln = m.group(2)[0]
st = m.start() + 4
cred = d[st:st + ln]
if len(cred) == 114 and cred[:1] == b"\x0a":
return cred
return None
def parse_risk_url(resp: bytes) -> str | None:
"""提取 safe_auth 风控 URL。"""
urls = []
for m in RISK_URL_RE.finditer(resp):
u = m.group().decode("utf-8", "ignore")
while u and u[-1] not in _URL_TAIL_KEEP:
u = u[:-1]
urls.append(u)
if not urls:
return None
pt = [u for u in urls if "pt_auth" in u]
return (pt or urls)[0]
def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
"""解 safe_auth 风控滑块。"""
from .verification.solver import (
HuyaQrAuthRequiredError,
HuyaVerificationSolver,
)
q = {k: v[0] for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()}
app_id = str(q.get("appId") or "5002")
last_err: Exception | None = None
for attempt in range(max_retry):
solver = HuyaVerificationSolver(
ua=APP_UA_MOBILE,
proxies=proxies,
app_id=app_id,
page_url=risk_url,
use_touch_events=True,
)
try:
result = solver.solve(risk_url)
except HuyaQrAuthRequiredError:
raise
except Exception as exc:
last_err = exc
logger.warning(f"[safe_auth] 第 {attempt + 1} 次过验异常: {exc}")
time.sleep(1.0)
continue
auth_id = str((result or {}).get("authId") or "")
if auth_id:
logger.info(f"[safe_auth] 滑块通过 authId={auth_id[:24]}...")
return result
raise HuyaAppLoginError(f"safe_auth 滑块过验失败: {last_err or '未返回 authId'}")
def parse_real_uid(resp: bytes) -> int:
"""从登录响应中提取 12 位真实 UID。"""
s = resp.find(b"\x0a\x0a", 0x40)
e = resp.find(b"_wup_header")
if s >= 0 and e >= 0:
chunk = resp[s:e]
if len(chunk) > 243:
uid = struct.unpack_from(">Q", chunk, 235)[0]
if 1_100_000_000_000 < uid < 1_300_000_000_000:
return uid
for off in range(len(chunk) - 8):
u = struct.unpack_from(">Q", chunk, off)[0]
if 1_100_000_000_000 < u < 1_300_000_000_000:
return u
raise HuyaAppLoginError("无法从登录响应解析真实 UID")
def login_cred_with_flow(
account: str,
password: str,
max_rounds: int = 3,
device_info: dict | None = None,
proxies: dict | None = None,
) -> tuple[bytes, int]:
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
"""
dev = dict(device_info or DEFAULT_GOLDEN_DEV)
try:
_t1, safedeviceid, registered_device_id = register_device(
fingerprint=dev.get("fingerprint"),
proxies=proxies,
)
except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id
for rnd in range(max_rounds):
resp = wup_password_login_raw(
account,
password,
device_info=dev,
safedeviceid=safedeviceid,
proxies=proxies,
)
cred = parse_cred(resp)
if cred:
uid = parse_real_uid(resp)
return cred, uid
risk_url = parse_risk_url(resp)
if risk_url:
kind = "pt_auth(滑块)" if "pt_auth" in risk_url else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
if "qr_auth" in risk_url:
raise HuyaAppQrAuthRequiredError(
f"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
)
solve_safe_auth(risk_url, proxies=proxies)
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
continue
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)")
raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据")
class QrRole:
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。"""
def __init__(self, pc: bool, sdid: str, proxies: dict | None = None):
self.pc = pc
self.sdid = sdid
self.s = requests.Session()
self.s.trust_env = False
if proxies:
self.s.proxies.update(proxies)
ctx_hex = uuid.uuid4().hex
tail = "CBC9F93DBEB000011DB11EC02A401BAD-" if pc else uuid.uuid4().hex.upper()
tail = tail if tail.endswith("-") else tail + "-"
prefix = "WB" if pc else "H5"
self.context = f"{prefix}-{ctx_hex}-{tail}"
self.page_id = random.randint(40_000_000, 41_000_000)
self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update({
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
"Origin": UDB_BASE,
"content-type": "application/json;charset=UTF-8",
"Accept": "*/*",
})
def _headers(self, uri: str) -> dict:
mid = "2.6" if self.pc else "2.5"
return {
"context": self.context,
"uri": uri,
"reqid": str(self.req_counter),
"lcid": "2052",
"Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}",
}
def call(self, path: str, uri: str, data: dict, cookies: dict | None = None) -> dict:
envelope = {
"uri": uri,
"version": "2.6" if self.pc else "2.5",
"context": self.context,
"appId": "5002" if self.pc else "5131",
"appSign": APP_SIGN_WEB if self.pc else APP_SIGN_H5,
"authId": "",
"sdid": self.sdid,
"lcid": "2052",
"byPass": "3",
"requestId": str(self.req_counter),
"data": data,
}
self.req_counter += random.randint(120, 400)
r = self.s.post(
f"{UDB_BASE}{path}",
json=envelope,
headers=self._headers(uri),
cookies=cookies,
timeout=20,
)
return r.json()
def web_behavior(page: str = "https://www.huya.com/g") -> tuple[str, str]:
now = int(time.time() * 1000) - random.randint(3000, 10000)
acts = []
d = random.randint(800, 1800)
for a in ("7", "7", "8", "8"):
now += random.randint(600, 2000)
d += random.randint(600, 2000)
acts.append({"id": a, "d": d, "time": now})
val = {"furl": page, "curl": page, "user_action": acts}
beh = quote(json.dumps(val, separators=(",", ":")), safe="~()*!.'")
return beh, quote(page, safe="")
class HuyaAppPasswordLogin:
"""虎牙 App 渠道纯协议密码登录器。"""
def __init__(
self,
username: str,
password: str,
proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] | None = None,
force_new_device: bool = False,
device_info: dict | None = None,
):
self.username = username.strip()
self.password = password
self.proxies = dict(proxies) if proxies else None
self.timeout = timeout or (10.0, 25.0)
self.force_new_device = force_new_device
self.device_info = device_info or get_profile(self.username, force_new=force_new_device)
def login(self) -> HuyaLoginResult:
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
result = self._login_impl()
try:
from .device_profile import record_login
record_login(self.username, result.success, result.message)
except Exception: # 元数据记录失败不影响登录结果
pass
return result
def _login_impl(self) -> HuyaLoginResult:
"""登录主体 (原 login)。"""
acct = self.username
logger.info(f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})...")
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
try:
cred, uid = login_cred_with_flow(
acct,
self.password,
device_info=self.device_info,
proxies=self.proxies,
)
except HuyaAppQrAuthRequiredError as exc:
return HuyaLoginResult(
success=False,
message=str(exc),
code="QR_AUTH_REQUIRED",
)
except Exception as exc:
return HuyaLoginResult(
success=False,
message=f"App 登录凭证获取失败: {exc}",
code="LOGIN_FAILED",
)
logger.info(f"[huya-app] 成功获取 cred ({len(cred)}B), uid={uid}")
# 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致)
try:
env = Envelope.load()
orig = base64.b64decode(env.cert_b64)
f = parse_p1(decrypt_cert(orig))
st = int(time.time() * 1000)
rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0)
fp_bytes = self.device_info["fingerprint"].encode("ascii")
p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd)
cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode()
# 3) 信封补丁
raw = bytearray(env.raw)
raw[env.cert_off:env.cert_off + env.cert_len] = cert.encode("ascii")
if env.uid != uid:
struct.pack_into(">Q", raw, env.uid_off, uid)
wup = base64.b64encode(bytes(raw)).decode("ascii")
except Exception as exc:
return HuyaLoginResult(
success=False,
message=f"证书铸造/信封补丁失败: {exc}",
code="CERT_FORGE_FAILED",
)
# 4) 模拟扫码绑定四步流获取 biztoken
try:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid(allow_fallback=True,
state_dir=account_state_dir(self.username),
device_hint=self.device_info)
sdid = sdid_obj.sdid if sdid_obj else ""
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
beh, page = web_behavior()
# 4.1 获取 qrId
resp = pc.call(
"/qrLgn/getQrId",
"70001",
{"behavior": beh, "type": "", "domainList": "", "page": page},
)
qrid = (resp.get("data") or {}).get("qrId")
if not qrid:
return HuyaLoginResult(
success=False,
message=f"获取 qrId 失败: {resp.get('message') or resp}",
code="QR_ID_FAILED",
)
# 4.2 扫码与绑定
cp = f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002"
ph.call(
"/qrLgn/scanQrPicNotify",
"70005",
{
"qrId": qrid,
"wupData": wup,
"behavior": quote("[]", safe=""),
"page": quote(cp, safe=""),
},
)
r2 = ph.call(
"/qrLgn/bindQrLoginUser",
"70007",
{
"qrId": qrid,
"wupData": wup,
"behavior": quote("[]", safe=""),
"page": quote(cp, safe=""),
},
)
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
logger.warning(f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}")
# 4.3 轮询 tryQrLogin
biztoken = None
for _ in range(12):
rt = pc.call(
"/qrLgn/tryQrLogin",
"70003",
{
"qrId": qrid,
"remember": "1",
"domainList": "",
"behavior": beh,
"page": page,
},
)
dt = rt.get("data") or {}
if dt.get("stage") == 2:
biztoken = dt.get("biztoken")
break
time.sleep(1.5)
if not biztoken:
return HuyaLoginResult(
success=False,
message="未能在轮询时间内获取到 biztoken (绑定超时或失败)",
code="BIZTOKEN_TIMEOUT",
)
# 5) POST /web/cookie/verify 兑换 Cookie
verify_resp = pc.s.post(
"https://udblgn.huya.com/web/cookie/verify",
json={"appId": 5002},
timeout=15,
)
if verify_resp.status_code != 200:
return HuyaLoginResult(
success=False,
message=f"Cookie verify 兑换 HTTP 失败: {verify_resp.status_code}",
code="VERIFY_FAILED",
)
cookie_str = normalize_huya_cookie(pc.s.cookies)
if "udb_cred" not in cookie_str and "yyuid" not in cookie_str:
return HuyaLoginResult(
success=False,
message="Cookie 兑换完成但缺失关键凭据 (udb_cred/yyuid)",
code="COOKIE_INCOMPLETE",
)
logger.info(f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)")
return HuyaLoginResult(
success=True,
cookie=cookie_str,
message="App协议登录成功",
sdid=sdid,
context=pc.context,
)
except Exception as exc:
return HuyaLoginResult(
success=False,
message=f"扫码绑定兑换 Cookie 失败: {exc}",
code="BIND_FLOW_FAILED",
)
def login_huya_app_password(
username: str,
password: str,
proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] | None = None,
force_new_device: bool = False,
) -> HuyaLoginResult:
"""函数式入口:使用 App 协议执行虎牙密码登录并获取全套 Cookie。"""
return HuyaAppPasswordLogin(
username=username,
password=password,
proxies=proxies,
timeout=timeout,
force_new_device=force_new_device,
).login()