Reimplement standard login procedure, keep chrome as backup

This commit is contained in:
DevilXD committed 2022-11-19 19:03:52 +01:00
1 parent 5e3e7b6f4f
commit 504c6b06c0
4 files changed
+388 -197

No files matched your search

+3 -3
View File
@@ -64,9 +64,9 @@ DEFAULT_LANG = "English"
BASE_URL = URL("https://twitch.tv")
CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
# CLIENT_ID = "uo6dggojyb8d6soh92zknwmi5ej1q2"
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/105.0.0.0 Safari/537.36"
ANDROID_USER_AGENT = (
"Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G975N Build/N2G48C) "
"tv.twitch.android.app/13.4.1/1304010"
)
# Intervals and Delays
PING_INTERVAL = timedelta(minutes=3)
+49 -166
View File
@@ -1,8 +1,6 @@
from __future__ import annotations
import re
import sys
import json
import ctypes
import asyncio
import logging
@@ -10,7 +8,7 @@ import webbrowser
import tkinter as tk
from collections import abc
from math import log10, ceil
from subprocess import CREATE_NO_WINDOW
from dataclasses import dataclass
from tkinter.font import Font, nametofont
from functools import partial, cached_property
from datetime import datetime, timedelta, timezone
@@ -23,31 +21,13 @@ import win32con
import win32gui
from yarl import URL
from PIL import Image as Image_module
try:
from seleniumwire.request import Request
from selenium.common.exceptions import WebDriverException
from seleniumwire.undetected_chromedriver import Chrome, ChromeOptions
except ImportError as exc:
raise ImportError(
"You need to install Visual C++ Redist (x86 and x64): "
"https://support.microsoft.com/en-gb/help/2977003/the-latest-supported-visual-c-downloads"
) from exc
from translate import _
from cache import ImageCache
from exceptions import ExitRequest
from utils import resource_path, Game, _T
from registry import RegistryKey, ValueType
from exceptions import MinerException, ExitRequest, LoginException
from constants import (
CLIENT_ID,
SELF_PATH,
# CACHE_PATH,
FORMATTER,
WS_TOPICS_LIMIT,
MAX_WEBSOCKETS,
WINDOW_TITLE,
State,
)
from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
if TYPE_CHECKING:
from twitch import Twitch
@@ -424,6 +404,13 @@ class WebsocketStatus:
self._topics_var.set('\n'.join(topic_lines))
@dataclass
class LoginData:
username: str
password: str
token: str
class LoginForm:
def __init__(self, manager: GUIManager, master: ttk.Widget):
self._manager = manager
@@ -435,14 +422,14 @@ class LoginForm:
frame.rowconfigure(4, weight=1)
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0)
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
# self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
# self._login_entry.grid(column=0, row=1, columnspan=2)
# self._pass_entry = PlaceholderEntry(
# frame, placeholder=_("gui", "login", "password"), show='•'
# )
# self._pass_entry.grid(column=0, row=2, columnspan=2)
# self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
# self._token_entry.grid(column=0, row=3, columnspan=2)
self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
self._login_entry.grid(column=0, row=1, columnspan=2)
self._pass_entry = PlaceholderEntry(
frame, placeholder=_("gui", "login", "password"), show='•'
)
self._pass_entry.grid(column=0, row=2, columnspan=2)
self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
self._token_entry.grid(column=0, row=3, columnspan=2)
self._confirm = asyncio.Event()
self._button = ttk.Button(
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
@@ -450,150 +437,46 @@ class LoginForm:
self._button.grid(column=0, row=4, columnspan=2)
self.update(_("gui", "login", "logged_out"), None)
@staticmethod
def interceptor(request: Request) -> None:
if (
request.method == "POST"
and request.url == "https://passport.twitch.tv/protected_login"
):
body = request.body.decode("utf-8")
data = json.loads(body)
data["client_id"] = CLIENT_ID
request.body = json.dumps(data).encode("utf-8")
del request.headers["Content-Length"]
request.headers["Content-Length"] = str(len(request.body))
def clear(self, login: bool = False, password: bool = False, token: bool = False):
clear_all = not login and not password and not token
if login or clear_all:
self._login_entry.clear()
if password or clear_all:
self._pass_entry.clear()
if token or clear_all:
self._token_entry.clear()
async def ask_login(self) -> str:
self.update(_("gui", "login", "required"), None)
self._manager.print(_("gui", "login", "request"))
# ensure the window isn't hidden into tray when this runs
self._manager.tray.restore()
# the user needs to press on the login button to open the browser
async def wait_for_login_press(self) -> None:
self._confirm.clear()
try:
self._button.config(state="normal")
await self._manager.coro_unless_closed(self._confirm.wait())
finally:
self._button.config(state="disabled")
self._confirm.clear()
self._manager.print("Opening Chrome...")
# open the chrome browser on the Twitch's login page
# use a separate executor to void blocking the event loop
loop = asyncio.get_running_loop()
driver = None
try:
version_main = None
for attempt in range(2):
options = ChromeOptions()
options.add_argument("--log-level=3")
options.add_argument("--disable-web-security")
options.add_argument("--allow-running-insecure-content")
options.add_argument("--lang=en")
options.add_argument("--no-sandbox")
options.add_argument("--test-type")
options.add_argument("--disable-gpu")
options.set_capability("pageLoadStrategy", "eager")
try:
wire_options: dict[str, Any] = {"proxy": {}}
if self._manager._twitch.settings.proxy:
wire_options["proxy"]["http"] = str(self._manager._twitch.settings.proxy)
driver_coro = loop.run_in_executor(
None,
lambda: Chrome(
options=options,
suppress_welcome=True,
version_main=version_main,
seleniumwire_options=wire_options,
service_creationflags=CREATE_NO_WINDOW,
# user_data_dir=str(CACHE_PATH.joinpath("ChromeProfile")),
)
)
driver = await self._manager.coro_unless_closed(driver_coro)
break
except WebDriverException as exc:
message = exc.msg
if (
message is not None
and (match := re.search(
r'Chrome version ([\d]+)\nCurrent browser version is ((\d+)\.[\d.]+)',
message,
)) is not None
):
if not attempt:
version_main = int(match.group(3))
continue
else:
raise MinerException(
"Your Chrome browser is out of date\n"
f"Required version: {match.group(1)}\n"
f"Current version: {match.group(2)}"
) from None
raise MinerException(
"An error occured while boostrapping the Chrome browser"
) from exc
assert driver is not None
driver.request_interceptor = self.interceptor
# driver.set_page_load_timeout(30)
page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv/login")
await self._manager.coro_unless_closed(page_coro)
# enable the login button once the page finishes opening
self._button.config(state="normal")
self._manager.print(
"Complete the login procedure manually by pressing the Login button again."
async def ask_login(self) -> LoginData:
self.update(_("gui", "login", "required"), None)
# ensure the window isn't hidden into tray when this runs
self._manager.tray.restore()
while True:
self._manager.print(_("gui", "login", "request"))
await self.wait_for_login_press()
login_data = LoginData(
self._login_entry.get().strip(),
self._pass_entry.get(),
self._token_entry.get().strip(),
)
# auto login
# driver.find_element("id", "login-username").send_keys(username)
# driver.find_element("id", "password-input").send_keys(password)
# driver.find_element(
# "css selector", '[data-a-target="passport-login-button"]'
# ).click()
# token submit button css selectors
# Button: "screen="two_factor" target="submit_button"
# Input: <input type="text" autocomplete="one-time-code" data-a-target="tw-input"
# inputmode="numeric" pattern="[0-9]*" value="">
# wait for the user to navigate away from the URL, indicating successful login
# alternatively, they can press on the login button again
while (
driver.current_url != "https://www.twitch.tv/?no-reload=true"
and not self._confirm.is_set()
):
if self._manager.close_requested:
raise ExitRequest()
await asyncio.sleep(0.5)
# cookies = [
# {
# "domain": ".twitch.tv",
# "expiry": 1700000000,
# "httpOnly": False,
# "name": "auth-token",
# "path": "/",
# "sameSite": "None",
# "secure": True,
# "value": "..."
# },
# ...,
# ]
cookies = driver.get_cookies()
for cookie in cookies:
if "twitch.tv" in cookie["domain"] and cookie["name"] == "auth-token":
auth_token = cookie["value"]
break
else:
raise LoginException("Unable to extract authorization token")
except WebDriverException:
driver = None
raise LoginException(
"Chrome window was closed before the login procedure could complete."
)
finally:
self._button.config(state="disabled")
if driver is not None:
driver.quit()
return auth_token
# basic input data validation
if len(login_data.username) < 3:
self.clear(login=True)
continue
if len(login_data.password) < 8:
self.clear(password=True)
continue
if login_data.token and len(login_data.token) < 6:
self.clear(token=True)
continue
return login_data
def update(self, status: str, user_id: int | None):
if user_id is not None:
+325 -28
View File
@@ -1,14 +1,16 @@
from __future__ import annotations
import re
# import json
import sys
import json
import random
import asyncio
import logging
from math import ceil
from time import time
from itertools import chain
from functools import partial
# from base64 import urlsafe_b64decode
from subprocess import CREATE_NO_WINDOW
from collections import abc, OrderedDict
from datetime import datetime, timedelta, timezone
from contextlib import suppress, asynccontextmanager
@@ -16,18 +18,34 @@ from typing import Any, Literal, Final, NoReturn, cast, TYPE_CHECKING
import aiohttp
from yarl import URL
try:
from seleniumwire.request import Request
from selenium.common.exceptions import WebDriverException
from seleniumwire.undetected_chromedriver import Chrome, ChromeOptions
except ImportError as exc:
raise ImportError(
"You need to install Visual C++ Redist (x86 and x64): "
"https://support.microsoft.com/en-gb/help/2977003/the-latest-supported-visual-c-downloads"
) from exc
from translate import _
from gui import GUIManager
from channel import Channel
from websocket import WebsocketPool
from inventory import DropsCampaign
from exceptions import MinerException, ExitRequest, LoginException, ReloadRequest, RequestInvalid
from exceptions import (
MinerException,
ExitRequest,
LoginException,
ReloadRequest,
RequestInvalid,
)
from utils import (
CHARS_HEX_LOWER,
timestamp,
create_nonce,
task_wrapper,
first_to_complete,
OrderedSet,
AwaitableValue,
ExponentialBackoff,
@@ -35,13 +53,13 @@ from utils import (
from constants import (
BASE_URL,
CLIENT_ID,
USER_AGENT,
COOKIES_PATH,
GQL_OPERATIONS,
MAX_WEBSOCKETS,
WATCH_INTERVAL,
WS_TOPICS_LIMIT,
DROPS_ENABLED_TAG,
ANDROID_USER_AGENT,
State,
WebsocketTopic,
)
@@ -58,6 +76,16 @@ logger = logging.getLogger("TwitchDrops")
gql_logger = logging.getLogger("TwitchDrops.gql")
class SkipExtraJsonDecoder(json.JSONDecoder):
def decode(self, s: str, *args):
# skip whitespace check
obj, end = self.raw_decode(s)
return obj
SAFE_LOADS = lambda s: json.loads(s, cls=SkipExtraJsonDecoder)
class _AuthState:
def __init__(self, twitch: Twitch):
self._twitch: Twitch = twitch
@@ -98,37 +126,292 @@ class _AuthState:
)
self._logged_in.clear()
@staticmethod
def interceptor(request: Request) -> None:
if (
request.method == "POST"
and request.url == "https://passport.twitch.tv/protected_login"
):
body = request.body.decode("utf-8")
data = json.loads(body)
data["client_id"] = CLIENT_ID
request.body = json.dumps(data).encode("utf-8")
del request.headers["Content-Length"]
request.headers["Content-Length"] = str(len(request.body))
async def _chrome_login(self) -> None:
gui_print = self._twitch.gui.print
login_form: LoginForm = self._twitch.gui.login
coro_unless_closed = self._twitch.gui.coro_unless_closed
# open the chrome browser on the Twitch's login page
# use a separate executor to void blocking the event loop
loop = asyncio.get_running_loop()
driver: Chrome | None = None
while True:
gui_print("Opening Chrome...")
try:
version_main = None
for attempt in range(2):
options = ChromeOptions()
options.add_argument("--log-level=3")
options.add_argument("--disable-web-security")
options.add_argument("--allow-running-insecure-content")
options.add_argument("--lang=en")
options.add_argument("--no-sandbox")
options.add_argument("--test-type")
options.add_argument("--disable-gpu")
options.set_capability("pageLoadStrategy", "eager")
try:
wire_options: dict[str, Any] = {"proxy": {}}
if self._twitch.settings.proxy:
wire_options["proxy"]["http"] = str(self._twitch.settings.proxy)
driver_coro = loop.run_in_executor(
None,
lambda: Chrome(
options=options,
suppress_welcome=True,
version_main=version_main,
seleniumwire_options=wire_options,
service_creationflags=CREATE_NO_WINDOW,
)
)
driver = await coro_unless_closed(driver_coro)
break
except WebDriverException as exc:
message = exc.msg
if (
message is not None
and (
match := re.search(
(
r'Chrome version ([\d]+)\n'
r'Current browser version is ((\d+)\.[\d.]+)'
),
message,
)
) is not None
):
if not attempt:
version_main = int(match.group(3))
continue
else:
raise MinerException(
"Your Chrome browser is out of date\n"
f"Required version: {match.group(1)}\n"
f"Current version: {match.group(2)}"
) from None
raise MinerException(
"An error occured while boostrapping the Chrome browser"
) from exc
assert driver is not None
driver.request_interceptor = self.interceptor
# driver.set_page_load_timeout(30)
# page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv")
# await coro_unless_closed(page_coro)
page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv/login")
await coro_unless_closed(page_coro)
# auto login
# if login_data.username and login_data.password:
# driver.find_element("id", "login-username").send_keys(login_data.username)
# driver.find_element("id", "password-input").send_keys(login_data.password)
# driver.find_element(
# "css selector", '[data-a-target="passport-login-button"]'
# ).click()
# token submit button css selectors
# Button: "screen="two_factor" target="submit_button"
# Input: <input type="text" autocomplete="one-time-code" data-a-target="tw-input"
# inputmode="numeric" pattern="[0-9]*" value="">
# wait for the user to navigate away from the URL, indicating successful login
# alternatively, they can press on the login button again
async def url_waiter(driver=driver):
while driver.current_url != "https://www.twitch.tv/?no-reload=true":
await asyncio.sleep(0.5)
gui_print(
"Complete the login procedure manually by pressing the Login button again."
)
await first_to_complete([
url_waiter(),
coro_unless_closed(login_form.wait_for_login_press()),
])
# cookies = [
# {
# "domain": ".twitch.tv",
# "expiry": 1700000000,
# "httpOnly": False,
# "name": "auth-token",
# "path": "/",
# "sameSite": "None",
# "secure": True,
# "value": "..."
# },
# ...,
# ]
cookies = driver.get_cookies()
for cookie in cookies:
if "twitch.tv" in cookie["domain"] and cookie["name"] == "auth-token":
self.access_token = cookie["value"]
break
else:
gui_print("Unable to extract authorization token")
except WebDriverException:
gui_print(
"Chrome window was closed before the login procedure could complete."
)
finally:
if driver is not None:
driver.quit()
driver = None
await coro_unless_closed(login_form.wait_for_login_press())
async def _login(self) -> str:
logger.debug("Login flow started")
gui_print = self._twitch.gui.print
login_form: LoginForm = self._twitch.gui.login
for attempt in range(1, 6): # 5 attempts
try:
self.access_token = await login_form.ask_login()
break
except LoginException as exc:
if attempt < 5:
self._twitch.gui.print(exc.args[0])
continue
raise # reraise on and past the 5th attempt
logger.debug("Access token granted")
return self.access_token
token_kind: str = ''
use_chrome: bool = False
payload: JsonType = {
"client_id": CLIENT_ID, # client ID to-be associated with the access token
"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 gql_headers(self, *, integrity: bool) -> JsonType:
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_ID,
"Content-Type": "application/json; charset=UTF-8",
"Host": "passport.twitch.tv",
"User-Agent": ANDROID_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.debug(f"Login error code: {error_code}")
if error_code == 1000:
logger.debug("1000: CAPTCHA is required")
use_chrome = True
break
elif error_code == 3001:
logger.debug("3001: Login failed due to incorrect username or password")
gui_print(_("login", "incorrect_login_pass"))
login_form.clear(password=True)
continue
elif error_code in (
3012, # Invalid authy token
3023, # Invalid email code
):
logger.debug("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.debug("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 == 5027:
# client blocked from this operation - use chrome
logger.debug("5027: Client blocked from this operation")
use_chrome = True
break
else:
msg = str(login_response["error"])
logger.debug(msg)
raise LoginException(msg)
# Success handling
if "access_token" in login_response:
self.access_token = cast(str, login_response["access_token"])
logger.debug("Access token granted")
login_form.clear()
break
if use_chrome:
await self._chrome_login()
if hasattr(self, "access_token"):
logger.debug("Access token granted")
return self.access_token
raise MinerException("Login flow finished without setting the access token")
def headers(
self, *, user_agent: str = '', gql: bool = False, integrity: bool = False
) -> JsonType:
headers = {
"Authorization": f"OAuth {self.access_token}",
"Accept": "*/*",
"Accept-Encoding": "gzip",
"Accept-Language": "en-US",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
"Origin": "https://www.twitch.tv",
"Referer": "https://www.twitch.tv/",
"Client-Id": CLIENT_ID,
"Client-Session-Id": self.session_id,
"Client-Version": self.client_version,
"X-Device-Id": self.device_id,
}
# if integrity:
# headers["Client-Integrity"] = self.integrity_token
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"] = "https://www.twitch.tv"
headers["Referer"] = "https://www.twitch.tv/"
headers["Authorization"] = f"OAuth {self.access_token}"
if integrity:
headers["Client-Integrity"] = self.integrity_token
return headers
async def validate(self):
@@ -142,8 +425,11 @@ class _AuthState:
session = await self._twitch.get_session()
jar = cast(aiohttp.CookieJar, session.cookie_jar)
if not self._hasattrs("client_version", "device_id"):
async with self._twitch.request("GET", BASE_URL) as response:
match = re.search(r'twilightBuildID="([-a-z0-9]+)"', await response.text("utf8"))
async with self._twitch.request(
"GET", BASE_URL, headers=self.headers() # (user_agent=ANDROID_USER_AGENT)
) as response:
page_html = await response.text("utf8")
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)
@@ -253,12 +539,23 @@ class Twitch:
if session.closed:
raise RuntimeError("Session is closed")
return session
# obtain the latest Chrome user agent from a Github project
async with aiohttp.request(
"GET", "https://jnrbsn.github.io/user-agents/user-agents.json"
) as response:
agents = await response.json()
if sys.platform == "win32":
chrome_agent = random.choice(agents[:3])
elif sys.platform == "linux":
chrome_agent = random.choice(agents[7:11])
# load in cookies
cookie_jar = aiohttp.CookieJar()
if COOKIES_PATH.exists():
cookie_jar.load(COOKIES_PATH)
# create session
self._session = aiohttp.ClientSession(
cookie_jar=cookie_jar,
headers={"User-Agent": USER_AGENT},
headers={"User-Agent": chrome_agent},
timeout=aiohttp.ClientTimeout(connect=5, total=10),
)
return self._session
@@ -974,7 +1271,7 @@ class Twitch:
"POST",
"https://gql.twitch.tv/gql",
json=op,
headers=auth_state.gql_headers(integrity=True),
headers=auth_state.headers(gql=True),
invalidate_after=getattr(auth_state, "integrity_expires", None),
) as response:
response_json: JsonType = await response.json()
+11
View File
@@ -38,6 +38,17 @@ _JSON_T = TypeVar("_JSON_T", bound=Mapping[Any, Any])
logger = logging.getLogger("TwitchDrops")
async def first_to_complete(coros: abc.Iterable[abc.Coroutine[Any, Any, _T]]) -> _T:
# In Python 3.11, we need to explicitly wrap awaitables
tasks = [asyncio.ensure_future(coro) for coro in coros]
done: set[asyncio.Task[Any]]
pending: set[asyncio.Task[Any]]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
return next(iter(done)).result()
def format_traceback(exc: BaseException, **kwargs: Any) -> str:
"""
Like `traceback.print_exc` but returns a string. Uses the passed-in exception.