diff --git a/genshin/__main__.py b/genshin/__main__.py index 05134196..629ed987 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -228,7 +228,7 @@ async def lineups(client: genshin.Client, scenario: typing.Optional[str]) -> Non click.echo(f"{click.style('Characters:', bold=True)}") for group, characters in enumerate(lineup.characters, 1): if len(lineup.characters) > 1: - click.echo(f"- Group {group }:") + click.echo(f"- Group {group}:") for character in characters: click.echo(f" - {character.name} ({character.role})") diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py index 243bf3f4..898a4d23 100644 --- a/genshin/client/components/geetest/client.py +++ b/genshin/client/components/geetest/client.py @@ -1,14 +1,14 @@ """Geetest client component.""" -import base64 import json import typing import aiohttp import aiohttp.web -import yarl -from genshin import errors +from genshin import constants, errors +from genshin.client import routes from genshin.client.components import base +from genshin.utility import ds as ds_utility from genshin.utility import geetest as geetest_utility from . import server @@ -16,39 +16,48 @@ __all__ = ["GeetestClient"] -WEB_LOGIN_URL = yarl.URL("https://sg-public-api.hoyolab.com/account/ma-passport/api/webLoginByPassword") - - class GeetestClient(base.BaseClient): """Geetest client component.""" - async def login_with_geetest( - self, account: str, password: str, session_id: str, geetest: typing.Dict[str, str] - ) -> typing.Mapping[str, str]: - """Login with a password and a solved geetest. + async def web_login( + self, + account: str, + password: str, + *, + tokenType: typing.Optional[int] = 6, + geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, + ) -> typing.Dict[str, typing.Any]: + """Login with a password using web endpoint. - Token type is a bitfield of cookie_token, ltoken, stoken. + Returns either data from aigis header or cookies. """ + headers = {**geetest_utility.WEB_LOGIN_HEADERS} + if geetest: + mmt_data = geetest["data"] + session_id = geetest["session_id"] + headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) + payload = { - "account": geetest_utility.encrypt_geetest_password(account), - "password": geetest_utility.encrypt_geetest_password(password), - "token_type": 6, + "account": geetest_utility.encrypt_geetest_credentials(account), + "password": geetest_utility.encrypt_geetest_credentials(password), + "token_type": tokenType, } - # we do not want to use the previous cookie manager sessions - async with aiohttp.ClientSession() as session: async with session.post( - WEB_LOGIN_URL, + routes.WEB_LOGIN_URL.get_url(), json=payload, - headers={ - **geetest_utility.HEADERS, - "x-rpc-aigis": f"{session_id};{base64.b64encode(json.dumps(geetest).encode()).decode()}", - }, + headers=headers, ) as r: data = await r.json() cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + aigis["data"] = json.loads(aigis["data"]) + return aigis + if not data["data"]: errors.raise_for_retcode(data) @@ -59,9 +68,190 @@ async def login_with_geetest( return cookies - async def login_with_password(self, account: str, password: str, *, port: int = 5000) -> typing.Mapping[str, str]: - """Login with a password. + async def app_login( + self, + account: str, + password: str, + *, + geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, + ) -> typing.Dict[str, typing.Any]: + """Login with a password using HoYoLab app endpoint. + + Returns data from aigis header or action_ticket or cookies. + """ + headers = { + **geetest_utility.APP_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["app_login"]), + } + if geetest: + mmt_data = geetest["data"] + session_id = geetest["session_id"] + headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) + + payload = { + "account": geetest_utility.encrypt_geetest_credentials(account), + "password": geetest_utility.encrypt_geetest_credentials(password), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.APP_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + aigis["data"] = json.loads(aigis["data"]) + return aigis + + if data["retcode"] == -3239: + # Email verification required + return json.loads(r.headers["x-rpc-verify"]) + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = { + "stoken": data["data"]["token"]["token"], + "ltuid_v2": data["data"]["user_info"]["aid"], + "ltmid_v2": data["data"]["user_info"]["mid"], + "account_id_v2": data["data"]["user_info"]["aid"], + "account_mid_v2": data["data"]["user_info"]["mid"], + } + self.set_cookies(cookies) + + return cookies + + async def send_verification_email( + self, + ticket: typing.Dict[str, typing.Any], + *, + geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, + ) -> typing.Union[None, typing.Dict[str, typing.Any]]: + """Send verification email. - This will start a webserver. + Returns None if success, aigis headers (mmt/aigis) otherwise. """ - return await server.login_with_app(self, account, password, port=port) + headers = {**geetest_utility.EMAIL_SEND_HEADERS} + if geetest: + mmt_data = geetest["data"] + session_id = geetest["session_id"] + headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.SEND_VERIFICATION_CODE_URL.get_url(), + json={ + "action_type": "verify_for_component", + "action_ticket": ticket["verify_str"]["ticket"], + }, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + aigis["data"] = json.loads(aigis["data"]) + return aigis + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def verify_email(self, code: str, ticket: typing.Dict[str, typing.Any]) -> None: + """Verify email.""" + async with aiohttp.ClientSession() as session: + async with session.post( + routes.VERIFY_EMAIL_URL.get_url(), + json={ + "action_type": "verify_for_component", + "action_ticket": ticket["verify_str"]["ticket"], + "email_captcha": code, + "verify_method": 2, + }, + headers=geetest_utility.EMAIL_VERIFY_HEADERS, + ) as r: + data = await r.json() + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def login_with_password( + self, + account: str, + password: str, + *, + port: int = 5000, + tokenType: typing.Optional[int] = 6, + geetest_solver: typing.Optional[ + typing.Callable[[typing.Dict[str, typing.Any]], typing.Awaitable[typing.Dict[str, typing.Any]]] + ] = None, + ) -> typing.Dict[str, str]: + """Login with a password via web endpoint. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + """ + result = await self.web_login(account, password, tokenType=tokenType) + + if "session_id" not in result: + # Captcha not triggered + return result + + if geetest_solver: + geetest = await geetest_solver(result) + else: + geetest = await server.solve_geetest(result, port=port) + + return await self.web_login(account, password, tokenType=tokenType, geetest=geetest) + + async def login_with_app_password( + self, + account: str, + password: str, + *, + port: int = 5000, + geetest_solver: typing.Optional[ + typing.Callable[[typing.Dict[str, typing.Any]], typing.Awaitable[typing.Dict[str, typing.Any]]] + ] = None, + ) -> typing.Dict[str, str]: + """Login with a password via HoYoLab app endpoint. + + Note that this will start a webserver if either of the + following happens: + + 1. Captcha is triggered and `geetest_solver` is not passed. + 2. Email verification is triggered (can happen if you + first login with a new device). + """ + result = await self.app_login(account, password) + + if "session_id" in result: + # Captcha triggered + if geetest_solver: + geetest = await geetest_solver(result) + else: + geetest = await server.solve_geetest(result, port=port) + + result = await self.app_login(account, password, geetest=geetest) + + if "risk_ticket" in result: + # Email verification required + mmt = await self.send_verification_email(result) + if mmt: + if geetest_solver: + geetest = await geetest_solver(mmt) + else: + geetest = await server.solve_geetest(mmt, port=port) + + await server.verify_email(self, result, port=port) + result = await self.app_login(account, password) + + return result diff --git a/genshin/client/components/geetest/server.py b/genshin/client/components/geetest/server.py index dc781349..639c3fc9 100644 --- a/genshin/client/components/geetest/server.py +++ b/genshin/client/components/geetest/server.py @@ -1,4 +1,4 @@ -"""Aiohttp webserver used for login.""" +"""Aiohttp webserver used for captcha solving and email verification.""" from __future__ import annotations import asyncio @@ -8,70 +8,97 @@ import aiohttp from aiohttp import web -from genshin.errors import raise_for_retcode -from genshin.utility import geetest - from . import client -__all__ = ["login_with_app"] - -INDEX = """ - - - - - - - + - -""" + } + ) + ); + + + """ + if page == "captcha" + else """ + + + + + + + + + """ + ) + GT_URL = "https://raw.githubusercontent.com/GeeTeam/gt3-node-sdk/master/demo/static/libs/gt.js" -async def login_with_app(client: client.GeetestClient, account: str, password: str, *, port: int = 5000) -> typing.Any: - """Create and run an application for handling login.""" +async def launch_webapp( + page: typing.Literal["captcha", "verify-email"], + *, + port: int = 5000, + mmt: typing.Optional[typing.Dict[str, typing.Any]] = None, +) -> typing.Any: + """Create and run a webapp to solve captcha or send verification code.""" routes = web.RouteTableDef() future: asyncio.Future[typing.Any] = asyncio.Future() - mmt_key: str = "" + @routes.get("/captcha") + async def captcha(request: web.Request) -> web.StreamResponse: + return web.Response(body=get_page("captcha"), content_type="text/html") - @routes.get("/") - async def index(request: web.Request) -> web.StreamResponse: - return web.Response(body=INDEX, content_type="text/html") + @routes.get("/verify-email") + async def verify_email(request: web.Request) -> web.StreamResponse: + return web.Response(body=get_page("verify-email"), content_type="text/html") @routes.get("/gt.js") async def gt(request: web.Request) -> web.StreamResponse: @@ -83,33 +110,14 @@ async def gt(request: web.Request) -> web.StreamResponse: @routes.get("/mmt") async def mmt_endpoint(request: web.Request) -> web.Response: - nonlocal mmt_key - - mmt = await geetest.create_mmt(account, password) - if mmt["data"] is None: - raise_for_retcode(mmt) # type: ignore - - mmt_key = mmt["data"] return web.json_response(mmt) - @routes.post("/login") - async def login_endpoint(request: web.Request) -> web.Response: + @routes.post("/send-data") + async def send_data_endpoint(request: web.Request) -> web.Response: body = await request.json() + future.set_result(body) - try: - data = await client.login_with_geetest( - account=account, - password=password, - session_id=body["sid"], - geetest=body["gt"], - ) - except Exception as e: - future.set_exception(e) - return web.json_response({}, status=500) - - future.set_result(data) - - return web.json_response(data) + return web.Response(status=204) app = web.Application() app.add_routes(routes) @@ -118,8 +126,8 @@ async def login_endpoint(request: web.Request) -> web.Response: await runner.setup() site = web.TCPSite(runner, host="localhost", port=port) - print(f"Opened browser in http://localhost:{port}") # noqa - webbrowser.open_new_tab(f"http://localhost:{port}") + print(f"Opening http://localhost:{port}/{page} in browser...") # noqa + webbrowser.open_new_tab(f"http://localhost:{port}/{page}") await site.start() @@ -130,3 +138,25 @@ async def login_endpoint(request: web.Request) -> web.Response: await runner.shutdown() return data + + +async def solve_geetest( + mmt: typing.Dict[str, typing.Any], + *, + port: int = 5000, +) -> typing.Dict[str, typing.Any]: + """Solve a geetest captcha manually.""" + return await launch_webapp("captcha", port=port, mmt=mmt) + + +async def verify_email( + client: client.GeetestClient, + ticket: typing.Dict[str, typing.Any], + *, + port: int = 5000, +) -> None: + """Verify email to login via HoYoLab app endpoint.""" + data = await launch_webapp("verify-email", port=port) + code = data["code"] + + return await client.verify_email(code, ticket) diff --git a/genshin/client/manager/cookie.py b/genshin/client/manager/cookie.py index a76bb226..1780de6e 100644 --- a/genshin/client/manager/cookie.py +++ b/genshin/client/manager/cookie.py @@ -11,6 +11,9 @@ - fetch_cookie_token_info - cookie_token -> cookie_token - login_ticket -> cookie_token +- fetch_cookie_with_stoken_v2 + - stoken (v2) + mid -> ltoken_v2 (token_type=2) + - stoken (v2) + mid -> cookie_token_v2 (token_type=4) """ from __future__ import annotations @@ -19,11 +22,18 @@ import aiohttp import aiohttp.typedefs -from genshin import errors, types +from genshin import constants, errors, types from genshin.client import routes from genshin.client.manager import managers +from genshin.utility import ds as ds_utility -__all__ = ["complete_cookies", "fetch_cookie_token_info", "fetch_cookie_with_cookie", "refresh_cookie_token"] +__all__ = [ + "complete_cookies", + "fetch_cookie_token_info", + "fetch_cookie_with_cookie", + "fetch_cookie_with_stoken_v2", + "refresh_cookie_token", +] async def fetch_cookie_with_cookie( @@ -59,6 +69,42 @@ async def fetch_cookie_with_cookie( return data +async def fetch_cookie_with_stoken_v2( + cookies: managers.CookieOrHeader, + *, + token_types: typing.List[typing.Literal[2, 4]], +) -> typing.Mapping[str, str]: + """Fetch cookie (v2) with an stoken (v2) and mid.""" + cookies = managers.parse_cookie(cookies) + if "ltmid_v2" in cookies: + # The endpoint requires ltmid_v2 to be named mid + cookies["mid"] = cookies["ltmid_v2"] + + url = routes.COOKIE_V2_REFRESH_URL.get_url() + + headers = { + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["app_login"]), + "x-rpc-app_id": "c9oqaq3s3gu8", + } + body = {"dst_token_types": token_types} + + async with aiohttp.ClientSession() as session: + r = await session.request("POST", url, json=body, headers=headers, cookies=cookies) + data = await r.json(content_type=None) + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + cookies = dict() + for token in data["data"]["tokens"]: + if token["token_type"] == 2: + cookies["ltoken_v2"] = token["token"] + elif token["token_type"] == 4: + cookies["cookie_token_v2"] = token["token"] + + return cookies + + async def fetch_cookie_token_info( cookies: managers.CookieOrHeader, *, diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 6befaa2e..e4804618 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -8,10 +8,12 @@ __all__ = [ "ACCOUNT_URL", + "APP_LOGIN_URL", "BBS_REFERER_URL", "BBS_URL", "CALCULATOR_URL", "COMMUNITY_URL", + "COOKIE_V2_REFRESH_URL", "DETAIL_LEDGER_URL", "GACHA_URL", "HK4E_URL", @@ -23,8 +25,10 @@ "Route", "TAKUMI_URL", "TEAPOT_URL", + "VERIFY_EMAIL_URL", "WEBAPI_URL", "WEBSTATIC_URL", + "WEB_LOGIN_URL", "YSULOG_URL", ] @@ -207,3 +211,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: bbs="https://webstatic-sea.mihoyo.com/admin/mi18n/bbs_cn/m11241040191111/m11241040191111-{lang}.json", inquiry="https://mi18n-os.hoyoverse.com/webstatic/admin/mi18n/hk4e_global/m02251421001311/m02251421001311-{lang}.json", ) + +COOKIE_V2_REFRESH_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/token/getBySToken") + +WEB_LOGIN_URL = Route("https://sg-public-api.hoyolab.com/account/ma-passport/api/webLoginByPassword") +APP_LOGIN_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/api/appLoginByPassword") + +SEND_VERIFICATION_CODE_URL = Route( + "https://sg-public-api.hoyoverse.com/account/ma-verifier/api/createEmailCaptchaByActionTicket" +) +VERIFY_EMAIL_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-verifier/api/verifyActionTicketPartly") diff --git a/genshin/constants.py b/genshin/constants.py index 39a40dbf..800d6012 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -26,6 +26,7 @@ DS_SALT = { types.Region.OVERSEAS: "6s25p5ox5y14umn1p61aqyyvbvvl3lrt", types.Region.CHINESE: "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs", + "app_login": "IZPgfb0dRPtBeLuFkdDznSZ6f4wWt6y2", "cn_signin": "9nQiU3AV0rJSIBWgdynfoGMGKaklfbM7", } """Dynamic Secret Salts.""" diff --git a/genshin/utility/geetest.py b/genshin/utility/geetest.py index 0827a8f5..4d535d7d 100644 --- a/genshin/utility/geetest.py +++ b/genshin/utility/geetest.py @@ -3,11 +3,10 @@ import json import typing -import aiohttp - -__all__ = ["create_mmt", "encrypt_geetest_password"] +__all__ = ["encrypt_geetest_credentials"] +# RSA key is the same for app and web login LOGIN_KEY_CERT = b""" -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4PMS2JVMwBsOIrYWRluY @@ -20,7 +19,7 @@ -----END PUBLIC KEY----- """ -HEADERS = { +WEB_LOGIN_HEADERS = { "x-rpc-app_id": "c9oqaq3s3gu8", "x-rpc-client_type": "4", "x-rpc-sdk_version": "2.14.1", @@ -32,36 +31,34 @@ "Referer": "https://account.hoyolab.com/", } +APP_LOGIN_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-app_version": "2.47.0", + "x-rpc-client_type": "2", + "x-rpc-sdk_version": "2.22.0", + "x-rpc-game_biz": "bbs_oversea", + "Origin": "https://account.hoyoverse.com", + "Referer": "https://account.hoyoverse.com/", +} -async def create_mmt(account: str, password: str) -> typing.Mapping[str, typing.Any]: - """Create a new hoyolab mmt.""" - async with aiohttp.ClientSession() as session: - _payload = { - "account": encrypt_geetest_password(account), - "password": encrypt_geetest_password(password), - "token_type": 6, - } - - r = await session.post( - "https://sg-public-api.hoyolab.com/account/ma-passport/api/webLoginByPassword", - json=_payload, - headers=HEADERS, - ) - - data = await r.json() - - if data["retcode"] == -3101: - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis +EMAIL_SEND_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", +} - return {} +EMAIL_VERIFY_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", +} -def encrypt_geetest_password(text: str) -> str: +def encrypt_geetest_credentials(text: str) -> str: """Encrypt text for geetest.""" import rsa public_key = rsa.PublicKey.load_pkcs1_openssl_pem(LOGIN_KEY_CERT) crypto = rsa.encrypt(text.encode("utf-8"), public_key) return base64.b64encode(crypto).decode("utf-8") + + +def get_aigis_header(session_id: str, mmt_data: typing.Dict[str, typing.Any]) -> str: + """Get aigis header.""" + return f"{session_id};{base64.b64encode(json.dumps(mmt_data).encode()).decode()}"