Files
live-hub-py/core/huya/app_login.py
T

676 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙 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 / userAction 均按当前画像和本次请求动态生成;
- 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 collections.abc 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, reset_account_state
from .device_profile import get_profile, mobile_user_agent
from .dfp_register import DfpRegistrationError, register_device
from .envelope_forge import Envelope
from .login import 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"
)
SessionAssets = tuple[dict, str, str]
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
_URL_TAIL_KEEP = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
)
class HuyaAppLoginError(HuyaLoginError):
"""虎牙 App 登录失败。"""
class HuyaAppQrAuthRequiredError(HuyaAppLoginError):
"""风控要求扫码验证 (qr_auth)。"""
def _golden_session_assets() -> tuple[dict, str, str]:
"""生成本次登录的会话元数据和行为轨迹。"""
from .wup_encoder import make_trace_id, make_user_action
mj = {
"associationId": 8193,
"funcName": "hypasswordLogin",
"group": 1,
"id": 4097,
"session": random.randint(1_000_000, 9_999_999),
"step": 1,
"stillLogin": False,
"traceId": make_trace_id(),
"type": 3,
"uid": 0,
"userContext": "",
}
return mj, make_user_action(), ""
def _android_version(device_info: Mapping[str, object]) -> str:
parts = str(device_info.get("screen") or "").split(",")
return parts[2] if len(parts) >= 3 and parts[2] else "11"
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,
session_assets: SessionAssets | None = None,
) -> bytes:
"""发送 WUP 密码登录,返回原始响应字节。
未显式传入 ``safedeviceid`` 时会先执行新设备注册链,不再回退旧的
固定 action/device_id。风控重试调用方应显式复用同一注册结果。
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
"""
uid_str = account.removeprefix("hy_")
dev = dict(device_info) if device_info is not None else get_profile(account)
mj, ua, _old_sd = session_assets or _golden_session_assets()
if not safedeviceid:
try:
_t1, safedeviceid, registered_device_id = register_device(
fingerprint=dev.get("fingerprint"),
proxies=proxies,
timeout=timeout,
device_info=dev,
)
except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id
hdid_value = hdid or dev.get("hdid")
if not hdid_value:
raise HuyaAppLoginError("账号设备画像缺少 HDID32,拒绝使用固定金样本")
pkt = build_password_login_wup(
uid_str,
hashlib.sha1(password.encode()).hexdigest(),
safedeviceid,
str(hdid_value),
mj["session"],
mj["traceId"],
ua,
dev,
)
r = requests.post(
WUP_URL,
data=pkt,
headers={
"Content-Type": "application/multipart-formdata; charset=UTF-8",
"User-Agent": f"Dalvik/2.1.0 (Linux; U; Android {_android_version(dev)})",
"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 _response_markers(resp: bytes) -> str:
"""提取 WUP 错误响应中的短 ASCII 字段,便于区分签名和凭据失败。"""
markers = []
for raw in re.findall(rb"[ -~]{4,}", resp):
value = raw.decode("ascii", "ignore")
if value not in markers and len(value) <= 160:
markers.append(value)
return ", ".join(markers[:8])
def solve_safe_auth(
risk_url: str,
proxies=None,
max_retry: int = 3,
device_info: Mapping[str, object] | None = None,
) -> dict:
"""解 safe_auth 风控滑块。"""
from .verification.solver import (
HuyaQrAuthRequiredError,
HuyaVerificationSolver,
)
if device_info is None:
raise HuyaAppLoginError("safe_auth 缺少当前账号设备画像")
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=mobile_user_agent(device_info),
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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) if device_info is not None else get_profile(account)
try:
_t1, safedeviceid, registered_device_id = register_device(
fingerprint=dev.get("fingerprint"),
proxies=proxies,
device_info=dev,
)
except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id
# 风控验证绑定首次 WUP 请求的 session/traceId;所有重发必须复用它们。
session_assets = _golden_session_assets()
for rnd in range(max_rounds):
logger.debug(f"[huya-app] WUP 登录请求第 {rnd + 1} 轮...")
resp = wup_password_login_raw(
account,
password,
device_info=dev,
safedeviceid=safedeviceid,
proxies=proxies,
session_assets=session_assets,
)
cred = parse_cred(resp)
risk_url = parse_risk_url(resp) if not cred else None
logger.debug(
f"[huya-app] WUP 第 {rnd + 1} 轮响应: {len(resp)}B, "
f"cred={bool(cred)}, risk={bool(risk_url)}"
)
if cred:
uid = parse_real_uid(resp)
return cred, uid
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(
"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
)
solve_safe_auth(risk_url, proxies=proxies, device_info=dev)
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
continue
markers = _response_markers(resp)
detail = f",服务端字段: {markers}" if markers else ""
logger.warning(f"[huya-app] WUP 响应未包含 cred/风控: {len(resp)}B{detail}")
if "LGN_INFO_INVALID_USER_OR_PASSWORD" in markers:
raise HuyaAppLoginError(
"虎牙账号或密码错误(服务端: LGN_INFO_INVALID_USER_OR_PASSWORD"
)
if "APP_SIGN_NOT_MATCH" in markers:
raise HuyaAppLoginError("虎牙设备签名不匹配(服务端: APP_SIGN_NOT_MATCH")
raise HuyaAppLoginError(
f"登录未返回凭据也无风控URL(响应 {len(resp)}B{detail}"
)
raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据")
class QrRole:
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。"""
def __init__(
self,
pc: bool,
sdid: str,
proxies: dict | None = None,
device_info: Mapping[str, object] | None = None,
):
self.pc = pc
self.sdid = sdid
if not pc and device_info is None:
raise HuyaAppLoginError("移动端二维码角色缺少当前账号设备画像")
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 mobile_user_agent(device_info),
"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
if force_new_device:
reset_account_state(self.username)
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 as exc: # noqa: BLE001 # 元数据记录失败不影响登录结果
logger.debug(f"登录元数据记录失败: {exc}")
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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)
if env.cert_off is None or env.uid_off is None:
raise ValueError("信封缺少证书或 uid 偏移")
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)
# QR 信封只保留协议结构;不要重放抓包里的旧会话值。
qr_session = random.randint(1_000_000, 9_999_999)
qr_trace = (
f"{uuid.uuid4().hex[:16]}-{random.randint(10000, 99999)}-"
f"{time.time_ns():020d}"
)
env.raw = raw
env.patch_session(qr_session).patch_meta(qr_session, qr_trace)
wup = base64.b64encode(bytes(env.raw)).decode("ascii")
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaLoginResult(
success=False,
message=f"证书铸造/信封补丁失败: {exc}",
code="CERT_FORGE_FAILED",
)
# 4) 模拟扫码绑定四步流获取 biztoken
try:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid(
# App 主链只接受当前账号的 hydevice 高信任结果;旧版
# token+collect 降级没有账号画像,不再静默放行。
allow_fallback=False,
state_dir=account_state_dir(self.username),
device_hint=self.device_info,
)
if not sdid_obj or not sdid_obj.sdid or sdid_obj.source != "fingerprint":
detail = sdid_obj.message if sdid_obj else "未返回结果"
raise HuyaAppLoginError(
f"账号设备指纹获取失败(必须为 hydevice): {detail}"
)
sdid = sdid_obj.sdid
logger.info("[huya-app] cred 已获取,开始二维码绑定流程")
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole(
pc=False,
sdid=sdid,
proxies=self.proxies,
device_info=self.device_info,
)
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")
logger.debug(f"[huya-app] getQrId 响应: qrid={bool(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=""),
},
)
logger.debug(
f"[huya-app] bindQrLoginUser 响应: returnCode={r2.get('returnCode')}"
)
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 index in range(12):
rt = pc.call(
"/qrLgn/tryQrLogin",
"70003",
{
"qrId": qrid,
"remember": "1",
"domainList": "",
"behavior": beh,
"page": page,
},
)
dt = rt.get("data") or {}
logger.debug(
f"[huya-app] tryQrLogin 第 {index + 1}/12 轮: stage={dt.get('stage')}"
)
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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()