forked from FOSS/Steam-Panel
v 3.0.3
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user