Support new login flow via Chrome

This commit is contained in:
DevilXD committed 2022-11-01 17:49:21 +01:00
1 parent 88b228d38b
commit 73a2a6145f
5 files changed
+184 -121

No files matched your search

+6 -1
View File
@@ -31,7 +31,12 @@ a = Analysis(
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
noarchive=False, noarchive=False,
hiddenimports=[], hiddenimports=[
"setuptools._distutils.log",
"setuptools._distutils.dir_util",
"setuptools._distutils.file_util",
"setuptools._distutils.archive_util",
],
runtime_hooks=[], runtime_hooks=[],
cipher=block_cipher, cipher=block_cipher,
win_no_prefer_redirects=False, win_no_prefer_redirects=False,
+156 -34
View File
@@ -1,18 +1,21 @@
from __future__ import annotations from __future__ import annotations
import re
import sys import sys
import json
import ctypes import ctypes
import asyncio import asyncio
import logging import logging
import webbrowser import webbrowser
import tkinter as tk import tkinter as tk
from collections import abc
from math import log10, ceil from math import log10, ceil
from collections import abc, namedtuple from subprocess import CREATE_NO_WINDOW
from tkinter.font import Font, nametofont from tkinter.font import Font, nametofont
from functools import partial, cached_property from functools import partial, cached_property
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, TypedDict, NoReturn, Generic, TYPE_CHECKING
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage
from typing import Any, Union, Tuple, TypedDict, NoReturn, Generic, TYPE_CHECKING
import pystray import pystray
import win32api import win32api
@@ -20,13 +23,18 @@ import win32con
import win32gui import win32gui
from yarl import URL from yarl import URL
from PIL import Image as Image_module from PIL import Image as Image_module
from undetected_chromedriver import ChromeOptions
from seleniumwire.undetected_chromedriver.v2 import Chrome
from selenium.common.exceptions import WebDriverException, NoSuchWindowException
from translate import _ from translate import _
from cache import ImageCache from cache import ImageCache
from exceptions import ExitRequest
from utils import resource_path, Game, _T from utils import resource_path, Game, _T
from registry import RegistryKey, ValueType from registry import RegistryKey, ValueType
from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State from exceptions import MinerException, ExitRequest, LoginException
from constants import (
CLIENT_ID, SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
)
if TYPE_CHECKING: if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
@@ -35,9 +43,15 @@ if TYPE_CHECKING:
from inventory import DropsCampaign, TimedDrop from inventory import DropsCampaign, TimedDrop
TK_PADDING = Union[int, Tuple[int, int], Tuple[int, int, int], Tuple[int, int, int, int]]
digits = ceil(log10(WS_TOPICS_LIMIT)) digits = ceil(log10(WS_TOPICS_LIMIT))
######################
# GUI ELEMENTS START #
######################
class _TKOutputHandler(logging.Handler): class _TKOutputHandler(logging.Handler):
def __init__(self, output: GUIManager): def __init__(self, output: GUIManager):
super().__init__() super().__init__()
@@ -161,7 +175,7 @@ class PlaceholderCombobox(PlaceholderEntry, ttk.Combobox):
class PaddedListbox(tk.Listbox): class PaddedListbox(tk.Listbox):
def __init__(self, master: ttk.Widget, *args, padding: tk._Padding = (0, 0, 0, 0), **kwargs): def __init__(self, master: ttk.Widget, *args, padding: TK_PADDING = (0, 0, 0, 0), **kwargs):
# we place the listbox inside a frame with the same background # we place the listbox inside a frame with the same background
# this means we need to forward the 'grid' method to the frame, not the listbox # this means we need to forward the 'grid' method to the frame, not the listbox
self._frame = tk.Frame(master) self._frame = tk.Frame(master)
@@ -210,7 +224,7 @@ class PaddedListbox(tk.Listbox):
self._frame.configure(frame_options) self._frame.configure(frame_options)
# update padding # update padding
if "padding" in options: if "padding" in options:
padding: tk._Padding = options.pop("padding") padding: TK_PADDING = options.pop("padding")
padx1: tk._ScreenUnits padx1: tk._ScreenUnits
padx2: tk._ScreenUnits padx2: tk._ScreenUnits
pady1: tk._ScreenUnits pady1: tk._ScreenUnits
@@ -221,9 +235,9 @@ class PaddedListbox(tk.Listbox):
padx1 = padx2 = pady1 = pady2 = padding padx1 = padx2 = pady1 = pady2 = padding
elif len(padding) == 2: elif len(padding) == 2:
padx1 = padx2 = padding[0] padx1 = padx2 = padding[0]
pady1 = pady2 = padding[1] # type: ignore pady1 = pady2 = padding[1]
elif len(padding) == 3: elif len(padding) == 3:
padx1, padx2 = padding[0:2] # type: ignore padx1, padx2 = padding[0], padding[1]
pady1 = pady2 = padding[2] # type: ignore pady1 = pady2 = padding[2] # type: ignore
else: else:
padx1, padx2, pady1, pady2 = padding # type: ignore padx1, padx2, pady1, pady2 = padding # type: ignore
@@ -309,7 +323,9 @@ class SelectMenu(tk.Menubutton, Generic[_T]):
return self._menu_options[self.cget("text")] return self._menu_options[self.cget("text")]
# GUI ELEMENTS END / GUI DEFINITION START ###########################################
# GUI ELEMENTS END / GUI DEFINITION START #
###########################################
class StatusBar: class StatusBar:
@@ -395,9 +411,6 @@ class WebsocketStatus:
self._topics_var.set('\n'.join(topic_lines)) self._topics_var.set('\n'.join(topic_lines))
LoginData = namedtuple("LoginData", ["username", "password", "token"])
class LoginForm: class LoginForm:
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._manager = manager self._manager = manager
@@ -409,14 +422,14 @@ class LoginForm:
frame.rowconfigure(4, weight=1) frame.rowconfigure(4, weight=1)
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0) 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) 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 = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
self._login_entry.grid(column=0, row=1, columnspan=2) # self._login_entry.grid(column=0, row=1, columnspan=2)
self._pass_entry = PlaceholderEntry( # self._pass_entry = PlaceholderEntry(
frame, placeholder=_("gui", "login", "password"), show='•' # frame, placeholder=_("gui", "login", "password"), show='•'
) # )
self._pass_entry.grid(column=0, row=2, columnspan=2) # self._pass_entry.grid(column=0, row=2, columnspan=2)
self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code")) # self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
self._token_entry.grid(column=0, row=3, columnspan=2) # self._token_entry.grid(column=0, row=3, columnspan=2)
self._confirm = asyncio.Event() self._confirm = asyncio.Event()
self._button = ttk.Button( self._button = ttk.Button(
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled" frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
@@ -424,31 +437,130 @@ class LoginForm:
self._button.grid(column=0, row=4, columnspan=2) self._button.grid(column=0, row=4, columnspan=2)
self.update(_("gui", "login", "logged_out"), None) self.update(_("gui", "login", "logged_out"), None)
def clear(self, login: bool = False, password: bool = False, token: bool = False): @staticmethod
clear_all = not login and not password and not token def interceptor(request) -> None:
if login or clear_all: if (
self._login_entry.clear() request.method == "POST"
if password or clear_all: and request.url == "https://passport.twitch.tv/protected_login"
self._pass_entry.clear() ):
if token or clear_all: body = request.body.decode("utf-8")
self._token_entry.clear() 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 ask_login(self) -> LoginData: async def ask_login(self) -> str:
self.update(_("gui", "login", "required"), None) self.update(_("gui", "login", "required"), None)
self._manager.print(_("gui", "login", "request")) self._manager.print(_("gui", "login", "request"))
self._confirm.clear() self._confirm.clear()
self._button.config(state="normal")
# NOTE: we need this to allow for the closing window event to break the waiting here # 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("--disable-gpu")
try:
driver_coro = loop.run_in_executor(
None,
lambda: Chrome(
options=options,
# use_subprocess=True,
suppress_welcome=True,
version_main=version_main,
service_creationflags=CREATE_NO_WINDOW,
)
)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
[self._confirm.wait(), self._manager.wait_until_closed()], [driver_coro, self._manager.wait_until_closed()],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED
) )
for task in pending: for task in pending:
task.cancel() task.cancel()
if self._manager.close_requested: if self._manager.close_requested:
raise ExitRequest() raise ExitRequest()
driver = next(iter(done)).result()
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
page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv/login")
done, pending = await asyncio.wait(
[page_coro, self._manager.wait_until_closed()],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
if self._manager.close_requested:
raise ExitRequest()
# enable the login button once the page finishes opening
self._button.config(state="normal")
# 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
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 = driver.get_cookies()
for cookie in cookies:
if cookie["name"] == "auth-token":
auth_token = cookie["value"]
break
else:
raise LoginException("Unable to extract authorization token")
except NoSuchWindowException:
driver = None
raise LoginException("Chrome window was closed, reopening...")
finally:
self._button.config(state="disabled") self._button.config(state="disabled")
return LoginData(self._login_entry.get(), self._pass_entry.get(), self._token_entry.get()) if driver is not None:
driver.quit()
return auth_token
def update(self, status: str, user_id: int | None): def update(self, status: str, user_id: int | None):
if user_id is not None: if user_id is not None:
@@ -1643,6 +1755,11 @@ class HelpTab:
).grid(sticky="nsew") ).grid(sticky="nsew")
##########################################
# GUI DEFINITION END / GUI MANAGER START #
##########################################
class GUIManager: class GUIManager:
def __init__(self, twitch: Twitch): def __init__(self, twitch: Twitch):
self._twitch: Twitch = twitch self._twitch: Twitch = twitch
@@ -1887,6 +2004,11 @@ class GUIManager:
self.output.print(*args, **kwargs) self.output.print(*args, **kwargs)
###################
# GUI MANAGER END #
###################
if __name__ == "__main__": if __name__ == "__main__":
# Everything below is for debug purposes only # Everything below is for debug purposes only
import aiohttp import aiohttp
+11 -5
View File
@@ -1,5 +1,10 @@
from __future__ import annotations from __future__ import annotations
# import an additional thing for proper PyInstaller freeze support
from multiprocessing import freeze_support
if __name__ == "__main__":
freeze_support()
import io import io
import sys import sys
import signal import signal
@@ -36,7 +41,6 @@ from version import __version__
from exceptions import CaptchaRequired from exceptions import CaptchaRequired
from constants import SELF_PATH, FORMATTER, LOG_PATH, WINDOW_TITLE from constants import SELF_PATH, FORMATTER, LOG_PATH, WINDOW_TITLE
class Parser(argparse.ArgumentParser): class Parser(argparse.ArgumentParser):
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -52,7 +56,6 @@ class Parser(argparse.ArgumentParser):
finally: finally:
messagebox.showerror("Argument Parser Error", self._message.getvalue()) messagebox.showerror("Argument Parser Error", self._message.getvalue())
class ParsedArgs(argparse.Namespace): class ParsedArgs(argparse.Namespace):
_verbose: int _verbose: int
_debug_ws: bool _debug_ws: bool
@@ -92,7 +95,6 @@ class ParsedArgs(argparse.Namespace):
return logging.INFO return logging.INFO
return logging.NOTSET return logging.NOTSET
# handle input parameters # handle input parameters
# NOTE: parser output is shown via message box # NOTE: parser output is shown via message box
# we also need a dummy invisible window for the parser # we also need a dummy invisible window for the parser
@@ -112,8 +114,12 @@ parser.add_argument("--log", action="store_true")
parser.add_argument( parser.add_argument(
"--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS "--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS
) )
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS) parser.add_argument(
parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS) "--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS
)
parser.add_argument(
"--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS
)
args = parser.parse_args(namespace=ParsedArgs()) args = parser.parse_args(namespace=ParsedArgs())
# load settings # load settings
try: try:
+3
View File
@@ -2,3 +2,6 @@ aiohttp>2.0,<4.0
Pillow Pillow
pystray pystray
pywin32 pywin32
selenium-wire
# use a fork that has an extra option of hiding the unneeded Chrome's console cmd window
git+https://github.com/sebdelsol/undetected-chromedriver
+8 -81
View File
@@ -22,9 +22,7 @@ from gui import GUIManager
from channel import Channel from channel import Channel
from websocket import WebsocketPool from websocket import WebsocketPool
from inventory import DropsCampaign from inventory import DropsCampaign
from exceptions import ( from exceptions import MinerException, ExitRequest, LoginException, ReloadRequest, RequestInvalid
MinerException, LoginException, CaptchaRequired, ExitRequest, ReloadRequest, RequestInvalid
)
from utils import ( from utils import (
CHARS_HEX_LOWER, CHARS_HEX_LOWER,
timestamp, timestamp,
@@ -103,88 +101,17 @@ class _AuthState:
async def _login(self) -> str: async def _login(self) -> str:
logger.debug("Login flow started") logger.debug("Login flow started")
login_form: LoginForm = self._twitch.gui.login login_form: LoginForm = self._twitch.gui.login
gui_print = self._twitch.gui.print
payload: JsonType = { for attempt in range(1, 6): # 5 attempts
"client_id": CLIENT_ID, try:
"undelete_user": False, self.access_token = await login_form.ask_login()
"remember_me": True,
# 'force_twitchguard': False,
}
while True:
username, password, token = await login_form.ask_login()
payload["username"] = username
payload["password"] = password
# remove stale 2FA tokens, if present
payload.pop("authy_token", None)
payload.pop("twitchguard_code", None)
for attempt in range(2):
async with self._twitch.request(
"POST", "https://passport.twitch.tv/login", json=payload
) as response:
login_response: JsonType = await response.json()
# 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 in (1000, 5027):
# we've failed bois
session = await self._twitch.get_session()
jar = cast(aiohttp.CookieJar, session.cookie_jar)
assert BASE_URL.host is not None
jar.clear_domain(BASE_URL.host)
logger.debug("Login failed due to CAPTCHA")
raise CaptchaRequired()
elif error_code == 3001:
# wrong password you dummy
logger.debug("Login failed due to incorrect username or password")
gui_print(_("login", "incorrect_login_pass"))
login_form.clear(password=True)
break break
elif error_code in ( except LoginException as exc:
3012, # Invalid authy token if attempt < 5:
3023, # Invalid email code self._twitch.gui.print(exc.args[0])
):
logger.debug("Login failed due to incorrect 2FA code")
if error_code == 3023:
gui_print(_("login", "incorrect_email_code"))
else:
gui_print(_("login", "incorrect_twofa_code"))
login_form.clear(token=True)
break
elif error_code in (
3011, # Authy token needed
3022, # Email code needed
):
# 2FA handling
logger.debug("2FA token required")
email = error_code == 3022
if not token:
# user didn't provide a token, so ask them for it
if email:
gui_print(_("login", "email_code_required"))
else:
gui_print(_("login", "twofa_code_required"))
break
if email:
payload["twitchguard_code"] = token
else:
payload["authy_token"] = token
continue continue
else: raise # reraise on and past the 5th attempt
raise LoginException(login_response["error"])
# Success handling
if "access_token" in login_response:
# we're in bois
self.access_token = cast(str, login_response["access_token"])
logger.debug("Access token granted") logger.debug("Access token granted")
login_form.clear()
return self.access_token return self.access_token
def gql_headers(self, *, integrity: bool) -> JsonType: def gql_headers(self, *, integrity: bool) -> JsonType: