forked from FOSS/Steam-Panel
v 3.0.3
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_active_browsers: list = []
|
||||
|
||||
STEAM_DOMAINS = [
|
||||
".steamcommunity.com",
|
||||
".steampowered.com",
|
||||
".store.steampowered.com",
|
||||
".help.steampowered.com",
|
||||
".login.steampowered.com",
|
||||
]
|
||||
|
||||
|
||||
async def open_browser_with_cookies(account: dict) -> None:
|
||||
"""Open a Chrome browser with Steam session cookies using nodriver."""
|
||||
import nodriver as uc
|
||||
|
||||
cookies_json = account.get("session_cookies")
|
||||
if not cookies_json:
|
||||
raise ValueError("No session cookies saved. Validate the account first.")
|
||||
|
||||
cookies = json.loads(cookies_json)
|
||||
|
||||
browser = await uc.start()
|
||||
_active_browsers.append(browser)
|
||||
|
||||
tab = await browser.get("about:blank")
|
||||
|
||||
for c in cookies:
|
||||
domain = c.get("domain") or ".steamcommunity.com"
|
||||
domains_to_set = [domain]
|
||||
if "steam" in domain:
|
||||
domains_to_set = list(set([domain] + STEAM_DOMAINS))
|
||||
|
||||
for d in domains_to_set:
|
||||
try:
|
||||
await tab.send(uc.cdp.network.set_cookie(
|
||||
name=c["name"],
|
||||
value=c["value"],
|
||||
domain=d,
|
||||
path=c.get("path") or "/",
|
||||
secure=c.get("secure", True),
|
||||
http_only=c.get("httpOnly", False),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
steam_id = account.get("steam_id") or ""
|
||||
if steam_id:
|
||||
start_url = f"https://steamcommunity.com/profiles/{steam_id}/"
|
||||
else:
|
||||
start_url = "https://steamcommunity.com/"
|
||||
await browser.get(start_url)
|
||||
logger.info(f"Browser opened for {account['login']}")
|
||||
|
||||
# Wait until browser is closed by user, suppress connection errors
|
||||
try:
|
||||
while browser:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
if not browser.connection or browser.connection.closed:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if browser in _active_browsers:
|
||||
_active_browsers.remove(browser)
|
||||
|
||||
# Temporarily suppress nodriver's internal task errors during cleanup
|
||||
# (Browser.update_targets() raises ConnectionRefusedError after close)
|
||||
loop = asyncio.get_running_loop()
|
||||
_original_handler = loop.get_exception_handler()
|
||||
|
||||
def _suppress_nodriver_close_errors(loop, context):
|
||||
exc = context.get("exception")
|
||||
if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, OSError)):
|
||||
return
|
||||
if _original_handler:
|
||||
_original_handler(loop, context)
|
||||
else:
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
loop.set_exception_handler(_suppress_nodriver_close_errors)
|
||||
try:
|
||||
browser.stop()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
loop.set_exception_handler(_original_handler)
|
||||
|
||||
logger.debug(f"Browser closed for {account['login']}")
|
||||
@@ -0,0 +1,20 @@
|
||||
from app.core.task_manager import task_manager
|
||||
from app.services.steam_guard import remove_guard, generate_2fa
|
||||
from app.services.steam_password import change_password, random_password
|
||||
from app.services.steam_email import change_email, validate_account
|
||||
from app.services.steam_phone import change_phone
|
||||
from app.services.steam_checker import check_logpass_account, full_parse_logpass_account
|
||||
from app.services.steam_token_checker import check_token_account
|
||||
|
||||
|
||||
def register_all_handlers() -> None:
|
||||
task_manager.register_handler("change_password", change_password)
|
||||
task_manager.register_handler("random_password", random_password)
|
||||
task_manager.register_handler("change_email", change_email)
|
||||
task_manager.register_handler("change_phone", change_phone)
|
||||
task_manager.register_handler("remove_guard", remove_guard)
|
||||
task_manager.register_handler("generate_2fa", generate_2fa)
|
||||
task_manager.register_handler("validate", validate_account)
|
||||
task_manager.register_handler("logpass_validate", check_logpass_account)
|
||||
task_manager.register_handler("logpass_full_parse", full_parse_logpass_account)
|
||||
task_manager.register_handler("token_validate", check_token_account)
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from loguru import logger
|
||||
|
||||
from app.core.exceptions import SteamAuthError
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
from pysteamauth.base.request import BaseRequestStrategy, DEFAULT_REQUEST_TIMEOUT
|
||||
|
||||
|
||||
class ProxyRequestStrategy(BaseRequestStrategy):
|
||||
"""Request strategy that routes traffic through a proxy connector."""
|
||||
|
||||
def __init__(self, connector: aiohttp.BaseConnector):
|
||||
super().__init__()
|
||||
self._proxy_connector = connector
|
||||
|
||||
def _create_session(self) -> ClientSession:
|
||||
return ClientSession(
|
||||
connector=self._proxy_connector,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def read_mafile_data(account: dict) -> dict | None:
|
||||
"""Read and parse the account's mafile JSON from disk."""
|
||||
mafile_path = account.get("mafile_path")
|
||||
if not mafile_path:
|
||||
return None
|
||||
try:
|
||||
return json.loads(Path(mafile_path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_proxy(account: dict) -> dict | None:
|
||||
"""Determine proxy dict {address, protocol} for the account."""
|
||||
proxy_address = account.get("proxy")
|
||||
if proxy_address:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT protocol FROM proxies WHERE address = ?", (proxy_address,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
protocol = row["protocol"] if row else "http"
|
||||
return {"address": proxy_address, "protocol": protocol}
|
||||
if proxy_manager.count > 0:
|
||||
return proxy_manager.get_next()
|
||||
return None
|
||||
|
||||
|
||||
async def create_steam_session(account: dict):
|
||||
"""Create an authenticated Steam session for an account."""
|
||||
try:
|
||||
from pysteamauth.auth import Steam
|
||||
except ImportError:
|
||||
raise SteamAuthError("pysteamauth not installed: pip install pysteamauth")
|
||||
|
||||
proxy = await _resolve_proxy(account)
|
||||
request_strategy = None
|
||||
if proxy:
|
||||
connector = proxy_manager.get_connector(proxy)
|
||||
request_strategy = ProxyRequestStrategy(connector)
|
||||
logger.debug(f"Using proxy {proxy['protocol']}://{proxy['address']} for {account['login']}")
|
||||
|
||||
mafile = read_mafile_data(account)
|
||||
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
identity_secret = account.get("identity_secret", "")
|
||||
device_id = None
|
||||
steamid = None
|
||||
|
||||
if mafile:
|
||||
shared_secret = mafile.get("shared_secret", shared_secret)
|
||||
identity_secret = mafile.get("identity_secret", identity_secret)
|
||||
device_id = mafile.get("device_id")
|
||||
steamid = int(mafile.get("Session", {}).get("SteamID", 0)) or None
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
password=account["password"],
|
||||
shared_secret=shared_secret,
|
||||
identity_secret=identity_secret,
|
||||
device_id=device_id,
|
||||
steamid=steamid,
|
||||
request_strategy=request_strategy,
|
||||
)
|
||||
|
||||
try:
|
||||
await steam.login_to_steam()
|
||||
except Exception as exc:
|
||||
await close_steam(steam)
|
||||
exc_str = str(exc)
|
||||
# Pydantic ValidationError from pysteamauth = Steam rejected login
|
||||
if "validation error" in exc_str.lower() and "FinalizeLoginStatus" in exc_str:
|
||||
# Extract the raw response dict from the error
|
||||
m = __import__("re").search(r"input_value=(\{[^}]+\})", exc_str)
|
||||
raw = m.group(1) if m else ""
|
||||
logger.error(f"Auth failed for {account['login']}: Steam rejected login: {raw}")
|
||||
raise SteamAuthError(f"Login failed: Steam rejected credentials ({raw})")
|
||||
logger.error(f"Auth failed for {account['login']}: {exc}")
|
||||
raise SteamAuthError(f"Login failed: {exc}")
|
||||
|
||||
return steam
|
||||
|
||||
|
||||
async def close_steam(steam) -> None:
|
||||
"""Close the underlying aiohttp session and proxy connector of a pysteamauth Steam object."""
|
||||
requests = getattr(steam, "_requests", None)
|
||||
if requests is None:
|
||||
return
|
||||
session = getattr(requests, "_session", None)
|
||||
if session is not None and not getattr(session, "closed", True):
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
requests._session = None
|
||||
|
||||
connector = getattr(requests, "_proxy_connector", None)
|
||||
if connector is not None and not getattr(connector, "closed", True):
|
||||
try:
|
||||
await connector.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def extract_session_cookies(steam) -> str | None:
|
||||
"""Extract session cookies from a pysteamauth Steam object as JSON string.
|
||||
|
||||
Returns JSON string of cookies list, or None if extraction fails.
|
||||
"""
|
||||
try:
|
||||
_req = getattr(steam, "_requests", None)
|
||||
_sess = getattr(_req, "_session", None) if _req else None
|
||||
if not _sess or not hasattr(_sess, "cookie_jar"):
|
||||
return None
|
||||
all_cookies = []
|
||||
for c in _sess.cookie_jar:
|
||||
all_cookies.append({
|
||||
"name": c.key,
|
||||
"value": c.value,
|
||||
"domain": c.get("domain", ""),
|
||||
"path": c.get("path", "/"),
|
||||
"secure": str(c.get("secure", "")).lower() == "true" or c.get("secure") is True,
|
||||
"httpOnly": str(c.get("httponly", "")).lower() == "true" or c.get("httponly") is True,
|
||||
})
|
||||
return json.dumps(all_cookies)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def check_cookies_alive(cookies_json: str, proxy: dict | None = None) -> bool:
|
||||
"""Check if Steam session cookies are still valid.
|
||||
|
||||
Makes a lightweight request to Steam's clientjstoken endpoint.
|
||||
Returns True if logged in, False otherwise.
|
||||
"""
|
||||
try:
|
||||
cookies = json.loads(cookies_json)
|
||||
jar = aiohttp.CookieJar(unsafe=True)
|
||||
for c in cookies:
|
||||
jar.update_cookies(
|
||||
{c["name"]: c["value"]},
|
||||
response_url=__import__("yarl").URL(f"https://{c.get('domain', '.steamcommunity.com').lstrip('.')}"),
|
||||
)
|
||||
connector = proxy_manager.get_connector(proxy) if proxy else None
|
||||
async with aiohttp.ClientSession(cookie_jar=jar, connector=connector) as session:
|
||||
async with session.get(
|
||||
"https://steamcommunity.com/chat/clientjstoken",
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return False
|
||||
data = await resp.json(content_type=None)
|
||||
return bool(data.get("logged_in"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def raw_request(steam, url: str, method: str = "GET", **kwargs) -> aiohttp.ClientResponse:
|
||||
"""Low-level request returning raw aiohttp.ClientResponse (for redirects, etc.)."""
|
||||
from yarl import URL
|
||||
|
||||
host = URL(url).host or ""
|
||||
cookies = await steam.cookies(host)
|
||||
return await steam._requests.request(url=url, method=method, cookies=cookies, **kwargs)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Steam auto-accept login confirmations — ported from legacy/steam_auto_accept_logins.py."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
|
||||
import aiohttp
|
||||
import rsa
|
||||
from loguru import logger
|
||||
|
||||
from pysteamauth.pb2.steammessages_auth.steamclient_pb2 import (
|
||||
CAuthentication_GetPasswordRSAPublicKey_Request,
|
||||
CAuthentication_GetPasswordRSAPublicKey_Response,
|
||||
CAuthentication_BeginAuthSessionViaCredentials_Request,
|
||||
CAuthentication_BeginAuthSessionViaCredentials_Response,
|
||||
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request,
|
||||
CAuthentication_PollAuthSessionStatus_Request,
|
||||
CAuthentication_PollAuthSessionStatus_Response,
|
||||
CAuthentication_GetAuthSessionsForAccount_Response,
|
||||
CAuthentication_GetAuthSessionInfo_Request,
|
||||
CAuthentication_GetAuthSessionInfo_Response,
|
||||
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
|
||||
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response,
|
||||
EAuthTokenPlatformType,
|
||||
EAuthSessionGuardType,
|
||||
)
|
||||
|
||||
BASE_URL = "https://api.steampowered.com"
|
||||
HEADERS = {"User-Agent": "okhttp/4.9.2", "Cookie": "Steam_Language=english"}
|
||||
|
||||
|
||||
def _generate_2fa_code(shared_secret: str, server_time: int | None = None) -> str:
|
||||
"""Generate a Steam Guard TOTP code from shared_secret."""
|
||||
import time as _time
|
||||
|
||||
if server_time is None:
|
||||
server_time = int(_time.time())
|
||||
key = base64.b64decode(shared_secret)
|
||||
msg = struct.pack(">Q", server_time // 30)
|
||||
mac = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
offset = mac[-1] & 0x0F
|
||||
code_int = struct.unpack(">I", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
chars = "23456789BCDFGHJKMNPQRTVWXY"
|
||||
code = ""
|
||||
for _ in range(5):
|
||||
code += chars[code_int % len(chars)]
|
||||
code_int //= len(chars)
|
||||
return code
|
||||
|
||||
|
||||
def _confirmation_signature(shared_secret: str, client_id: int, steamid: int) -> bytes:
|
||||
"""HMAC-SHA256 signature for login confirmation (version=1 + client_id + steamid)."""
|
||||
key = base64.b64decode(shared_secret)
|
||||
data = struct.pack("<HQQ", 1, client_id, steamid)
|
||||
return hmac.new(key, data, hashlib.sha256).digest()
|
||||
|
||||
|
||||
async def mobile_login(session: aiohttp.ClientSession, account: dict) -> str | None:
|
||||
"""Perform mobile login flow, return access_token or None."""
|
||||
login = account["login"]
|
||||
password = account["password"]
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
|
||||
try:
|
||||
# GetPasswordRSAPublicKey
|
||||
req = CAuthentication_GetPasswordRSAPublicKey_Request()
|
||||
req.account_name = login
|
||||
encoded = base64.b64encode(req.SerializeToString()).decode()
|
||||
|
||||
async with session.get(
|
||||
f"{BASE_URL}/IAuthenticationService/GetPasswordRSAPublicKey/v1",
|
||||
params={"origin": "SteamMobile", "input_protobuf_encoded": encoded},
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] RSA key failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
rsa_resp = CAuthentication_GetPasswordRSAPublicKey_Response.FromString(await resp.read())
|
||||
|
||||
pub_key = rsa.PublicKey(int(rsa_resp.publickey_mod, 16), int(rsa_resp.publickey_exp, 16))
|
||||
encrypted_pw = base64.b64encode(rsa.encrypt(password.encode(), pub_key)).decode()
|
||||
|
||||
# BeginAuthSession
|
||||
begin = CAuthentication_BeginAuthSessionViaCredentials_Request()
|
||||
begin.account_name = login
|
||||
begin.encrypted_password = encrypted_pw
|
||||
begin.encryption_timestamp = rsa_resp.timestamp
|
||||
begin.platform_type = EAuthTokenPlatformType.k_EAuthTokenPlatformType_MobileApp
|
||||
begin.device_friendly_name = "Android Device"
|
||||
begin.persistence = 1
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(begin.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/BeginAuthSessionViaCredentials/v1",
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] BeginAuth failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
begin_resp = CAuthentication_BeginAuthSessionViaCredentials_Response.FromString(await resp.read())
|
||||
|
||||
# Send 2FA code
|
||||
if shared_secret:
|
||||
code = _generate_2fa_code(shared_secret)
|
||||
upd = CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request()
|
||||
upd.client_id = begin_resp.client_id
|
||||
upd.steamid = begin_resp.steamid
|
||||
upd.code = code
|
||||
upd.code_type = EAuthSessionGuardType.k_EAuthSessionGuardType_DeviceCode
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(upd.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/UpdateAuthSessionWithSteamGuardCode/v1",
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] 2FA submit failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
|
||||
# PollAuthSessionStatus
|
||||
poll = CAuthentication_PollAuthSessionStatus_Request()
|
||||
poll.client_id = begin_resp.client_id
|
||||
poll.request_id = begin_resp.request_id
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(poll.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/PollAuthSessionStatus/v1",
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[auto-accept] Poll failed for {login}: HTTP {resp.status}")
|
||||
return None
|
||||
poll_resp = CAuthentication_PollAuthSessionStatus_Response.FromString(await resp.read())
|
||||
|
||||
if not poll_resp.access_token:
|
||||
logger.error(f"[auto-accept] No access_token for {login}")
|
||||
return None
|
||||
|
||||
logger.success(f"[auto-accept] Mobile login OK for {login}")
|
||||
return poll_resp.access_token
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto-accept] Login error for {login}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_pending_sessions(session: aiohttp.ClientSession, access_token: str) -> list[int] | None:
|
||||
"""Get pending auth session client_ids. Returns None on 401 (token expired)."""
|
||||
try:
|
||||
async with session.get(
|
||||
f"{BASE_URL}/IAuthenticationService/GetAuthSessionsForAccount/v1",
|
||||
params={
|
||||
"access_token": access_token,
|
||||
"origin": "SteamMobile",
|
||||
"input_protobuf_encoded": "",
|
||||
},
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
return None
|
||||
if resp.status != 200:
|
||||
return []
|
||||
data = CAuthentication_GetAuthSessionsForAccount_Response.FromString(await resp.read())
|
||||
return list(data.client_ids)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[auto-accept] get_pending_sessions error: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
async def confirm_session(
|
||||
session: aiohttp.ClientSession,
|
||||
access_token: str,
|
||||
account: dict,
|
||||
client_id: int,
|
||||
) -> bool:
|
||||
"""Confirm a pending login session."""
|
||||
steam_id = int(account.get("steam_id", 0))
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
if not steam_id or not shared_secret:
|
||||
return False
|
||||
|
||||
try:
|
||||
signature = _confirmation_signature(shared_secret, client_id, steam_id)
|
||||
|
||||
msg = CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request()
|
||||
msg.version = 1
|
||||
msg.client_id = client_id
|
||||
msg.steamid = steam_id
|
||||
msg.signature = signature
|
||||
msg.confirm = True
|
||||
msg.persistence = 1
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("input_protobuf_encoded", base64.b64encode(msg.SerializeToString()).decode())
|
||||
|
||||
async with session.post(
|
||||
f"{BASE_URL}/IAuthenticationService/UpdateAuthSessionWithMobileConfirmation/v1",
|
||||
params={"access_token": access_token},
|
||||
data=data,
|
||||
headers=HEADERS,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
logger.success(f"[auto-accept] Confirmed login for {account['login']} (client_id={client_id})")
|
||||
return True
|
||||
logger.warning(f"[auto-accept] Confirm failed for {account['login']}: HTTP {resp.status}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(f"[auto-accept] Confirm error for {account['login']}: {exc}")
|
||||
return False
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Steam account ban/alert checking via supportmessages page."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
async def check_ban(steam) -> str:
|
||||
"""Check if account has active bans/alerts.
|
||||
|
||||
Returns: 'BANNED', 'NO BAN', or '' on error.
|
||||
"""
|
||||
try:
|
||||
response_html = await steam.request(
|
||||
"https://store.steampowered.com/supportmessages/",
|
||||
method="GET",
|
||||
)
|
||||
|
||||
if isinstance(response_html, bytes):
|
||||
response_html = response_html.decode("utf-8", errors="replace")
|
||||
|
||||
if "support_message_page" in response_html or "This account has been locked" in response_html:
|
||||
return "BANNED"
|
||||
elif "It doesn't appear that you have any active account alerts" in response_html:
|
||||
return "NO BAN"
|
||||
else:
|
||||
return "NO BAN"
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Ban check failed: {exc}")
|
||||
return ""
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Validation service for log:pass accounts (no mafile)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import _resolve_proxy, ProxyRequestStrategy, close_steam, extract_session_cookies
|
||||
from app.services.steam_ban import check_ban
|
||||
from app.services.steam_profile import fetch_profile
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
|
||||
async def check_logpass_account(account: dict, params: dict, *, task_id: str) -> None:
|
||||
"""Login with login:pass only, get steamid/nickname, check ban, update DB row."""
|
||||
try:
|
||||
from pysteamauth.auth import Steam
|
||||
except ImportError:
|
||||
raise RuntimeError("pysteamauth not installed")
|
||||
|
||||
acc_id = account["id"]
|
||||
proxy = await _resolve_proxy(account)
|
||||
request_strategy = None
|
||||
if proxy:
|
||||
connector = proxy_manager.get_connector(proxy)
|
||||
request_strategy = ProxyRequestStrategy(connector)
|
||||
logger.debug(f"[checker] Using proxy for {account['login']}")
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
password=account["password"],
|
||||
request_strategy=request_strategy,
|
||||
)
|
||||
|
||||
try:
|
||||
await task_manager.set_step(task_id, 1, 3, "Авторизация", acc_id)
|
||||
|
||||
# Pre-validate password before attempting RSA encryption (Steam RSA-2048 max 245 bytes)
|
||||
password = account.get("password") or ""
|
||||
if len(password.encode("utf-8")) > 245:
|
||||
raise ValueError(
|
||||
f"Password is too long ({len(password)} chars) — possible mafile data imported as password. "
|
||||
"Re-import the account in login:password format."
|
||||
)
|
||||
|
||||
await steam.login_to_steam()
|
||||
|
||||
# Save session cookies right after login
|
||||
try:
|
||||
session_cookies_json = extract_session_cookies(steam)
|
||||
if session_cookies_json:
|
||||
from app.database import get_db as _get_db
|
||||
_db = await _get_db()
|
||||
await _db.execute(
|
||||
"UPDATE logpass_accounts SET session_cookies = ? WHERE id = ?",
|
||||
(session_cookies_json, acc_id),
|
||||
)
|
||||
await _db.commit()
|
||||
logger.debug(f"[checker] {account['login']}: saved session cookies")
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"[checker] {account['login']}: failed to save cookies — {cookie_err}")
|
||||
|
||||
from app.config import read_validation_settings
|
||||
val_settings = read_validation_settings()
|
||||
|
||||
steam_id = str(steam.steamid)
|
||||
ban_status = ""
|
||||
if val_settings.get("check_ban"):
|
||||
await task_manager.set_step(task_id, 2, 3, "Проверка бана", acc_id)
|
||||
ban_status = await check_ban(steam)
|
||||
|
||||
nickname = None
|
||||
steam_level = None
|
||||
avatar_url = None
|
||||
last_online = None
|
||||
if steam_id and steam_id != "0" and val_settings.get("fetch_profile"):
|
||||
await task_manager.set_step(task_id, 3, 3, "Получение профиля", acc_id)
|
||||
profile = await fetch_profile(steam_id, steam=steam)
|
||||
if profile:
|
||||
nickname = profile.get("nickname")
|
||||
steam_level = profile.get("steam_level")
|
||||
avatar_url = profile.get("avatar_url")
|
||||
last_online = profile.get("last_online")
|
||||
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""UPDATE logpass_accounts
|
||||
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
|
||||
ban_status = ?, status = 'valid', updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(steam_id, nickname, steam_level, avatar_url, last_online, ban_status, acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.success(f"[checker] {account['login']} → valid, ban={ban_status}")
|
||||
|
||||
except Exception as exc:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
|
||||
(acc_id,),
|
||||
)
|
||||
await db.commit()
|
||||
logger.error(f"[checker] {account['login']} → {exc}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def _check_cs2_prime(steam, steam_id: str) -> str:
|
||||
"""Check CS2 Prime status."""
|
||||
try:
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/730/?tab=primeaccount"
|
||||
resp = await steam.request(url, method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
if "no_personal_data_stored_message" in html:
|
||||
return "Disabled"
|
||||
if "personaldata_elements_container" in html and "Enabled" in html:
|
||||
return "Enabled"
|
||||
return "Disabled"
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] prime check error: {e}")
|
||||
return "Disabled"
|
||||
|
||||
|
||||
async def _check_dota_trophy(steam, steam_id: str) -> str | None:
|
||||
"""Check Dota 2 Trophy Score."""
|
||||
try:
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/570/?category=Stats&tab=Trophy"
|
||||
resp = await steam.request(url, method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
if "no_personal_data_stored_message" in html:
|
||||
return None
|
||||
if "personaldata_elements_container" in html:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for row in soup.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if len(cells) >= 3:
|
||||
try:
|
||||
int(cells[0].get_text(strip=True))
|
||||
score = cells[1].get_text(strip=True)
|
||||
if score.isdigit():
|
||||
return score
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] trophy check error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _check_dota_behavior(steam, steam_id: str) -> str | None:
|
||||
"""Check Dota 2 Behavior Score."""
|
||||
try:
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/570/?category=Account&tab=MatchPlayerReportIncoming"
|
||||
resp = await steam.request(url, method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
if "no_personal_data_stored_message" in html:
|
||||
return None
|
||||
if "personaldata_elements_container" in html:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for row in soup.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if cells:
|
||||
last_cell = cells[-1].get_text(strip=True)
|
||||
if last_cell.isdigit():
|
||||
return last_cell
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] behavior check error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _check_licenses(steam) -> str:
|
||||
"""Get list of licenses (games) from Steam account."""
|
||||
try:
|
||||
resp = await steam.request("https://store.steampowered.com/account/licenses/", method="GET")
|
||||
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
table = soup.find("table", class_="account_table")
|
||||
if not table:
|
||||
return ""
|
||||
licenses = []
|
||||
rows = table.find_all("tr")
|
||||
for row in rows[1:]:
|
||||
cells = row.find_all("td")
|
||||
if len(cells) >= 2:
|
||||
game_cell = cells[1]
|
||||
remove_div = game_cell.find("div", class_="free_license_remove_link")
|
||||
if remove_div:
|
||||
remove_div.decompose()
|
||||
name = game_cell.get_text(strip=True)
|
||||
if name:
|
||||
licenses.append(name)
|
||||
return ", ".join(licenses)
|
||||
except Exception as e:
|
||||
logger.debug(f"[full_parse] licenses check error: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
async def full_parse_logpass_account(account: dict, params: dict, *, task_id: str) -> None:
|
||||
"""Full parse: login, ban, profile, prime, trophy, behavior, licenses."""
|
||||
try:
|
||||
from pysteamauth.auth import Steam
|
||||
except ImportError:
|
||||
raise RuntimeError("pysteamauth not installed")
|
||||
|
||||
acc_id = account["id"]
|
||||
proxy = await _resolve_proxy(account)
|
||||
request_strategy = None
|
||||
if proxy:
|
||||
connector = proxy_manager.get_connector(proxy)
|
||||
request_strategy = ProxyRequestStrategy(connector)
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
password=account["password"],
|
||||
request_strategy=request_strategy,
|
||||
)
|
||||
|
||||
try:
|
||||
await task_manager.set_step(task_id, 1, 6, "Авторизация", acc_id)
|
||||
await steam.login_to_steam()
|
||||
|
||||
# Save session cookies
|
||||
try:
|
||||
session_cookies_json = extract_session_cookies(steam)
|
||||
if session_cookies_json:
|
||||
from app.database import get_db as _get_db
|
||||
_db = await _get_db()
|
||||
await _db.execute(
|
||||
"UPDATE logpass_accounts SET session_cookies = ? WHERE id = ?",
|
||||
(session_cookies_json, acc_id),
|
||||
)
|
||||
await _db.commit()
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"[full_parse] {account['login']}: failed to save cookies — {cookie_err}")
|
||||
|
||||
steam_id = str(steam.steamid)
|
||||
|
||||
await task_manager.set_step(task_id, 2, 6, "Проверка бана", acc_id)
|
||||
ban_status = await check_ban(steam)
|
||||
|
||||
nickname = avatar_url = last_online = None
|
||||
steam_level = None
|
||||
if steam_id and steam_id != "0":
|
||||
await task_manager.set_step(task_id, 3, 6, "Профиль", acc_id)
|
||||
profile = await fetch_profile(steam_id, steam=steam)
|
||||
if profile:
|
||||
nickname = profile.get("nickname")
|
||||
steam_level = profile.get("steam_level")
|
||||
avatar_url = profile.get("avatar_url")
|
||||
last_online = profile.get("last_online")
|
||||
|
||||
prime = "Disabled"
|
||||
trophy = None
|
||||
behavior = None
|
||||
if steam_id and steam_id != "0":
|
||||
await task_manager.set_step(task_id, 4, 6, "Prime / Trophy", acc_id)
|
||||
prime = await _check_cs2_prime(steam, steam_id)
|
||||
trophy = await _check_dota_trophy(steam, steam_id)
|
||||
behavior = await _check_dota_behavior(steam, steam_id)
|
||||
|
||||
await task_manager.set_step(task_id, 5, 6, "Лицензии", acc_id)
|
||||
license_str = await _check_licenses(steam)
|
||||
|
||||
await task_manager.set_step(task_id, 6, 6, "Сохранение", acc_id)
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""UPDATE logpass_accounts
|
||||
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
|
||||
ban_status = ?, prime = ?, trophy = ?, behavior = ?, license = ?,
|
||||
status = 'valid', updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(steam_id, nickname, steam_level, avatar_url, last_online,
|
||||
ban_status, prime, trophy, behavior, license_str, acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.success(f"[full_parse] {account['login']} → valid, ban={ban_status}, prime={prime}, trophy={trophy}, behavior={behavior}, licenses={len(license_str.split(', ')) if license_str else 0}")
|
||||
|
||||
except Exception as exc:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE logpass_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
|
||||
(acc_id,),
|
||||
)
|
||||
await db.commit()
|
||||
logger.error(f"[full_parse] {account['login']} → {exc}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import create_steam_session, close_steam, extract_session_cookies
|
||||
from app.services.steam_wizard import AJAX_HEADERS, run_common_wizard
|
||||
|
||||
|
||||
def _get_fresh_sessionid(steam) -> str:
|
||||
"""Get sessionid from the live aiohttp cookie jar."""
|
||||
return steam._requests.cookies("help.steampowered.com").get("sessionid", "")
|
||||
|
||||
|
||||
async def change_email(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change account email via Steam recovery wizard + interactive prompts."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
login = account["login"]
|
||||
new_email = params.get("new_email", "")
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[change_email] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
try:
|
||||
password = account["password"]
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[change_email] {login}: auth ok, starting email change wizard...")
|
||||
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/wizard/HelpChangeEmail?redir=store/account/",
|
||||
login=login,
|
||||
password=password,
|
||||
)
|
||||
logger.info(f"[change_email] {login}: wizard done")
|
||||
|
||||
if not new_email:
|
||||
new_email = await task_manager.prompt_user(task_id, f"Введите новый email для {login}", login=login)
|
||||
if not new_email:
|
||||
raise ValueError("new_email is required")
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
|
||||
logger.info(f"[change_email] {login}: requesting email change -> {new_email}...")
|
||||
|
||||
for attempt in range(2):
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangeEmail/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"email": new_email,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.debug(f"[change_email] {login}: change request attempt {attempt+1} response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
error_code = data.get("success", 0)
|
||||
if error_code == 24:
|
||||
raise PermissionError(f"Insufficient privileges for email change: {data['errorMsg']}")
|
||||
raise RuntimeError(f"Email change request failed: {data['errorMsg']}")
|
||||
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await task_manager.set_step(task_id, 4, 5, "Код подтверждения", acc_id=account["id"])
|
||||
email_code = await task_manager.prompt_user(task_id, f"Введите код подтверждения с email ({new_email})", login=login)
|
||||
if not email_code:
|
||||
raise ValueError("email_code is required")
|
||||
email_code = email_code.strip()
|
||||
|
||||
logger.info(f"[change_email] {login}: confirming email change...")
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
confirm_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"email": new_email,
|
||||
"email_change_code": email_code,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
|
||||
logger.debug(f"[change_email] {login}: confirm response: success={confirm_data.get('success')}")
|
||||
|
||||
error_code = confirm_data.get("success", 0)
|
||||
if confirm_data.get("errorMsg") and error_code != 29:
|
||||
raise RuntimeError(f"Email confirmation failed: {confirm_data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET email = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_email, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[change_email] {login}: email changed successfully -> {new_email}")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def validate_account(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Validate account credentials by attempting login. Fetches profile and ban status on success."""
|
||||
from app.database import get_db
|
||||
from app.services.steam_auth import close_steam
|
||||
from app.services.steam_profile import fetch_profile
|
||||
from app.services.steam_ban import check_ban
|
||||
from app.core.task_manager import task_manager
|
||||
from app.config import read_validation_settings
|
||||
|
||||
steam = None
|
||||
error_msg = None
|
||||
ban_status = ""
|
||||
|
||||
val_settings = read_validation_settings()
|
||||
|
||||
login = account["login"]
|
||||
profile = None
|
||||
try:
|
||||
await task_manager.set_step(task_id, 1, 4, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[validate] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
status = "valid"
|
||||
logger.info(f"[validate] {login}: auth ok")
|
||||
|
||||
if status == "valid" and steam is not None:
|
||||
if val_settings.get("check_ban"):
|
||||
await task_manager.set_step(task_id, 2, 4, "Проверка бана", acc_id=account["id"])
|
||||
logger.info(f"[validate] {login}: checking ban status...")
|
||||
ban_status = await check_ban(steam)
|
||||
logger.info(f"[validate] {login}: ban = {ban_status or 'none'}")
|
||||
if account.get("steam_id") and val_settings.get("fetch_profile"):
|
||||
await task_manager.set_step(task_id, 3, 4, "Профиль", acc_id=account["id"])
|
||||
logger.info(f"[validate] {login}: fetching profile (id={account['steam_id']})...")
|
||||
profile = await fetch_profile(account["steam_id"], steam=steam)
|
||||
if profile:
|
||||
logger.info(f"[validate] {login}: profile fetched — {profile.get('nickname')}, lvl={profile.get('steam_level')}")
|
||||
else:
|
||||
logger.warning(f"[validate] {login}: failed to fetch profile")
|
||||
|
||||
await task_manager.set_step(task_id, 4, 4, "Сохранение", acc_id=account["id"])
|
||||
if steam is not None and status == "valid":
|
||||
try:
|
||||
session_cookies = extract_session_cookies(steam)
|
||||
if session_cookies:
|
||||
_db = await get_db()
|
||||
await _db.execute(
|
||||
"UPDATE accounts SET session_cookies = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(session_cookies, account["id"]),
|
||||
)
|
||||
await _db.commit()
|
||||
logger.debug(f"[validate] {login}: saved session cookies")
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"[validate] {login}: failed to save cookies — {cookie_err}")
|
||||
|
||||
except Exception as exc:
|
||||
status = "invalid"
|
||||
error_msg = str(exc)
|
||||
logger.warning(f"[validate] {login}: error — {error_msg}")
|
||||
finally:
|
||||
if steam is not None:
|
||||
await close_steam(steam)
|
||||
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET status = ?, ban_status = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(status, ban_status or None, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if profile:
|
||||
sets = []
|
||||
vals = []
|
||||
for col, key in [("nickname", "nickname"), ("avatar_url", "avatar_url"), ("steam_level", "steam_level"), ("last_online", "last_online")]:
|
||||
if profile.get(key) is not None:
|
||||
sets.append(f"{col} = ?")
|
||||
vals.append(profile[key])
|
||||
if sets:
|
||||
sets.append("updated_at = datetime('now')")
|
||||
vals.append(account["id"])
|
||||
await db.execute(f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?", vals)
|
||||
await db.commit()
|
||||
logger.info(f"[validate] {login}: profile saved — {profile.get('nickname')}")
|
||||
|
||||
if error_msg:
|
||||
raise Exception(f"{error_msg}")
|
||||
else:
|
||||
ban_info = f" | ban={ban_status}" if ban_status else ""
|
||||
logger.info(f"[validate] {login}: result — {status}{ban_info}")
|
||||
@@ -0,0 +1,83 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def generate_2fa_code(shared_secret: str) -> str:
|
||||
"""Generate a Steam Guard 2FA code from shared_secret."""
|
||||
timestamp = int(time.time()) // 30
|
||||
msg = struct.pack(">Q", timestamp)
|
||||
key = base64.b64decode(shared_secret)
|
||||
auth = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
|
||||
offset = auth[19] & 0xF
|
||||
code = struct.unpack(">I", auth[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
|
||||
chars = "23456789BCDFGHJKMNPQRTVWXY"
|
||||
result = []
|
||||
for _ in range(5):
|
||||
result.append(chars[code % len(chars)])
|
||||
code //= len(chars)
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
async def remove_guard(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Remove Steam Guard 2FA via revocation code from mafile."""
|
||||
from app.services.steam_auth import create_steam_session, close_steam, read_mafile_data
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
mafile = read_mafile_data(account)
|
||||
revocation_code = (mafile or {}).get("revocation_code", "") if mafile else ""
|
||||
if not revocation_code:
|
||||
raise ValueError("revocation_code not found in mafile")
|
||||
|
||||
login = account["login"]
|
||||
await task_manager.set_step(task_id, 1, 3, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[remove_guard] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
logger.info(f"[remove_guard] {login}: auth ok")
|
||||
try:
|
||||
await task_manager.set_step(task_id, 2, 3, "Удаление Guard", acc_id=account["id"])
|
||||
logger.info(f"[remove_guard] {login}: sending Guard removal request...")
|
||||
sessionid = await steam.sessionid("store.steampowered.com")
|
||||
|
||||
await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/remove",
|
||||
method="GET",
|
||||
params={"step": "promptdevice"},
|
||||
)
|
||||
await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/remove",
|
||||
method="GET",
|
||||
params={"step": "promptrcode"},
|
||||
)
|
||||
await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/manage_action",
|
||||
method="POST",
|
||||
data={"action": "removercode", "sessionid": sessionid},
|
||||
)
|
||||
response = await steam.request(
|
||||
url="https://store.steampowered.com/twofactor/manage_remove_revocation_code",
|
||||
method="POST",
|
||||
data={"revocation_code": revocation_code, "sessionid": sessionid},
|
||||
)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 3, "Готово", acc_id=account["id"])
|
||||
logger.success(f"[remove_guard] {login}: Guard removed")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def generate_2fa(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Generate and log a 2FA code for an account."""
|
||||
shared_secret = account.get("shared_secret", "")
|
||||
if not shared_secret:
|
||||
raise ValueError("No shared_secret for this account")
|
||||
|
||||
code = generate_2fa_code(shared_secret)
|
||||
logger.debug(f"[generate_2fa] {account['login']}: code = {code}")
|
||||
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.core.crypto import encrypt_password
|
||||
from app.services.steam_auth import create_steam_session, close_steam, read_mafile_data
|
||||
from app.services.steam_wizard import (
|
||||
AJAX_HEADERS,
|
||||
get_rsa_key,
|
||||
run_common_wizard,
|
||||
)
|
||||
|
||||
|
||||
async def change_password(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change account password via Steam recovery wizard."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
new_password = params.get("new_password", "")
|
||||
if not new_password:
|
||||
raise ValueError("new_password is required")
|
||||
|
||||
login = account["login"]
|
||||
|
||||
mafile_data = read_mafile_data(account)
|
||||
if not mafile_data:
|
||||
raise ValueError(f"No mafile found for {login} — cannot change password without 2FA")
|
||||
|
||||
identity_secret = mafile_data.get("identity_secret", "")
|
||||
device_id = mafile_data.get("device_id")
|
||||
|
||||
if not identity_secret:
|
||||
raise ValueError(f"No identity_secret in mafile for {login} — mobile confirmation impossible")
|
||||
if not device_id:
|
||||
raise ValueError(f"No device_id in mafile for {login} — mobile confirmation impossible")
|
||||
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
logger.info(f"[change_password] {login}: auth ok")
|
||||
|
||||
try:
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: starting password change wizard...")
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/wizard/HelpChangePassword?redir=store/account/",
|
||||
login=login,
|
||||
password=account["password"],
|
||||
)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "RSA ключ", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: wizard done, getting RSA key...")
|
||||
mod, exp, timestamp = await get_rsa_key(steam, login)
|
||||
|
||||
logger.info(f"[change_password] {login}: checking new password availability...")
|
||||
sessionid = await steam.sessionid("help.steampowered.com")
|
||||
check_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxCheckPasswordAvailable/",
|
||||
method="POST",
|
||||
data={
|
||||
"sessionid": sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"password": new_password,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
check_data = json.loads(check_text) if isinstance(check_text, str) else check_text
|
||||
if not check_data.get("available"):
|
||||
raise ValueError("New password is not available (too weak or reused)")
|
||||
|
||||
encrypted_new = encrypt_password(new_password, mod, exp)
|
||||
await task_manager.set_step(task_id, 4, 5, "Отправка запроса", acc_id=account["id"])
|
||||
logger.info(f"[change_password] {login}: password available, sending change request...")
|
||||
result_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePassword/",
|
||||
method="POST",
|
||||
data={
|
||||
"sessionid": sessionid,
|
||||
"wizard_ajax": 1,
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"password": encrypted_new,
|
||||
"rsatimestamp": timestamp,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
|
||||
result_data = json.loads(result_text) if isinstance(result_text, str) else result_text
|
||||
logger.debug(f"[change_password] {login}: change response: success={result_data.get('success')}, hasHash={bool(result_data.get('hash'))}")
|
||||
|
||||
if result_data.get("errorMsg"):
|
||||
raise RuntimeError(f"Password change rejected by Steam: {result_data['errorMsg']}")
|
||||
if not result_data.get("hash"):
|
||||
raise RuntimeError(f"Password change failed — no confirmation hash from Steam: {result_data}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET password = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_password, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[change_password] {login}: password changed")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
def _generate_random_password(length: int = 24) -> str:
|
||||
alphabet = string.ascii_letters + string.digits + "!"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
async def random_password(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change account password to a randomly generated one."""
|
||||
new_password = _generate_random_password()
|
||||
params["new_password"] = new_password
|
||||
await change_password(account, params)
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import create_steam_session, close_steam
|
||||
from app.services.steam_wizard import AJAX_HEADERS, run_common_wizard
|
||||
|
||||
_PHONE_RE = re.compile(r"^\+\d{7,15}$")
|
||||
|
||||
|
||||
def _get_fresh_sessionid(steam) -> str:
|
||||
"""Get sessionid from the live aiohttp cookie jar (not from stale storage)."""
|
||||
return steam._requests.cookies("help.steampowered.com").get("sessionid", "")
|
||||
|
||||
|
||||
def _validate_phone(phone: str) -> str:
|
||||
"""Strip spaces/dashes and verify E.164-like format."""
|
||||
cleaned = re.sub(r"[\s\-()]", "", phone)
|
||||
if cleaned and not cleaned.startswith("+"):
|
||||
cleaned = "+" + cleaned
|
||||
if not _PHONE_RE.match(cleaned):
|
||||
raise ValueError(
|
||||
f"Неверный формат номера: '{phone}'. "
|
||||
"Ожидается международный формат, например +79123456789"
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
async def change_phone(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Change phone number on a Steam account via recovery wizard + interactive prompts."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
login = account["login"]
|
||||
new_phone = params.get("new_phone", "")
|
||||
if new_phone:
|
||||
new_phone = _validate_phone(new_phone)
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[change_phone] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
try:
|
||||
password = account["password"]
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[change_phone] {login}: auth ok, starting phone change wizard...")
|
||||
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/en/wizard/HelpRemovePhoneNumber?redir=store/account",
|
||||
login=login,
|
||||
password=password,
|
||||
)
|
||||
logger.info(f"[change_phone] {login}: wizard done")
|
||||
|
||||
if not new_phone:
|
||||
new_phone = await task_manager.prompt_user(task_id, f"Введите новый номер телефона для {login}", login=login)
|
||||
if not new_phone:
|
||||
raise ValueError("new_phone is required")
|
||||
new_phone = _validate_phone(new_phone)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
|
||||
logger.info(f"[change_phone] {login}: requesting phone change -> {new_phone}...")
|
||||
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.info(f"[change_phone] {login}: change request response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
err = data["errorMsg"]
|
||||
hint = ""
|
||||
if "112" in err:
|
||||
hint = " (номер уже привязан к другому аккаунту, виртуальный/VoIP, или не поддерживается Steam)"
|
||||
raise RuntimeError(f"Phone change request failed: {err}{hint}")
|
||||
|
||||
await task_manager.set_step(task_id, 4, 5, "SMS код", acc_id=account["id"])
|
||||
sms_code = await task_manager.prompt_user(task_id, f"Введите SMS код с номера {new_phone}", login=login)
|
||||
if not sms_code:
|
||||
raise ValueError("sms_code is required")
|
||||
sms_code = sms_code.strip()
|
||||
|
||||
logger.info(f"[change_phone] {login}: confirming with SMS code...")
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
confirm_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
"phone_change_code": sms_code,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
|
||||
logger.info(f"[change_phone] {login}: confirm response: {confirm_data}")
|
||||
if confirm_data.get("errorMsg"):
|
||||
raise RuntimeError(f"Phone confirmation failed: {confirm_data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET phone = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_phone, account["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[change_phone] {login}: phone changed -> {new_phone}")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
|
||||
|
||||
async def remove_phone(account: dict, params: dict, task_id: str = "") -> None:
|
||||
"""Remove phone by changing it to a disposable number + SMS confirmation."""
|
||||
from app.core.task_manager import task_manager
|
||||
|
||||
login = account["login"]
|
||||
new_phone = params.get("new_phone", "")
|
||||
if new_phone:
|
||||
new_phone = _validate_phone(new_phone)
|
||||
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
|
||||
logger.info(f"[remove_phone] {login}: logging in to Steam...")
|
||||
steam = await create_steam_session(account)
|
||||
try:
|
||||
password = account["password"]
|
||||
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
|
||||
logger.info(f"[remove_phone] {login}: starting phone removal wizard...")
|
||||
|
||||
wizard = await run_common_wizard(
|
||||
steam,
|
||||
entry_url="https://help.steampowered.com/en/wizard/HelpRemovePhoneNumber?redir=store/account",
|
||||
login=login,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if not new_phone:
|
||||
new_phone = await task_manager.prompt_user(task_id, "Введите новый номер телефона (на который переставится старый)", login=login)
|
||||
if not new_phone:
|
||||
raise ValueError("new_phone is required for phone removal")
|
||||
new_phone = _validate_phone(new_phone)
|
||||
|
||||
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
|
||||
logger.info(f"[remove_phone] {login}: requesting phone change -> {new_phone}...")
|
||||
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
data = json.loads(text) if isinstance(text, str) else text
|
||||
logger.info(f"[remove_phone] {login}: change request response: {data}")
|
||||
if data.get("errorMsg"):
|
||||
raise RuntimeError(f"Phone change request failed: {data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 4, 5, "SMS код", acc_id=account["id"])
|
||||
sms_code = await task_manager.prompt_user(task_id, f"Введите SMS код с номера {new_phone}", login=login)
|
||||
if not sms_code:
|
||||
raise ValueError("sms_code is required")
|
||||
sms_code = sms_code.strip()
|
||||
|
||||
logger.info(f"[remove_phone] {login}: confirming with SMS code...")
|
||||
fresh_sid = _get_fresh_sessionid(steam)
|
||||
confirm_text = await steam.request(
|
||||
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangePhone/",
|
||||
method="POST",
|
||||
data={
|
||||
"s": wizard.s,
|
||||
"account": wizard.account,
|
||||
"sessionid": fresh_sid,
|
||||
"wizard_ajax": 1,
|
||||
"gamepad": 0,
|
||||
"phone_number": new_phone,
|
||||
"phone_change_code": sms_code,
|
||||
},
|
||||
headers=AJAX_HEADERS,
|
||||
)
|
||||
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
|
||||
logger.info(f"[remove_phone] {login}: confirm response: {confirm_data}")
|
||||
if confirm_data.get("errorMsg"):
|
||||
raise RuntimeError(f"Phone confirmation failed: {confirm_data['errorMsg']}")
|
||||
|
||||
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE accounts SET phone = NULL, updated_at = datetime('now') WHERE id = ?",
|
||||
(account["id"],),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.success(f"[remove_phone] {login}: phone removed (changed to {new_phone})")
|
||||
finally:
|
||||
await close_steam(steam)
|
||||
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
|
||||
async def fetch_profile(steam_id: str, steam=None) -> dict | None:
|
||||
"""Fetch persona name and avatar URL from Steam community profile page.
|
||||
|
||||
If *steam* session is provided, uses authenticated cookies.
|
||||
Returns {"nickname": str, "avatar_url": str, "steam_level": int|None} or None on failure.
|
||||
"""
|
||||
url = f"https://steamcommunity.com/profiles/{steam_id}/"
|
||||
try:
|
||||
if steam is not None:
|
||||
from app.services.steam_auth import raw_request
|
||||
resp = await raw_request(steam, url, method="GET", allow_redirects=True)
|
||||
if resp.status != 200:
|
||||
logger.warning(f"Profile fetch for {steam_id}: HTTP {resp.status}")
|
||||
return None
|
||||
html = await resp.text()
|
||||
else:
|
||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||
async with aiohttp.ClientSession(headers=headers) as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(f"Profile fetch for {steam_id}: HTTP {resp.status}")
|
||||
return None
|
||||
html = await resp.text()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Profile fetch error for {steam_id}: {exc}")
|
||||
return None
|
||||
|
||||
nickname = _parse_persona_name(html)
|
||||
avatar_url = _parse_avatar(html)
|
||||
steam_level = _parse_level(html)
|
||||
last_online = _parse_last_online(html)
|
||||
|
||||
if not nickname and not avatar_url:
|
||||
return None
|
||||
|
||||
return {"nickname": nickname, "avatar_url": avatar_url, "steam_level": steam_level, "last_online": last_online}
|
||||
|
||||
|
||||
def _parse_persona_name(html: str) -> str | None:
|
||||
# g_rgProfileData = {"personaname":"jeffreyoelze1999",...}
|
||||
m = re.search(r'"personaname"\s*:\s*"([^"]*)"', html)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# Fallback: <span class="actual_persona_name">...</span>
|
||||
m = re.search(r'<span class="actual_persona_name">([^<]+)</span>', html)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _parse_avatar(html: str) -> str | None:
|
||||
# 1) g_rgProfileData JSON: {"avatarfull":"https://avatars.fastly.steamstatic.com/..."}
|
||||
m = re.search(r'"avatarfull"\s*:\s*"(https://[^"]+)"', html)
|
||||
if m:
|
||||
return m.group(1).replace("_full.jpg", "_medium.jpg")
|
||||
# 2) <meta property="og:image" content="https://avatars...">
|
||||
m = re.search(r'<meta\s+property="og:image"\s+content="(https://avatars[^"]+)"', html)
|
||||
if m:
|
||||
return m.group(1).replace("_full.jpg", "_medium.jpg")
|
||||
# 3) playerAvatarAutoSizeInner img
|
||||
m = re.search(r'playerAvatarAutoSizeInner[\s\S]{0,200}?<img[^>]+srcset="(https://[^"]+)"', html)
|
||||
if m:
|
||||
return m.group(1).replace("_full.jpg", "_medium.jpg")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_level(html: str) -> int | None:
|
||||
# <span class="friendPlayerLevelNum">100</span>
|
||||
m = re.search(r'<span\s+class="friendPlayerLevelNum">(\d+)</span>', html)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _parse_last_online(html: str) -> str | None:
|
||||
# "Last Online X days ago" → "Xd", "X hrs ago" → "Xh", "X min ago" → "Xm"
|
||||
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+day', html, re.IGNORECASE)
|
||||
if m:
|
||||
return f"{m.group(1)}d"
|
||||
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+hr', html, re.IGNORECASE)
|
||||
if m:
|
||||
return f"{m.group(1)}h"
|
||||
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+min', html, re.IGNORECASE)
|
||||
if m:
|
||||
return f"{m.group(1)}m"
|
||||
if re.search(r'Currently\s+(In-Game|Online)', html, re.IGNORECASE):
|
||||
return "online"
|
||||
return None
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Validation service for token accounts (refresh token → cookies)."""
|
||||
|
||||
import base64
|
||||
import json as _json
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
from loguru import logger
|
||||
|
||||
from app.services.steam_auth import _resolve_proxy
|
||||
from app.core.proxy_manager import proxy_manager
|
||||
from app.core.task_manager import task_manager
|
||||
from pysteamauth.auth.schemas import FinalizeLoginStatus
|
||||
|
||||
|
||||
def _decode_jwt_payload(token: str) -> dict:
|
||||
"""Decode the payload section of a JWT without verifying the signature."""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("Token must have 3 parts (header.payload.signature)")
|
||||
payload_b64 = parts[1]
|
||||
# Pad base64 if needed
|
||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
return _json.loads(payload_bytes)
|
||||
|
||||
|
||||
async def check_token_account(account: dict, params: dict, *, task_id: str) -> None:
|
||||
"""Convert refresh_token → web cookies, fetch profile via cookies, update DB."""
|
||||
acc_id = account["id"]
|
||||
token = account["token"]
|
||||
|
||||
# Step 1: Decode JWT to get steam_id
|
||||
await task_manager.set_step(task_id, 1, 4, "Декодирование токена", acc_id)
|
||||
try:
|
||||
jwt_payload = _decode_jwt_payload(token)
|
||||
except Exception as exc:
|
||||
logger.error(f"[token_checker] {account.get('login', acc_id)}: bad JWT — {exc}")
|
||||
await _mark_invalid(acc_id)
|
||||
raise
|
||||
|
||||
steam_id = jwt_payload.get("sub")
|
||||
|
||||
# Step 2: GET steamcommunity.com for sessionid cookie
|
||||
await task_manager.set_step(task_id, 2, 4, "Получение sessionid", acc_id)
|
||||
proxy = await _resolve_proxy(account)
|
||||
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
|
||||
jar = aiohttp.CookieJar(unsafe=True)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Origin": "https://steamcommunity.com",
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
connector=connector, cookie_jar=jar, headers=headers, timeout=timeout,
|
||||
) as session:
|
||||
async with session.get("https://steamcommunity.com") as resp:
|
||||
await resp.read()
|
||||
|
||||
sessionid = None
|
||||
for cookie in jar:
|
||||
if cookie.key == "sessionid":
|
||||
sessionid = cookie.value
|
||||
break
|
||||
if not sessionid:
|
||||
raise RuntimeError("Failed to acquire sessionid cookie")
|
||||
|
||||
# Step 3: POST /jwt/finalizelogin with refresh_token as nonce
|
||||
await task_manager.set_step(task_id, 3, 4, "Получение куки", acc_id)
|
||||
form = FormData(fields=[
|
||||
("nonce", token),
|
||||
("sessionid", sessionid),
|
||||
("redir", "https://steamcommunity.com/login/home/?goto="),
|
||||
])
|
||||
async with session.post(
|
||||
"https://login.steampowered.com/jwt/finalizelogin",
|
||||
data=form,
|
||||
) as resp:
|
||||
resp_text = await resp.text()
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"finalizelogin HTTP {resp.status}: {resp_text[:200]}")
|
||||
|
||||
resp_json = _json.loads(resp_text)
|
||||
if not resp_json.get("steamID"):
|
||||
error_msg = resp_json.get("message", resp_text[:200])
|
||||
raise RuntimeError(f"finalizelogin failed: {error_msg}")
|
||||
|
||||
finalize = FinalizeLoginStatus.model_validate(resp_json)
|
||||
steam_id = finalize.steamID or steam_id
|
||||
|
||||
# Post to each transfer_info URL to set domain cookies
|
||||
for ti in finalize.transfer_info:
|
||||
form = FormData(fields=[
|
||||
("nonce", ti.params.nonce),
|
||||
("auth", ti.params.auth),
|
||||
("steamID", str(steam_id)),
|
||||
])
|
||||
try:
|
||||
async with session.post(ti.url, data=form) as _resp:
|
||||
await _resp.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Visit extra domains to collect all cookies
|
||||
for url in ("https://store.steampowered.com", "https://help.steampowered.com"):
|
||||
try:
|
||||
async with session.get(url) as _resp:
|
||||
await _resp.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Collect cookies from jar
|
||||
all_cookies = []
|
||||
for c in jar:
|
||||
all_cookies.append({
|
||||
"name": c.key,
|
||||
"value": c.value,
|
||||
"domain": c.get("domain", ""),
|
||||
"path": c.get("path", "/"),
|
||||
"secure": "secure" in str(c).lower(),
|
||||
"httpOnly": "httponly" in str(c).lower(),
|
||||
})
|
||||
session_cookies_json = _json.dumps(all_cookies)
|
||||
logger.debug(f"[token_checker] {account.get('login', acc_id)}: got {len(all_cookies)} cookies")
|
||||
|
||||
# Step 4: Fetch profile using authenticated session (cookies set)
|
||||
await task_manager.set_step(task_id, 4, 4, "Получение профиля", acc_id)
|
||||
nickname = None
|
||||
steam_level = None
|
||||
avatar_url = None
|
||||
last_online = None
|
||||
if steam_id and steam_id != "0":
|
||||
profile_url = f"https://steamcommunity.com/profiles/{steam_id}/"
|
||||
try:
|
||||
async with session.get(profile_url) as resp:
|
||||
if resp.status == 200:
|
||||
html = await resp.text()
|
||||
from app.services.steam_profile import (
|
||||
_parse_persona_name, _parse_avatar, _parse_level, _parse_last_online,
|
||||
)
|
||||
nickname = _parse_persona_name(html)
|
||||
avatar_url = _parse_avatar(html)
|
||||
steam_level = _parse_level(html)
|
||||
last_online = _parse_last_online(html)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[token_checker] profile fetch error: {exc}")
|
||||
|
||||
# Update DB
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""UPDATE token_accounts
|
||||
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
|
||||
session_cookies = ?, status = 'valid', updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(steam_id, nickname, steam_level, avatar_url, last_online, session_cookies_json, acc_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.success(f"[token_checker] {account.get('login', acc_id)} → valid, steam_id={steam_id}")
|
||||
|
||||
except Exception as exc:
|
||||
await _mark_invalid(acc_id)
|
||||
logger.error(f"[token_checker] {account.get('login', acc_id)} → {exc}")
|
||||
raise
|
||||
|
||||
|
||||
async def _mark_invalid(acc_id: int) -> None:
|
||||
from app.database import get_db
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE token_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
|
||||
(acc_id,),
|
||||
)
|
||||
await db.commit()
|
||||
@@ -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