mirror of
https://github.com/daimyomizukagebay61/SteamPanel.git
synced 2026-07-25 11:44:28 +00:00
391 lines
14 KiB
Python
391 lines
14 KiB
Python
"""Steam mobile confirmations — fetch, accept, deny trade/market confirmations."""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import struct
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import aiohttp
|
|
from yarl import URL
|
|
from loguru import logger
|
|
|
|
|
|
COMMUNITY_BASE = "https://steamcommunity.com"
|
|
MOBILE_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 9; Valve Steam App Version/3)"
|
|
|
|
CONF_TYPES = {
|
|
0: "Invalid",
|
|
1: "Test",
|
|
2: "Trade",
|
|
3: "MarketListing",
|
|
4: "FeatureOptOut",
|
|
5: "PhoneNumberChange",
|
|
6: "AccountRecovery",
|
|
}
|
|
|
|
|
|
def _decode_jwt_payload(token: str) -> dict:
|
|
parts = token.split(".")
|
|
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
|
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
|
|
|
|
def _is_token_expired(token: str, margin: int = 60) -> bool:
|
|
"""Check if a JWT access token is expired (with safety margin in seconds)."""
|
|
try:
|
|
payload = _decode_jwt_payload(token)
|
|
return payload.get("exp", 0) < int(time.time()) + margin
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
async def _resolve_proxy(account: dict):
|
|
"""Resolve proxy for an account (same logic as steam_auth)."""
|
|
from app.core.proxy_manager import proxy_manager
|
|
|
|
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 _refresh_access_token(
|
|
refresh_token: str, steam_id: str, connector: aiohttp.BaseConnector | None = None,
|
|
) -> str | None:
|
|
"""Use RefreshToken to get a fresh AccessToken via Steam protobuf API."""
|
|
try:
|
|
from pysteamauth.pb2.steammessages_auth.steamclient_pb2 import (
|
|
CAuthentication_AccessToken_GenerateForApp_Request,
|
|
CAuthentication_AccessToken_GenerateForApp_Response,
|
|
)
|
|
except ImportError:
|
|
logger.warning("[confirmations] protobuf classes not available for token refresh")
|
|
return None
|
|
|
|
msg = CAuthentication_AccessToken_GenerateForApp_Request()
|
|
msg.refresh_token = refresh_token
|
|
msg.steamid = int(steam_id)
|
|
encoded = base64.b64encode(msg.SerializeToString()).decode()
|
|
|
|
kw: dict = {"headers": {"User-Agent": MOBILE_USER_AGENT}, "timeout": aiohttp.ClientTimeout(total=15)}
|
|
if connector:
|
|
kw["connector"] = connector
|
|
|
|
try:
|
|
async with aiohttp.ClientSession(**kw) as http:
|
|
async with http.post(
|
|
"https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1/",
|
|
data={"input_protobuf_encoded": encoded},
|
|
) as resp:
|
|
eresult = resp.headers.get("x-eresult", "?")
|
|
raw = await resp.read()
|
|
if raw:
|
|
resp_msg = CAuthentication_AccessToken_GenerateForApp_Response()
|
|
resp_msg.ParseFromString(raw)
|
|
if resp_msg.access_token:
|
|
logger.info(f"[confirmations] Refreshed AccessToken for SteamID {steam_id}")
|
|
return resp_msg.access_token
|
|
logger.warning(f"[confirmations] Token refresh failed for SteamID {steam_id}: eresult={eresult}")
|
|
except Exception as e:
|
|
logger.error(f"[confirmations] Token refresh error for SteamID {steam_id}: {e}")
|
|
return None
|
|
|
|
|
|
def _has_mobile_audience(token: str) -> bool:
|
|
"""Check if JWT token has 'mobile' in its audience claim."""
|
|
try:
|
|
payload = _decode_jwt_payload(token)
|
|
aud = payload.get("aud", [])
|
|
return "mobile" in aud
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def _ensure_valid_access_token(account: dict, mafile: dict) -> str:
|
|
"""Return a valid AccessToken, refreshing if expired. Updates mafile on disk."""
|
|
session = mafile.get("Session", {})
|
|
access_token = session.get("AccessToken", "")
|
|
refresh_token = session.get("RefreshToken", "")
|
|
steam_id = str(session.get("SteamID", ""))
|
|
|
|
needs_refresh = (
|
|
not access_token
|
|
or _is_token_expired(access_token)
|
|
or not _has_mobile_audience(access_token)
|
|
)
|
|
|
|
if not needs_refresh:
|
|
return access_token
|
|
|
|
if not refresh_token:
|
|
raise ValueError("AccessToken expired and no RefreshToken available")
|
|
|
|
if _is_token_expired(refresh_token, margin=0):
|
|
raise ValueError("Both AccessToken and RefreshToken are expired — re-authentication required")
|
|
|
|
from app.core.proxy_manager import proxy_manager
|
|
proxy = await _resolve_proxy(account)
|
|
connector = proxy_manager.get_connector(proxy) if proxy else None
|
|
|
|
new_token = await _refresh_access_token(refresh_token, steam_id, connector)
|
|
if not new_token:
|
|
raise ValueError(
|
|
"Failed to refresh AccessToken — session may be revoked or proxy IP mismatch"
|
|
)
|
|
|
|
# Update mafile on disk
|
|
mafile["Session"]["AccessToken"] = new_token
|
|
mafile["Session"]["SteamLoginSecure"] = f"{steam_id}%7C%7C{new_token}"
|
|
mafile_path = account.get("mafile_path")
|
|
if mafile_path:
|
|
try:
|
|
Path(mafile_path).write_text(json.dumps(mafile, indent=2), encoding="utf-8")
|
|
logger.debug(f"[confirmations] Updated mafile on disk: {mafile_path}")
|
|
except OSError as e:
|
|
logger.warning(f"[confirmations] Could not update mafile: {e}")
|
|
|
|
return new_token
|
|
|
|
|
|
async def _get_steam_server_time() -> int:
|
|
"""Query Steam server time for HMAC generation."""
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
"https://api.steampowered.com/ITwoFactorService/QueryTime/v0001",
|
|
data={"steamid": "0"},
|
|
headers={"User-Agent": MOBILE_USER_AGENT},
|
|
timeout=aiohttp.ClientTimeout(total=10),
|
|
) as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
return int(data.get("response", {}).get("server_time", time.time()))
|
|
except Exception:
|
|
pass
|
|
return int(time.time())
|
|
|
|
|
|
def _generate_confirmation_hash(identity_secret: str, steam_time: int, tag: str) -> str:
|
|
"""HMAC-SHA1(identity_secret, time_bytes + tag_bytes) → Base64."""
|
|
key = base64.b64decode(identity_secret)
|
|
msg = struct.pack(">Q", steam_time)
|
|
tag_bytes = tag.encode("ascii")
|
|
msg += tag_bytes[:32]
|
|
mac = hmac.new(key, msg, hashlib.sha1).digest()
|
|
return base64.b64encode(mac).decode()
|
|
|
|
|
|
def _build_query_params(
|
|
device_id: str, steam_id: str, identity_secret: str, steam_time: int, tag: str
|
|
) -> dict:
|
|
"""Build confirmation query parameters."""
|
|
return {
|
|
"p": device_id,
|
|
"a": steam_id,
|
|
"k": _generate_confirmation_hash(identity_secret, steam_time, tag),
|
|
"t": str(steam_time),
|
|
"m": "react",
|
|
"tag": tag,
|
|
}
|
|
|
|
|
|
def _build_cookies(steam_id: str, access_token: str, session_id: str) -> dict:
|
|
"""Build mobile cookies for confirmation requests."""
|
|
steam_login_secure = f"{steam_id}%7C%7C{access_token}"
|
|
return {
|
|
"steamLoginSecure": steam_login_secure,
|
|
"sessionid": session_id,
|
|
"mobileClient": "android",
|
|
"mobileClientVersion": "777777 3.7.2",
|
|
"Steam_Language": "english",
|
|
}
|
|
|
|
|
|
async def fetch_confirmations(account: dict) -> list[dict]:
|
|
"""Fetch pending confirmations for an account.
|
|
|
|
Returns list of confirmation dicts with id, nonce, type, headline, summary etc.
|
|
"""
|
|
from app.services.steam_auth import read_mafile_data
|
|
from app.core.proxy_manager import proxy_manager
|
|
|
|
mafile = read_mafile_data(account)
|
|
if not mafile:
|
|
raise ValueError("No mafile data for this account")
|
|
|
|
identity_secret = mafile.get("identity_secret", "")
|
|
device_id = mafile.get("device_id", "")
|
|
session = mafile.get("Session", {})
|
|
steam_id = str(session.get("SteamID", ""))
|
|
session_id = session.get("SessionID", "")
|
|
|
|
if not identity_secret or not steam_id:
|
|
raise ValueError("Missing identity_secret or SteamID in mafile")
|
|
|
|
access_token = await _ensure_valid_access_token(account, mafile)
|
|
|
|
if not device_id:
|
|
device_id = f"android:{steam_id}"
|
|
|
|
if not session_id:
|
|
import secrets
|
|
session_id = secrets.token_hex(12)
|
|
|
|
steam_time = await _get_steam_server_time()
|
|
params = _build_query_params(device_id, steam_id, identity_secret, steam_time, "conf")
|
|
cookies = _build_cookies(steam_id, access_token, session_id)
|
|
|
|
url = f"{COMMUNITY_BASE}/mobileconf/getlist"
|
|
|
|
proxy = await _resolve_proxy(account)
|
|
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
|
|
|
|
async with aiohttp.ClientSession(
|
|
headers={"User-Agent": MOBILE_USER_AGENT},
|
|
cookie_jar=aiohttp.CookieJar(unsafe=True),
|
|
connector=connector,
|
|
) as http:
|
|
for domain in ["steamcommunity.com", "store.steampowered.com"]:
|
|
for k, v in cookies.items():
|
|
http.cookie_jar.update_cookies({k: v}, URL(f"https://{domain}"))
|
|
|
|
async with http.get(url, params=params) as resp:
|
|
if resp.status != 200:
|
|
raise ValueError(f"HTTP {resp.status} from Steam confirmations API")
|
|
data = await resp.json()
|
|
|
|
if not data.get("success"):
|
|
if data.get("needsauth"):
|
|
raise ValueError("Session expired — needs re-authentication")
|
|
detail = data.get("detail", "")
|
|
message = data.get("message", "")
|
|
login = account.get("login", "?")
|
|
logger.warning(f"[confirmations] {login}: {message or 'unknown error'}")
|
|
|
|
if "authenticator" in message.lower() or "authenticator" in detail.lower():
|
|
raise ValueError(
|
|
"Invalid authenticator — mafile secrets may be outdated. "
|
|
"Re-link Steam Guard or re-import a fresh mafile."
|
|
)
|
|
if detail:
|
|
raise ValueError(f"Steam: {detail}")
|
|
if message:
|
|
raise ValueError(f"Steam: {message}")
|
|
raise ValueError("Steam rejected the request — likely expired session or invalid mafile tokens")
|
|
|
|
confirmations = []
|
|
for conf in data.get("conf", []):
|
|
confirmations.append({
|
|
"id": str(conf.get("id", "")),
|
|
"nonce": str(conf.get("nonce", "")),
|
|
"type": conf.get("type", 0),
|
|
"type_name": CONF_TYPES.get(conf.get("type", 0), "Unknown"),
|
|
"creator_id": str(conf.get("creator_id", "")),
|
|
"headline": conf.get("headline", ""),
|
|
"summary": [conf.get("summary", "")] if isinstance(conf.get("summary"), str) else conf.get("summary", []),
|
|
"accept": conf.get("accept", "Accept"),
|
|
"cancel": conf.get("cancel", "Cancel"),
|
|
"icon": conf.get("icon", ""),
|
|
})
|
|
|
|
logger.info(f"[confirmations] {account.get('login', '?')}: fetched {len(confirmations)} confirmations")
|
|
return confirmations
|
|
|
|
|
|
async def respond_to_confirmation(
|
|
account: dict, confirmation_ids: list[str], confirmation_nonces: list[str], accept: bool
|
|
) -> bool:
|
|
"""Accept or deny one or more confirmations.
|
|
|
|
Uses multiajaxop for batch operations, ajaxop for single.
|
|
"""
|
|
from app.services.steam_auth import read_mafile_data
|
|
from app.core.proxy_manager import proxy_manager
|
|
|
|
mafile = read_mafile_data(account)
|
|
if not mafile:
|
|
raise ValueError("No mafile data")
|
|
|
|
identity_secret = mafile.get("identity_secret", "")
|
|
device_id = mafile.get("device_id", "")
|
|
session = mafile.get("Session", {})
|
|
steam_id = str(session.get("SteamID", ""))
|
|
session_id = session.get("SessionID", "")
|
|
|
|
if not identity_secret or not steam_id:
|
|
raise ValueError("Missing secrets in mafile")
|
|
|
|
access_token = await _ensure_valid_access_token(account, mafile)
|
|
|
|
if not device_id:
|
|
device_id = f"android:{steam_id}"
|
|
if not session_id:
|
|
import secrets
|
|
session_id = secrets.token_hex(12)
|
|
|
|
op = "allow" if accept else "cancel"
|
|
tag = "accept" if accept else "reject"
|
|
steam_time = await _get_steam_server_time()
|
|
params = _build_query_params(device_id, steam_id, identity_secret, steam_time, tag)
|
|
cookies = _build_cookies(steam_id, access_token, session_id)
|
|
|
|
proxy = await _resolve_proxy(account)
|
|
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
|
|
|
|
async with aiohttp.ClientSession(
|
|
headers={"User-Agent": MOBILE_USER_AGENT},
|
|
cookie_jar=aiohttp.CookieJar(unsafe=True),
|
|
connector=connector,
|
|
) as http:
|
|
for domain in ["steamcommunity.com", "store.steampowered.com"]:
|
|
for k, v in cookies.items():
|
|
http.cookie_jar.update_cookies({k: v}, URL(f"https://{domain}"))
|
|
|
|
if len(confirmation_ids) == 1:
|
|
url = f"{COMMUNITY_BASE}/mobileconf/ajaxop"
|
|
query = {
|
|
"op": op,
|
|
**params,
|
|
"cid": confirmation_ids[0],
|
|
"ck": confirmation_nonces[0],
|
|
}
|
|
async with http.get(url, params=query) as resp:
|
|
data = await resp.json()
|
|
else:
|
|
url = f"{COMMUNITY_BASE}/mobileconf/multiajaxop"
|
|
form_data = {"op": op}
|
|
form_data.update(params)
|
|
for cid in confirmation_ids:
|
|
form_data[f"cid[]"] = cid
|
|
for ck in confirmation_nonces:
|
|
form_data[f"ck[]"] = ck
|
|
|
|
payload = aiohttp.FormData()
|
|
payload.add_field("op", op)
|
|
for k, v in params.items():
|
|
payload.add_field(k, str(v))
|
|
for cid in confirmation_ids:
|
|
payload.add_field("cid[]", cid)
|
|
for ck in confirmation_nonces:
|
|
payload.add_field("ck[]", ck)
|
|
|
|
async with http.post(url, data=payload) as resp:
|
|
data = await resp.json()
|
|
|
|
success = data.get("success", False)
|
|
action_word = "accepted" if accept else "denied"
|
|
logger.info(f"[confirmations] {account.get('login', '?')}: {action_word} {len(confirmation_ids)} confirmations — success={success}")
|
|
return success
|