From 7f58036560b82de925582b3bd484c86e73cd9d0b Mon Sep 17 00:00:00 2001 From: Clement Julia Date: Sun, 8 Jun 2025 00:49:11 +0200 Subject: [PATCH 1/2] 2fa-fix 2fa fix attempt 1 --- moddb/__init__.py | 3 +- moddb/client.py | 90 ++++++++++++++++++++++++++++++++++++++++++++- moddb/errors.py | 8 ++++ moddb/pages/base.py | 10 +++-- moddb/utils.py | 25 +++++++++---- 5 files changed, 122 insertions(+), 14 deletions(-) diff --git a/moddb/__init__.py b/moddb/__init__.py index f5294df..2a925c4 100644 --- a/moddb/__init__.py +++ b/moddb/__init__.py @@ -2,7 +2,7 @@ import requests from .base import front_page, login, logout, parse_page, parse_results, rss, search, search_tags -from .client import Client, Thread +from .client import Client, TwoFactorAuthClient, Thread from .enums import * from .pages import * from .utils import BASE_URL, LOGGER, Object, get_page, request, soup @@ -23,6 +23,7 @@ "search", "search_tags", "Client", + "TwoFactorAuthClient", "Thread", "BASE_URL", "LOGGER", diff --git a/moddb/client.py b/moddb/client.py index 863f0e3..2da3e33 100644 --- a/moddb/client.py +++ b/moddb/client.py @@ -13,7 +13,7 @@ from .base import parse_page from .boxes import ResultList, Thumbnail, _parse_results from .enums import Status, ThumbnailType -from .errors import ModdbException +from .errors import AuthError, ModdbException from .pages import Member from .utils import ( BASE_URL, @@ -22,6 +22,7 @@ GLOBAL_THROTLE, LOGGER, concat_docs, + create_login_payload, generate_hash, generate_login_cookies, get, @@ -30,6 +31,7 @@ get_sitearea, get_siteareaid, join, + prepare_request, raise_for_status, ratelimit, soup, @@ -1176,3 +1178,89 @@ def downvote_tag(self, tag: Tag) -> bool: Whether the downvote was successful """ return self._vote_tag(tag, 1) + + +class TwoFactorAuthClient(Client): + """A subclass of client to be used when facing 2FA requirements.""" + + def __init__(self, username: str, password: str): + self.username = username + self.password = password + + session = requests.Session() + session.mount("http://", CurlCffiAdapter()) + session.mount("https://", CurlCffiAdapter()) + self._session = session + self.member: Member = None + + self._2fa_request: requests.Response = None + + def __repr__(self): + return f"" + + def login(self) -> bool: + """Log the user in + + Returns + -------- + bool + True if the login was successful, false it the login requires 2FA + """ + data, resp = create_login_payload(self.username, self.password, self._session) + + req = requests.Request("POST", f"{BASE_URL}/members/login", data=data, cookies=resp.cookies) + login = self._session.send(prepare_request(req, self._session), allow_redirects=False) + + if "members2faemailhash" in login.text: + self._2fa_request = login + return False + + if "freeman" not in login.cookies: + raise ValueError(f"Login failed for user {self.username}") + + self._session.cookies = login.cookies + + self.member = Member( + soup(self._request("GET", f"{BASE_URL}/members/{self.username.replace('_', '-')}").text) + ) + return True + + def submit_2fa_code(self, code: str) -> Member: + """Submit the 2FA code sent to the user being logged in. Only works if called + after `login` + + Returns + -------- + Member + The logged in member + """ + if self._2fa_request is None: + raise ValueError("Call login first") + + html = soup(self._2fa_request.text) + form = html.find("form", action="https://www.moddb.com/members/login2fa/#membersform") + + data = { + "rememberme": "1", + "referer": "", + "2faemaildomain": form.find("input", id="members2faemaildomain")["value"], + "2faemailhash": form.find("input", id="members2faemailhash")["value"], + "2faemailcode": code, + } + + req = requests.Request( + "POST", f"{BASE_URL}/members/login2fa", data=data, cookies=self._2fa_request.cookies + ) + login = self._session.send(prepare_request(req, self._session), allow_redirects=False) + + if "freeman" not in login.cookies: + raise ValueError(f"Login failed for user {self.username}") + + self._session.cookies = login.cookies + self._2fa_request = None + + self.member = Member( + soup(self._request("GET", f"{BASE_URL}/members/{self.username.replace('_', '-')}").text) + ) + + return self.member diff --git a/moddb/errors.py b/moddb/errors.py index 0d16b12..5682492 100644 --- a/moddb/errors.py +++ b/moddb/errors.py @@ -20,3 +20,11 @@ def __init__(self, message, remaining) -> None: super().__init__(message) self.remaining = remaining + + +class AuthError(ModdbException): + """The user you are trying to login with requires 2FA to login. Use + the TwoFactorAuthClient object to do so. + """ + + pass diff --git a/moddb/pages/base.py b/moddb/pages/base.py index 68f696f..8c76adc 100644 --- a/moddb/pages/base.py +++ b/moddb/pages/base.py @@ -59,17 +59,19 @@ def __init__(self, html: BeautifulSoup): ), lambda: int(html.find("input", attrs={"name": "siteareaid"})["value"]), lambda: int(html.find("meta", property="og:image")["content"].split("/")[-2]), - lambda: re.match( + lambda: re.findall( r"https:\/\/www\.moddb\.com\/html\/scripts\/autocomplete\.php\?a=mentions&p=home&l=6&u=(\d*)", str(html), - ).group(1), + )[0], ] ): try: self.id = func() break - except (AttributeError, TypeError): - LOGGER.warning("Failed to get id from method %s for member %s", index, self.name) + except (AttributeError, TypeError) as e: + LOGGER.warning( + "Failed to get id from method %s for member %s: %s", index, self.name, e + ) else: raise AttributeError(f"Failed to get id from member {self.name}") diff --git a/moddb/utils.py b/moddb/utils.py index 004ec71..001540b 100644 --- a/moddb/utils.py +++ b/moddb/utils.py @@ -17,7 +17,7 @@ from requests import utils from .enums import MediaCategory, ThumbnailType -from .errors import AwaitingAuthorisation, ModdbException, Ratelimited +from .errors import AuthError, AwaitingAuthorisation, ModdbException, Ratelimited LOGGER = logging.getLogger("moddb") BASE_URL = "https://www.moddb.com" @@ -216,6 +216,21 @@ def generate_login_cookies(username: str, password: str, session: requests.Sessi if session is None: session = sys.modules["moddb"].SESSION + data, resp = create_login_payload(username, password, session) + + req = requests.Request("POST", f"{BASE_URL}/members/login", data=data, cookies=resp.cookies) + login = session.send(prepare_request(req, session), allow_redirects=False) + + if "members2faemailhash" in login.text: + raise AuthError("2FA required, use TwoFactorAuthClient") + + if "freeman" not in login.cookies: + raise ValueError(f"Login failed for user {username}") + + return login.cookies + + +def create_login_payload(username: str, password: str, session: requests.Session): req = requests.Request("GET", f"{BASE_URL}/members/login") resp = session.send(prepare_request(req, session)) resp.raise_for_status() @@ -235,13 +250,7 @@ def generate_login_cookies(username: str, password: str, session: requests.Sessi "members": "Sign in", } - req = requests.Request("POST", f"{BASE_URL}/members/login", data=data, cookies=resp.cookies) - login = session.send(prepare_request(req, session), allow_redirects=False) - - if "freeman" not in login.cookies: - raise ValueError(f"Login failed for user {username}") - - return login.cookies + return data, resp @ratelimit(GLOBAL_THROTLE, GLOBAL_LIMITER) From 012342eb726494c90280d61e17b8795df3997bcb Mon Sep 17 00:00:00 2001 From: Clement Julia Date: Sun, 8 Jun 2025 08:56:38 +0200 Subject: [PATCH 2/2] 2fa-fix tweaking payload and removing login from test suite due to 2FA --- .github/workflows/python-package.yml | 7 ------- LICENSE | 2 +- docs/source/client.rst | 7 +++++++ docs/source/conf.py | 2 +- docs/source/index.rst | 16 ++++++++++++++++ moddb/client.py | 5 +++-- run_tests.sh | 4 ++-- 7 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 211b864..9f49b06 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -33,10 +33,6 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - - name: Echo env - run: | - echo ${{ github.event.pull_request.base.ref }} - echo ${{ github.event.pull_request.base.ref_name }} - name: Black and flake run: | bash run_lint.sh @@ -65,6 +61,3 @@ jobs: name: Test result path: report.html if: ${{ always() }} - - - diff --git a/LICENSE b/LICENSE index d8bcaf2..2f90256 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Clement Julia +Copyright (c) 2025 Clement Julia Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/source/client.rst b/docs/source/client.rst index 986392f..a12ff7f 100644 --- a/docs/source/client.rst +++ b/docs/source/client.rst @@ -13,6 +13,13 @@ Client :inherited-members: +TwoFactorAuthClient +-------------------- +.. autoclass:: moddb.client.TwoFactorAuthClient + :members: + :inherited-members: + + ThreadThumbnail ---------------- .. autoclass:: moddb.client.ThreadThumbnail diff --git a/docs/source/conf.py b/docs/source/conf.py index 353fc86..e0387f2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,7 +21,7 @@ # -- Project information ----------------------------------------------------- project = "moddb" -copyright = "2022, Clement Julia" +copyright = "2025, Clement Julia" author = "Clement Julia" # The short X.Y version diff --git a/docs/source/index.rst b/docs/source/index.rst index 7c6e758..d685f5c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -80,6 +80,22 @@ that mod page you would be forced to completly parse the result page on your own See more snippets there: :ref:`snippets-ref`. +Two Factor Authentication +-------------------------- +Sometimes ModDB will ask you for a code sent to your email when logging from a new device. There is no +way to circuvement this. As such the library provides a class to do this handshake:: + + >> import moddb + >> e = moddb.TwoFactorAuthClient("MyUser", "*****") + >> e.login() + False + >> e.submit_2fa_code("AZADV") + < Member > + + +This class' `login` method returns false if 2FA is required instead of erroring, allowing you elegantly +check for the code and to send it in a second request. + Searching ---------- diff --git a/moddb/client.py b/moddb/client.py index 2da3e33..f29efb4 100644 --- a/moddb/client.py +++ b/moddb/client.py @@ -13,7 +13,7 @@ from .base import parse_page from .boxes import ResultList, Thumbnail, _parse_results from .enums import Status, ThumbnailType -from .errors import AuthError, ModdbException +from .errors import ModdbException from .pages import Member from .utils import ( BASE_URL, @@ -1242,10 +1242,11 @@ def submit_2fa_code(self, code: str) -> Member: data = { "rememberme": "1", - "referer": "", + "referer": "/", "2faemaildomain": form.find("input", id="members2faemaildomain")["value"], "2faemailhash": form.find("input", id="members2faemailhash")["value"], "2faemailcode": code, + "members": "Verify", } req = requests.Request( diff --git a/run_tests.sh b/run_tests.sh index c7c33ba..5c6d8ac 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,6 +1,6 @@ if [ ${1:-not_full} == "full" ] then - python -m pytest -k "main" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "${@:2}" + python -m pytest -k "main and not test_client and not TestLogin" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "${@:2}" else - python -m pytest -k "not main and not test_client" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "$@" + python -m pytest -k "not main and not test_client and not TestLogin" --log-level="INFO" --html=report.html --self-contained-html --record-mode=new_episodes -vv "$@" fi