mirror of
https://github.com/rangermix/TwitchDropsMiner.git
synced 2026-05-26 07:08:04 +00:00
Replace the legacy desktop/Tkinter client and packaging artifacts with a Docker-first, web-hosted approach. - Add Docker quickstart and run-from-source instructions to the README to simplify deployment. - Simplify launcher to invoke the new web backend module instead of the old desktop entrypoint. - Update dependencies for a web UI stack (FastAPI, Uvicorn, python-socketio, Jinja2, etc.) and remove desktop/tray-specific packages. - Remove legacy GUI, packaging and platform-specific helper code, along with obsolete build/pack scripts and AppImage assets to declutter the repo. - Tidy project ignore rules to add runtime logs and editor metadata. Rationale: streamline deployment, favor a browser-accessible interface, and reduce maintenance overhead from multiple platform-specific GUI/packaging implementations.
454 lines
19 KiB
Python
454 lines
19 KiB
Python
"""Authentication state management for Twitch Drops Miner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
import aiohttp
|
|
from yarl import URL
|
|
|
|
from src.config import COOKIES_PATH
|
|
from src.exceptions import CaptchaRequired, LoginException
|
|
from src.i18n import _
|
|
from src.utils import CHARS_HEX_LOWER, create_nonce
|
|
|
|
if TYPE_CHECKING:
|
|
from src.core.client import Twitch
|
|
from src.web.gui_manager import LoginForm, LoginFormManager
|
|
from src.config import ClientInfo, JsonType
|
|
|
|
|
|
logger = logging.getLogger("TwitchDrops")
|
|
|
|
|
|
class _AuthState:
|
|
"""
|
|
Manages authentication state including tokens, session, and login flow.
|
|
|
|
This class handles:
|
|
- OAuth device code flow for authentication
|
|
- Legacy password-based login (deprecated)
|
|
- Access token validation and management
|
|
- Session and device ID management
|
|
- Cookie persistence
|
|
"""
|
|
|
|
def __init__(self, twitch: Twitch):
|
|
self._twitch: Twitch = twitch
|
|
self._lock = asyncio.Lock()
|
|
self._logged_in = asyncio.Event()
|
|
self.user_id: int
|
|
self.device_id: str
|
|
self.session_id: str
|
|
self.access_token: str
|
|
self.client_version: str
|
|
|
|
def _hasattrs(self, *attrs: str) -> bool:
|
|
"""Check if all specified attributes exist."""
|
|
return all(hasattr(self, attr) for attr in attrs)
|
|
|
|
def _delattrs(self, *attrs: str) -> None:
|
|
"""Delete all specified attributes if they exist."""
|
|
for attr in attrs:
|
|
if hasattr(self, attr):
|
|
delattr(self, attr)
|
|
|
|
def clear(self) -> None:
|
|
"""Clear all authentication state."""
|
|
self._delattrs(
|
|
"user_id",
|
|
"device_id",
|
|
"session_id",
|
|
"access_token",
|
|
"client_version",
|
|
)
|
|
self._logged_in.clear()
|
|
|
|
async def _oauth_login(self) -> str:
|
|
"""
|
|
Perform OAuth device code flow authentication.
|
|
|
|
This implements the OAuth 2.0 Device Authorization Grant flow:
|
|
1. Request device code and user code from Twitch
|
|
2. Display code to user for entry at twitch.tv/activate
|
|
3. Poll token endpoint until user completes authorization
|
|
4. Return access token
|
|
|
|
Returns:
|
|
str: The access token
|
|
"""
|
|
login_form: LoginForm = self._twitch.gui.login
|
|
client_info: ClientInfo = self._twitch._client_type
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"Accept-Encoding": "gzip",
|
|
"Accept-Language": "en-US",
|
|
"Cache-Control": "no-cache",
|
|
"Client-Id": client_info.CLIENT_ID,
|
|
"Host": "id.twitch.tv",
|
|
"Origin": str(client_info.CLIENT_URL),
|
|
"Pragma": "no-cache",
|
|
"Referer": str(client_info.CLIENT_URL),
|
|
"User-Agent": client_info.USER_AGENT,
|
|
"X-Device-Id": self.device_id,
|
|
}
|
|
payload = {
|
|
"client_id": client_info.CLIENT_ID,
|
|
"scopes": "", # no scopes needed
|
|
}
|
|
while True:
|
|
try:
|
|
from datetime import datetime, timedelta, timezone
|
|
from src.exceptions import RequestInvalid
|
|
|
|
now = datetime.now(timezone.utc)
|
|
async with self._twitch.request(
|
|
"POST", "https://id.twitch.tv/oauth2/device", headers=headers, data=payload
|
|
) as response:
|
|
# {
|
|
# "device_code": "40 chars [A-Za-z0-9]",
|
|
# "expires_in": 1800,
|
|
# "interval": 5,
|
|
# "user_code": "8 chars [A-Z]",
|
|
# "verification_uri": "https://www.twitch.tv/activate?device-code=ABCDEFGH"
|
|
# }
|
|
response_json: JsonType = await response.json()
|
|
device_code: str = response_json["device_code"]
|
|
user_code: str = response_json["user_code"]
|
|
interval: int = response_json["interval"]
|
|
verification_uri: URL = URL(response_json["verification_uri"])
|
|
expires_at = now + timedelta(seconds=response_json["expires_in"])
|
|
|
|
# Print the code to the user, open them the activate page so they can type it in
|
|
await login_form.ask_enter_code(verification_uri, user_code)
|
|
|
|
payload = {
|
|
"client_id": self._twitch._client_type.CLIENT_ID,
|
|
"device_code": device_code,
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
}
|
|
while True:
|
|
# sleep first, not like the user is gonna enter the code *that* fast
|
|
await asyncio.sleep(interval)
|
|
async with self._twitch.request(
|
|
"POST",
|
|
"https://id.twitch.tv/oauth2/token",
|
|
headers=headers,
|
|
data=payload,
|
|
invalidate_after=expires_at,
|
|
) as response:
|
|
# 200 means success, 400 means the user haven't entered the code yet
|
|
if response.status != 200:
|
|
continue
|
|
response_json = await response.json()
|
|
# {
|
|
# "access_token": "40 chars [A-Za-z0-9]",
|
|
# "refresh_token": "40 chars [A-Za-z0-9]",
|
|
# "scope": [...],
|
|
# "token_type": "bearer"
|
|
# }
|
|
self.access_token = cast(str, response_json["access_token"])
|
|
return self.access_token
|
|
except RequestInvalid:
|
|
# the device_code has expired, request a new code
|
|
continue
|
|
|
|
async def _login(self) -> str:
|
|
"""
|
|
Perform legacy password-based login flow.
|
|
|
|
This method implements the old Twitch login API flow using username/password.
|
|
It handles:
|
|
- Username/password authentication
|
|
- Two-factor authentication (TOTP and email codes)
|
|
- CAPTCHA detection
|
|
- Error handling and user feedback
|
|
|
|
NOTE: This flow is deprecated and may trigger CAPTCHA or be blocked by Twitch.
|
|
OAuth device code flow (_oauth_login) is the preferred method.
|
|
|
|
Returns:
|
|
str: The access token
|
|
|
|
Raises:
|
|
CaptchaRequired: When CAPTCHA is detected
|
|
LoginException: On login failure
|
|
"""
|
|
logger.info("Login flow started")
|
|
gui_print = self._twitch.gui.print
|
|
login_form: LoginFormManager = self._twitch.gui.login
|
|
client_info: ClientInfo = self._twitch._client_type
|
|
|
|
token_kind: str = ''
|
|
use_chrome: bool = False
|
|
payload: JsonType = {
|
|
# username and password are added later
|
|
# "username": str,
|
|
# "password": str,
|
|
# client ID to-be associated with the access token
|
|
"client_id": client_info.CLIENT_ID,
|
|
"undelete_user": False, # purpose unknown
|
|
"remember_me": True, # persist the session via the cookie
|
|
# "authy_token": str, # 2FA token
|
|
# "twitchguard_code": str, # email code
|
|
# "captcha": str, # self-fed captcha
|
|
# 'force_twitchguard': False, # force email code confirmation
|
|
}
|
|
|
|
def _safe_loads(s: str):
|
|
"""JSON loads that skips extra data after the first valid JSON object."""
|
|
import json
|
|
|
|
class SkipExtraJsonDecoder(json.JSONDecoder):
|
|
def decode(self, s: str, *args):
|
|
# skip whitespace check
|
|
obj, end = self.raw_decode(s)
|
|
return obj
|
|
|
|
return json.loads(s, cls=SkipExtraJsonDecoder)
|
|
|
|
while True:
|
|
login_data = await login_form.ask_login()
|
|
payload["username"] = login_data.username
|
|
payload["password"] = login_data.password
|
|
# reinstate the 2FA token, if present
|
|
payload.pop("authy_token", None)
|
|
payload.pop("twitchguard_code", None)
|
|
if login_data.token:
|
|
# if there's no token kind set yet, and the user has entered a token,
|
|
# we can immediately assume it's an authenticator token and not an email one
|
|
if not token_kind:
|
|
token_kind = "authy"
|
|
if token_kind == "authy":
|
|
payload["authy_token"] = login_data.token
|
|
elif token_kind == "email":
|
|
payload["twitchguard_code"] = login_data.token
|
|
|
|
# use fancy headers to mimic the twitch android app
|
|
headers = {
|
|
"Accept": "application/vnd.twitchtv.v3+json",
|
|
"Accept-Encoding": "gzip",
|
|
"Accept-Language": "en-US",
|
|
"Client-Id": client_info.CLIENT_ID,
|
|
"Content-Type": "application/json; charset=UTF-8",
|
|
"Host": "passport.twitch.tv",
|
|
"User-Agent": client_info.USER_AGENT,
|
|
"X-Device-Id": self.device_id,
|
|
# "X-Device-Id": ''.join(random.choices('0123456789abcdef', k=32)),
|
|
}
|
|
async with self._twitch.request(
|
|
"POST", "https://passport.twitch.tv/login", headers=headers, json=payload
|
|
) as response:
|
|
login_response: JsonType = await response.json(loads=_safe_loads)
|
|
|
|
# Feed this back in to avoid running into CAPTCHA if possible
|
|
if "captcha_proof" in login_response:
|
|
payload["captcha"] = {"proof": login_response["captcha_proof"]}
|
|
|
|
# Error handling
|
|
if "error_code" in login_response:
|
|
error_code: int = login_response["error_code"]
|
|
logger.info(f"Login error code: {error_code}")
|
|
if error_code == 1000:
|
|
logger.info("1000: CAPTCHA is required")
|
|
use_chrome = True
|
|
break
|
|
elif error_code in (2004, 3001):
|
|
logger.info("3001: Login failed due to incorrect username or password")
|
|
gui_print(_("login", "incorrect_login_pass"))
|
|
if error_code == 2004:
|
|
# invalid username
|
|
login_form.clear(login=True)
|
|
login_form.clear(password=True)
|
|
continue
|
|
elif error_code in (
|
|
3012, # Invalid authy token
|
|
3023, # Invalid email code
|
|
):
|
|
logger.info("3012/23: Login failed due to incorrect 2FA code")
|
|
if error_code == 3023:
|
|
token_kind = "email"
|
|
gui_print(_("login", "incorrect_email_code"))
|
|
else:
|
|
token_kind = "authy"
|
|
gui_print(_("login", "incorrect_twofa_code"))
|
|
login_form.clear(token=True)
|
|
continue
|
|
elif error_code in (
|
|
3011, # Authy token needed
|
|
3022, # Email code needed
|
|
):
|
|
# 2FA handling
|
|
logger.info("3011/22: 2FA token required")
|
|
# user didn't provide a token, so ask them for it
|
|
if error_code == 3022:
|
|
token_kind = "email"
|
|
gui_print(_("login", "email_code_required"))
|
|
else:
|
|
token_kind = "authy"
|
|
gui_print(_("login", "twofa_code_required"))
|
|
continue
|
|
elif error_code >= 5000:
|
|
# Special errors, usually from Twitch telling the user to "go away"
|
|
# We print the code out to inform the user, and just use chrome flow instead
|
|
# {
|
|
# "error_code":5023,
|
|
# "error":"Please update your app to continue",
|
|
# "error_description":"client is not supported for this feature"
|
|
# }
|
|
# {
|
|
# "error_code":5027,
|
|
# "error":"Please update your app to continue",
|
|
# "error_description":"client blocked from this operation"
|
|
# }
|
|
gui_print(_("login", "error_code").format(error_code=error_code))
|
|
logger.info(str(login_response))
|
|
use_chrome = True
|
|
break
|
|
else:
|
|
ext_msg = str(login_response)
|
|
logger.info(ext_msg)
|
|
raise LoginException(ext_msg)
|
|
# Success handling
|
|
if "access_token" in login_response:
|
|
self.access_token = cast(str, login_response["access_token"])
|
|
logger.info("Access token granted")
|
|
login_form.clear()
|
|
break
|
|
|
|
if use_chrome:
|
|
# await self._chrome_login()
|
|
raise CaptchaRequired()
|
|
|
|
if hasattr(self, "access_token"):
|
|
return self.access_token
|
|
raise LoginException("Login flow finished without setting the access token")
|
|
|
|
def headers(self, *, user_agent: str = '', gql: bool = False) -> JsonType:
|
|
"""
|
|
Build HTTP headers for Twitch API requests.
|
|
|
|
Args:
|
|
user_agent: Optional custom User-Agent string
|
|
gql: If True, include GraphQL-specific headers
|
|
|
|
Returns:
|
|
Dictionary of HTTP headers
|
|
"""
|
|
client_info: ClientInfo = self._twitch._client_type
|
|
headers = {
|
|
"Accept": "*/*",
|
|
"Accept-Encoding": "gzip",
|
|
"Accept-Language": "en-US",
|
|
"Pragma": "no-cache",
|
|
"Cache-Control": "no-cache",
|
|
"Client-Id": client_info.CLIENT_ID,
|
|
}
|
|
if user_agent:
|
|
headers["User-Agent"] = user_agent
|
|
if hasattr(self, "session_id"):
|
|
headers["Client-Session-Id"] = self.session_id
|
|
# if hasattr(self, "client_version"):
|
|
# headers["Client-Version"] = self.client_version
|
|
if hasattr(self, "device_id"):
|
|
headers["X-Device-Id"] = self.device_id
|
|
if gql:
|
|
headers["Origin"] = str(client_info.CLIENT_URL)
|
|
headers["Referer"] = str(client_info.CLIENT_URL)
|
|
headers["Authorization"] = f"OAuth {self.access_token}"
|
|
return headers
|
|
|
|
async def validate(self):
|
|
"""Thread-safe wrapper for _validate()."""
|
|
async with self._lock:
|
|
await self._validate()
|
|
return self
|
|
|
|
async def _validate(self):
|
|
"""
|
|
Validate and restore authentication state.
|
|
|
|
This method:
|
|
1. Generates session ID if needed
|
|
2. Extracts device ID from Twitch cookies
|
|
3. Validates existing access token or initiates login flow
|
|
4. Ensures token client ID matches expected client
|
|
5. Saves validated cookies to disk
|
|
|
|
Raises:
|
|
RuntimeError: On repeated validation failures
|
|
"""
|
|
if not hasattr(self, "session_id"):
|
|
self.session_id = create_nonce(CHARS_HEX_LOWER, 16)
|
|
if not self._hasattrs("device_id", "access_token", "user_id"):
|
|
session = await self._twitch.get_session()
|
|
jar = cast(aiohttp.CookieJar, session.cookie_jar)
|
|
client_info: ClientInfo = self._twitch._client_type
|
|
if not self._hasattrs("device_id"):
|
|
async with self._twitch.request(
|
|
"GET", client_info.CLIENT_URL, headers=self.headers()
|
|
) as response:
|
|
page_html = await response.text("utf8")
|
|
assert page_html is not None
|
|
# match = re.search(r'twilightBuildID="([-a-z0-9]+)"', page_html)
|
|
# if match is None:
|
|
# raise MinerException("Unable to extract client_version")
|
|
# self.client_version = match.group(1)
|
|
# doing the request ends up setting the "unique_id" value in the cookie
|
|
cookie = jar.filter_cookies(client_info.CLIENT_URL)
|
|
self.device_id = cookie["unique_id"].value
|
|
if not self._hasattrs("access_token", "user_id"):
|
|
# looks like we're missing something
|
|
login_form: LoginForm = self._twitch.gui.login
|
|
logger.info("Checking login")
|
|
login_form.update(_("gui", "login", "logging_in"), None)
|
|
for client_mismatch_attempt in range(2):
|
|
for invalid_token_attempt in range(2):
|
|
cookie = jar.filter_cookies(client_info.CLIENT_URL)
|
|
if "auth-token" not in cookie:
|
|
self.access_token = await self._oauth_login()
|
|
cookie["auth-token"] = self.access_token
|
|
elif not hasattr(self, "access_token"):
|
|
logger.info("Restoring session from cookie")
|
|
self.access_token = cookie["auth-token"].value
|
|
# validate the auth token, by obtaining user_id
|
|
async with self._twitch.request(
|
|
"GET",
|
|
"https://id.twitch.tv/oauth2/validate",
|
|
headers={"Authorization": f"OAuth {self.access_token}"}
|
|
) as response:
|
|
if response.status == 401:
|
|
# the access token we have is invalid - clear the cookie and reauth
|
|
logger.info("Restored session is invalid")
|
|
assert client_info.CLIENT_URL.host is not None
|
|
jar.clear_domain(client_info.CLIENT_URL.host)
|
|
continue
|
|
elif response.status == 200:
|
|
validate_response = await response.json()
|
|
break
|
|
else:
|
|
raise RuntimeError("Login verification failure (step #2)")
|
|
# ensure the cookie's client ID matches the currently selected client
|
|
if validate_response["client_id"] == client_info.CLIENT_ID:
|
|
break
|
|
# otherwise, we need to delete the entire cookie file and clear the jar
|
|
logger.info("Cookie client ID mismatch")
|
|
jar.clear()
|
|
COOKIES_PATH.unlink(missing_ok=True)
|
|
else:
|
|
raise RuntimeError("Login verification failure (step #1)")
|
|
self.user_id = int(validate_response["user_id"])
|
|
cookie["persistent"] = str(self.user_id)
|
|
logger.info(f"Login successful, user ID: {self.user_id}")
|
|
login_form.update(_("gui", "login", "logged_in"), self.user_id)
|
|
# update our cookie and save it
|
|
jar.update_cookies(cookie, client_info.CLIENT_URL)
|
|
jar.save(COOKIES_PATH)
|
|
self._logged_in.set()
|
|
|
|
def invalidate(self):
|
|
"""Invalidate the current access token."""
|
|
self._delattrs("access_token")
|