From 76afc281e881aba7c2a5b9c5c795660b2314c0aa Mon Sep 17 00:00:00 2001 From: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat, 1 Jun 2024 01:52:27 +0300 Subject: [PATCH] Add HoYoLab geetest handling (#188) --- genshin/client/components/auth/client.py | 50 ++++++++++++++++++------ genshin/client/components/auth/server.py | 12 +++--- genshin/client/manager/managers.py | 7 +--- genshin/client/routes.py | 8 +++- genshin/constants.py | 14 +++++-- genshin/errors.py | 29 ++++++++------ genshin/utility/auth.py | 17 ++++++-- genshin/utility/ds.py | 9 ++--- tests/client/components/test_daily.py | 4 +- 9 files changed, 98 insertions(+), 52 deletions(-) diff --git a/genshin/client/components/auth/client.py b/genshin/client/components/auth/client.py index 73223aa1..0bbe36b9 100644 --- a/genshin/client/components/auth/client.py +++ b/genshin/client/components/auth/client.py @@ -6,7 +6,7 @@ import aiohttp -from genshin import errors, types +from genshin import constants, errors, types from genshin.client import routes from genshin.client.components import base from genshin.client.manager import managers @@ -19,10 +19,9 @@ QRLoginResult, WebLoginResult, ) -from genshin.models.auth.geetest import MMT, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult +from genshin.models.auth.geetest import MMT, MMTResult, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult from genshin.models.auth.qrcode import QRCodeStatus from genshin.models.auth.verification import ActionTicket -from genshin.types import Game from genshin.utility import auth as auth_utility from genshin.utility import ds as ds_utility @@ -273,24 +272,24 @@ async def login_with_qrcode(self) -> QRLoginResult: self.set_cookies(cookies) return QRLoginResult(**cookies) - @base.region_specific(types.Region.CHINESE) @managers.no_multi async def create_mmt(self) -> MMT: """Create a geetest challenge.""" - is_genshin = self.game is Game.GENSHIN + if self.default_game is None: + raise ValueError("No default game set.") + headers = { - "DS": ds_utility.generate_create_geetest_ds(), - "x-rpc-challenge_game": "2" if is_genshin else "6", - "x-rpc-page": "v4.1.5-ys_#ys" if is_genshin else "v1.4.1-rpg_#/rpg", - "x-rpc-tool-verison": "v4.1.5-ys" if is_genshin else "v1.4.1-rpg", - **auth_utility.CREATE_MMT_HEADERS, + "DS": ds_utility.generate_geetest_ds(self.region), + **auth_utility.CREATE_MMT_HEADERS[self.region], } + url = routes.CREATE_MMT_URL.get_url(self.region) + if self.region is types.Region.OVERSEAS: + url = url.update_query(app_key=constants.GEETEST_RECORD_KEYS[self.default_game]) + assert isinstance(self.cookie_manager, managers.CookieManager) async with self.cookie_manager.create_session() as session: - async with session.get( - routes.CREATE_MMT_URL.get_url(), headers=headers, cookies=self.cookie_manager.cookies - ) as r: + async with session.get(url, headers=headers, cookies=self.cookie_manager.cookies) as r: data = await r.json() if not data["data"]: @@ -298,6 +297,31 @@ async def create_mmt(self) -> MMT: return MMT(**data["data"]) + @base.region_specific(types.Region.OVERSEAS) + @managers.no_multi + async def verify_mmt(self, mmt_result: MMTResult) -> None: + """Verify a geetest challenge.""" + if self.default_game is None: + raise ValueError("No default game set.") + + headers = { + "DS": ds_utility.generate_geetest_ds(self.region), + **auth_utility.CREATE_MMT_HEADERS[self.region], + } + + body = mmt_result.dict() + body["app_key"] = constants.GEETEST_RECORD_KEYS[self.default_game] + + assert isinstance(self.cookie_manager, managers.CookieManager) + async with self.cookie_manager.create_session() as session: + async with session.post( + routes.VERIFY_MMT_URL.get_url(), json=body, headers=headers, cookies=self.cookie_manager.cookies + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + @base.region_specific(types.Region.OVERSEAS) async def os_game_login( self, diff --git a/genshin/client/components/auth/server.py b/genshin/client/components/auth/server.py index eb7789d1..d6ef8248 100644 --- a/genshin/client/components/auth/server.py +++ b/genshin/client/components/auth/server.py @@ -25,7 +25,7 @@ __all__ = ["PAGES", "enter_code", "launch_webapp", "solve_geetest"] -PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "captcha-v4", "enter-code"], str]] = { +PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "enter-code"], str]] = { "captcha": """ @@ -44,25 +44,25 @@ gt: mmt.gt, challenge: mmt.challenge, new_captcha: mmt.new_captcha, - api_server: '{api_server}', + api_server: "{api_server}", https: /^https/i.test(window.location.protocol), product: "bind", - lang: '{lang}', + lang: "{lang}", } : { captchaId: mmt.gt, riskType: mmt.risk_type, userInfo: mmt.session_id ? JSON.stringify({ mmt_key: mmt.session_id }) : undefined, - api_server: '{api_server}', + api_server: "{api_server}", product: "bind", - language: '{lang}', + language: "{lang}", }; initGeetest( initParams, (captcha) => { captcha.onReady(() => { - captcha.verify(); + geetestVersion == 3 ? captcha.verify() : captcha.showCaptcha(); }); captcha.onSuccess(() => { fetch("/send-data", { diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index 7b3b2704..7f484918 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -17,8 +17,6 @@ from genshin.client import ratelimit from genshin.utility import fs as fs_utility -from ...constants import MIYOUSHE_GEETEST_RETCODES - _LOGGER = logging.getLogger(__name__) __all__ = [ @@ -153,10 +151,7 @@ async def _request( cookies.update(new_cookies) _LOGGER.debug("Updating cookies for %s: %s", get_cookie_identifier(cookies), new_keys) - errors.check_for_geetest(data) - - if data["retcode"] in MIYOUSHE_GEETEST_RETCODES: - raise errors.MiyousheGeetestError(data, {k: morsel.value for k, morsel in response.cookies.items()}) + errors.check_for_geetest(data, {k: morsel.value for k, morsel in response.cookies.items()}) if data["retcode"] == 0: return data["data"] diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 8b8c2282..9f9e26c1 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -32,6 +32,7 @@ "TAKUMI_URL", "TEAPOT_URL", "VERIFY_EMAIL_URL", + "VERIFY_MMT_URL", "WEBAPI_URL", "WEBSTATIC_URL", "WEB_LOGIN_URL", @@ -239,10 +240,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: CREATE_QRCODE_URL = Route("https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/fetch") CHECK_QRCODE_URL = Route("https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/query") -CREATE_MMT_URL = Route( - "https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false" +CREATE_MMT_URL = InternationalRoute( + overseas="https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/createGeetest?is_high=true", + chinese="https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false", ) +VERIFY_MMT_URL = Route("https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/verifyGeetest") + GAME_RISKY_CHECK_URL = InternationalRoute( overseas="https://api-account-os.hoyoverse.com/account/risky/api/check", chinese="https://gameapi-account.mihoyo.com/account/risky/api/check", diff --git a/genshin/constants.py b/genshin/constants.py index 02d94d1b..340bc61f 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -2,7 +2,7 @@ from . import types -__all__ = ["LANGS"] +__all__ = ["APP_IDS", "APP_KEYS", "DS_SALT", "GEETEST_RETCODES", "LANGS"] LANGS = { @@ -33,8 +33,8 @@ } """Dynamic Secret Salts.""" -MIYOUSHE_GEETEST_RETCODES = {10035, 5003, 10041, 1034} -"""API error codes that indicate a Geetest was triggered during this Miyoushe API request.""" +GEETEST_RETCODES = {10035, 5003, 10041, 1034} +"""API error codes that indicate a Geetest was triggered during the API request.""" APP_KEYS = { types.Game.GENSHIN: { @@ -75,3 +75,11 @@ }, } """App IDs used for game login.""" + +GEETEST_RECORD_KEYS = { + types.Game.GENSHIN: "hk4e_game_record", + types.Game.STARRAIL: "hkrpg_game_record", + types.Game.HONKAI: "bh3_game_record", + types.Game.ZZZ: "nap_game_record", +} +"""Keys used to submit geetest result.""" diff --git a/genshin/errors.py b/genshin/errors.py index 611085e8..7cb796dc 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -2,6 +2,8 @@ import typing +from genshin.constants import GEETEST_RETCODES + __all__ = [ "ERRORS", "AccountNotFound", @@ -9,12 +11,12 @@ "AuthkeyException", "AuthkeyTimeout", "CookieException", + "DailyGeetestTriggered", "DataNotPublic", - "GeetestTriggered", + "GeetestError", "GenshinException", "InvalidAuthkey", "InvalidCookies", - "MiyousheGeetestError", "RedemptionClaimed", "RedemptionCooldown", "RedemptionException", @@ -114,8 +116,8 @@ class AlreadyClaimed(GenshinException): msg = "Already claimed the daily reward today." -class GeetestTriggered(GenshinException): - """Geetest triggered.""" +class DailyGeetestTriggered(GenshinException): + """Geetest triggered during daily reward claim.""" msg = "Geetest triggered during daily reward claim." @@ -187,8 +189,8 @@ class WrongOTP(GenshinException): msg = "The provided OTP code is wrong." -class MiyousheGeetestError(GenshinException): - """Geetest triggered during Miyoushe API request.""" +class GeetestError(GenshinException): + """Geetest triggered during the battle chronicle API request.""" def __init__( self, @@ -198,7 +200,7 @@ def __init__( self.cookies = cookies super().__init__(response) - msg = "Geetest triggered during Miyoushe API request." + msg = "Geetest triggered during the battle chronicle API request." class OTPRateLimited(GenshinException): @@ -340,12 +342,15 @@ def raise_for_retcode(data: typing.Dict[str, typing.Any]) -> typing.NoReturn: raise GenshinException(data) -def check_for_geetest(response: typing.Dict[str, typing.Any]) -> None: - """Check if geetest was triggered and raise an error.""" - if not response.get("data"): # if is an error +def check_for_geetest(data: typing.Dict[str, typing.Any], cookies: typing.Mapping[str, typing.Any]) -> None: + """Check if geetest was triggered during the request and raise an error if so.""" + if data["retcode"] in GEETEST_RETCODES: + raise GeetestError(data, cookies) + + if not data.get("data"): # if is an error return - gt_result = response["data"].get("gt_result", response["data"]) + gt_result = data["data"].get("gt_result", data["data"]) if ( gt_result.get("risk_code") != 0 @@ -353,4 +358,4 @@ def check_for_geetest(response: typing.Dict[str, typing.Any]) -> None: and gt_result.get("challenge") and gt_result.get("success") != 0 ): - raise GeetestTriggered(response, gt=gt_result.get("gt"), challenge=gt_result.get("challenge")) + raise DailyGeetestTriggered(data, gt=gt_result.get("gt"), challenge=gt_result.get("challenge")) diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py index 8a7f1a0e..618a8292 100644 --- a/genshin/utility/auth.py +++ b/genshin/utility/auth.py @@ -6,7 +6,7 @@ import typing from hashlib import sha256 -from genshin import constants +from genshin import constants, types __all__ = ["encrypt_credentials", "generate_sign"] @@ -70,8 +70,19 @@ } CREATE_MMT_HEADERS = { - "x-rpc-app_version": "2.60.1", - "x-rpc-client_type": "5", + types.Region.OVERSEAS: { + "x-rpc-challenge_path": "https://bbs-api-os.hoyolab.com/game_record/app/hkrpg/api/challenge", + "x-rpc-app_version": "2.55.0", + "x-rpc-challenge_game": "6", + "x-rpc-client_type": "5", + }, + types.Region.CHINESE: { + "x-rpc-app_version": "2.60.1", + "x-rpc-client_type": "5", + "x-rpc-challenge_game": "6", + "x-rpc-page": "v1.4.1-rpg_#/rpg", + "x-rpc-tool-version": "v1.4.1-rpg", + }, } DEVICE_ID = "D6AF5103-D297-4A01-B86A-87F87DS5723E" diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index 18bd619c..2a9e3164 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -11,8 +11,8 @@ __all__ = [ "generate_cn_dynamic_secret", - "generate_create_geetest_ds", "generate_dynamic_secret", + "generate_geetest_ds", "generate_passport_ds", "get_ds_headers", ] @@ -78,10 +78,9 @@ def generate_passport_ds(body: typing.Mapping[str, typing.Any]) -> str: return result -def generate_create_geetest_ds() -> str: - """Create a dynamic secret for Miyoushe createVerification API endpoint.""" - salt = constants.DS_SALT[types.Region.CHINESE] +def generate_geetest_ds(region: types.Region) -> str: + """Create a dynamic secret for geetest API endpoint.""" t = int(time.time()) r = random.randint(100000, 200000) - h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest() + h = hashlib.md5(f"salt={constants.DS_SALT[region]}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest() return f"{t},{r},{h}" diff --git a/tests/client/components/test_daily.py b/tests/client/components/test_daily.py index 95dfe5cb..9160a4ab 100644 --- a/tests/client/components/test_daily.py +++ b/tests/client/components/test_daily.py @@ -13,7 +13,7 @@ async def test_daily_reward(lclient: genshin.Client): try: reward = await lclient.claim_daily_reward() - except genshin.GeetestTriggered: + except genshin.DailyGeetestTriggered: pytest.skip("Geetest triggered on daily reward.") except genshin.AlreadyClaimed: assert signed_in @@ -35,7 +35,7 @@ async def test_starrail_daily_reward(lclient: genshin.Client): try: reward = await lclient.claim_daily_reward(game=genshin.types.Game.STARRAIL) - except genshin.GeetestTriggered: + except genshin.DailyGeetestTriggered: pytest.skip("Geetest triggered on daily reward.") except genshin.AlreadyClaimed: assert signed_in