Files
Steam-Panel-Simple/app/services/steam_wizard.py
T
2026-04-17 22:22:40 +03:00

398 lines
14 KiB
Python

"""Shared Steam account recovery wizard flow.
Steps 1-9 are identical across password change, email change, and phone removal.
This module extracts that common logic.
"""
import asyncio
import json
from dataclasses import dataclass
from urllib.parse import parse_qs, urlparse
from loguru import logger
from app.core.crypto import encrypt_password
from app.services.steam_auth import raw_request
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/"
"537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36"
)
AJAX_HEADERS = {
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": BROWSER_UA,
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "https://help.steampowered.com",
}
@dataclass
class WizardParams:
s: int
account: int
reset: int
issueid: int
lost: int = 0
sessionid: str = ""
async def parse_wizard_redirect(steam, entry_url: str) -> WizardParams:
"""Step 1-2: Navigate to wizard entry URL, follow redirects, parse params from final URL."""
logger.debug(f"[Wizard] Step 1-2: GET {entry_url}")
response = await raw_request(
steam,
url=entry_url,
method="GET",
headers={"User-Agent": BROWSER_UA},
allow_redirects=True,
)
final_url = str(response.url)
logger.debug(f"[Wizard] Redirect → {final_url}")
query = parse_qs(urlparse(final_url).query)
sessionid = await steam.sessionid("help.steampowered.com")
params = WizardParams(
s=int(query.get("s", [0])[0]),
account=int(query.get("account", [0])[0]),
reset=int(query.get("reset", [0])[0]),
issueid=int(query.get("issueid", [0])[0]),
lost=int(query.get("lost", [0])[0]),
sessionid=sessionid,
)
logger.debug(f"[Wizard] Parsed: s={params.s}, account={params.account}, reset={params.reset}, issueid={params.issueid}")
return params
async def login_info_enter_code(steam, params: WizardParams) -> dict:
"""Step 3: Enter code mode."""
logger.debug("[Wizard] Step 3: HelpWithLoginInfoEnterCode")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/HelpWithLoginInfoEnterCode",
method="GET",
params={
"s": params.s,
"account": params.account,
"reset": params.reset,
"lost": params.lost,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 3 response: {result}")
return result
async def send_recovery_code(steam, params: WizardParams) -> dict:
"""Step 4: Send account recovery code (method=8 = mobile app)."""
logger.debug("[Wizard] Step 4: AjaxSendAccountRecoveryCode (method=8, mobile)")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxSendAccountRecoveryCode",
method="POST",
data={
"s": params.s,
"account": params.account,
"reset": params.reset,
"lost": params.lost,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
"method": 8,
"link": "",
"n": 1,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 4 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"SendRecoveryCode failed: {result['errorMsg']}")
return result
async def mobile_confirm(steam, params: WizardParams, retries: int = 5) -> None:
"""Step 5: Confirm via Steam mobile confirmations API (getlist → find by creator_id → ajaxop allow)."""
logger.debug("[Wizard] Step 5: Mobile confirmation")
try:
sid = steam.steamid
except (ValueError, AttributeError):
sid = None
if not sid:
raise RuntimeError("No steamid on Steam object — cannot perform mobile confirmation")
try:
did = steam.device_id
except (ValueError, AttributeError):
did = None
if not did:
raise RuntimeError("No device_id on Steam object — cannot perform mobile confirmation")
try:
_ = steam.identity_secret
except (ValueError, AttributeError):
raise RuntimeError("No identity_secret on Steam object — cannot perform mobile confirmation")
logger.debug(f"[MobileConfirm] steamid={sid}, device_id={did[:20]}..., creator_id(s)={params.s}")
# Steam needs time to create the confirmation after send_recovery_code
logger.debug("[MobileConfirm] Waiting 3s for Steam to create confirmation...")
await asyncio.sleep(3)
for attempt in range(retries):
try:
server_time = await steam.get_server_time()
conf_hash = steam.get_confirmation_hash(server_time=server_time)
logger.debug(f"[MobileConfirm] Attempt {attempt + 1}/{retries}: getlist (server_time={server_time})")
conf_text = await steam.request(
url="https://steamcommunity.com/mobileconf/getlist",
method="GET",
cookies={
"mobileClient": "ios",
"mobileClientVersion": "2.0.20",
"steamid": str(sid),
"Steam_Language": "english",
},
params={
"p": did,
"a": str(sid),
"k": conf_hash,
"t": server_time,
"m": "react",
"tag": "conf",
},
)
conf_data = json.loads(conf_text) if isinstance(conf_text, str) else conf_text
confs = conf_data.get("conf", [])
logger.debug(f"[MobileConfirm] getlist response: success={conf_data.get('success')}, {len(confs)} confirmation(s)")
for i, c in enumerate(confs):
logger.debug(
f"[MobileConfirm] [{i}] id={c.get('id')}, type={c.get('type')}, "
f"type_name={c.get('type_name', '?')}, creator_id={c.get('creator_id')}, "
f"headline={c.get('headline', '')}"
)
if not conf_data.get("success"):
logger.warning(f"[MobileConfirm] getlist failed: {conf_data}")
raise RuntimeError(f"getlist failed: {conf_data}")
target = None
creator = str(params.s)
for c in confs:
if str(c.get("creator_id", "")) == creator:
target = c
break
if not target:
ids = [str(c.get("creator_id", "")) for c in confs]
logger.warning(f"[MobileConfirm] No match for creator_id={creator}, available: {ids}")
raise RuntimeError(f"No confirmation found for creator_id={creator}")
logger.debug(
f"[MobileConfirm] Found match! id={target['id']}, nonce={target.get('nonce')}, "
f"creator_id={target.get('creator_id')}"
)
allow_time = await steam.get_server_time()
allow_hash = steam.get_confirmation_hash(server_time=allow_time, tag="allow")
logger.debug(f"[MobileConfirm] Calling ajaxop allow (server_time={allow_time})")
allow_text = await steam.request(
url="https://steamcommunity.com/mobileconf/ajaxop",
method="GET",
cookies={
"mobileClient": "ios",
"mobileClientVersion": "2.0.20",
},
params={
"op": "allow",
"p": did,
"a": str(sid),
"k": allow_hash,
"t": allow_time,
"m": "react",
"tag": "allow",
"cid": target["id"],
"ck": target["nonce"],
},
)
allow_data = json.loads(allow_text) if isinstance(allow_text, str) else allow_text
logger.debug(f"[MobileConfirm] ajaxop response: {allow_data}")
if allow_data.get("success"):
logger.debug(f"[MobileConfirm] ✓ Confirmation accepted (attempt {attempt + 1})")
return
raise RuntimeError(f"ajaxop returned success=false: {allow_data}")
except Exception as exc:
logger.warning(f"[MobileConfirm] Attempt {attempt + 1}/{retries} failed: {exc}")
if attempt < retries - 1:
logger.debug(f"[MobileConfirm] Retrying in 3s...")
await asyncio.sleep(3)
raise RuntimeError(f"Mobile confirmation failed after {retries} retries")
async def poll_recovery_confirmation(steam, params: WizardParams) -> dict:
"""Step 6: Poll account recovery confirmation (single call, matches old chemail.py)."""
logger.debug("[Wizard] Step 6: AjaxPollAccountRecoveryConfirmation")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxPollAccountRecoveryConfirmation",
method="POST",
data={
"s": params.s,
"reset": params.reset,
"lost": params.lost,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
"method": 8,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 6 response: {data}")
if data.get("errorMsg"):
raise RuntimeError(f"Poll error from Steam: {data['errorMsg']}")
return data
async def verify_recovery_code(steam, params: WizardParams) -> dict:
"""Step 7: Verify recovery code (empty for mobile method)."""
logger.debug("[Wizard] Step 7: AjaxVerifyAccountRecoveryCode (code=empty, method=8)")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxVerifyAccountRecoveryCode",
method="GET",
params={
"code": "",
"s": params.s,
"reset": params.reset,
"lost": params.lost,
"method": 8,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 7 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"Step 7 (VerifyRecoveryCode) failed: {result['errorMsg']}")
return result
async def get_next_step(steam, params: WizardParams) -> dict:
"""Step 8: Get next wizard step (lost=2 hardcoded)."""
logger.debug("[Wizard] Step 8: AjaxAccountRecoveryGetNextStep (lost=2)")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryGetNextStep",
method="POST",
data={
"s": params.s,
"account": params.account,
"reset": params.reset,
"lost": 2,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 8 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"Step 8 (GetNextStep) failed: {result['errorMsg']}")
return result
async def get_rsa_key(steam, login: str) -> tuple[str, str, int]:
"""Step 9a: Get RSA public key from Steam."""
logger.debug(f"[Wizard] Getting RSA key for {login}")
sessionid = await steam.sessionid("help.steampowered.com")
text = await steam.request(
url="https://help.steampowered.com/en/login/getrsakey/",
method="POST",
data={
"sessionid": sessionid,
"username": login,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] RSA key received: success={data.get('success')}, timestamp={data.get('timestamp')}")
return data["publickey_mod"], data["publickey_exp"], int(data["timestamp"])
async def verify_password(steam, params: WizardParams, login: str, password: str) -> dict:
"""Step 9: Verify current password via RSA encryption."""
logger.debug(f"[Wizard] Step 9: Verifying current password for {login}")
mod, exp, timestamp = await get_rsa_key(steam, login)
encrypted = encrypt_password(password, mod, exp)
sessionid = await steam.sessionid("help.steampowered.com")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryVerifyPassword/",
method="POST",
data={
"sessionid": sessionid,
"s": params.s,
"lost": 2,
"reset": 1,
"password": encrypted,
"rsatimestamp": timestamp,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 9 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"Step 9 (VerifyPassword) failed: {result['errorMsg']}")
return result
async def run_common_wizard(steam, entry_url: str, login: str, password: str) -> WizardParams:
"""Execute the full shared wizard flow (steps 1-9).
Returns WizardParams ready for the service-specific final steps.
"""
logger.debug(f"[Wizard] ===== Starting wizard for {login} =====")
logger.debug(f"[Wizard] Entry URL: {entry_url}")
params = await parse_wizard_redirect(steam, entry_url)
await asyncio.sleep(1)
await login_info_enter_code(steam, params)
await asyncio.sleep(1)
await send_recovery_code(steam, params)
await asyncio.sleep(1)
await mobile_confirm(steam, params)
await poll_recovery_confirmation(steam, params)
await asyncio.sleep(1)
await verify_recovery_code(steam, params)
await asyncio.sleep(1)
await get_next_step(steam, params)
await asyncio.sleep(1)
await verify_password(steam, params, login, password)
logger.debug(f"[Wizard] ===== Wizard complete for {login} =====")
return params