Files
TwitchDropsMiner/src/auth/auth_state.py
Fengqing Liu 5b736e3bb1 Migrate to Docker-ready web UI and remove legacy desktop GUI
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.
2025-10-16 21:54:43 +11:00

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")