From e2fa750aa8c7b46dc896de7e985342f6f3cde73a Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Thu, 31 Oct 2024 21:52:22 +0100 Subject: [PATCH] Allow for captcha in North America --- bimmer_connected/account.py | 7 +++- bimmer_connected/api/authentication.py | 38 ++++++++++++------ bimmer_connected/const.py | 6 +-- bimmer_connected/tests/test_account.py | 25 ++++++++---- .../source/_static/captcha_north_america.html | 39 +++++++++++++++++++ docs/source/captcha.rst | 15 +++++++ docs/source/index.rst | 1 + 7 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 docs/source/_static/captcha_north_america.html create mode 100644 docs/source/captcha.rst diff --git a/bimmer_connected/account.py b/bimmer_connected/account.py index a1076139..3bba6279 100644 --- a/bimmer_connected/account.py +++ b/bimmer_connected/account.py @@ -53,9 +53,12 @@ class MyBMWAccount: use_metric_units: InitVar[Optional[bool]] = None """Deprecated. All returned values are metric units (km, l).""" + hcaptcha_token: InitVar[Optional[str]] = None + """Optional. Required for North America region.""" + vehicles: List[MyBMWVehicle] = field(default_factory=list, init=False) - def __post_init__(self, password, log_responses, observer_position, verify, use_metric_units): + def __post_init__(self, password, log_responses, observer_position, verify, use_metric_units, hcaptcha_token): """Initialize the account.""" if use_metric_units is not None: @@ -66,7 +69,7 @@ def __post_init__(self, password, log_responses, observer_position, verify, use_ if self.config is None: self.config = MyBMWClientConfiguration( - MyBMWAuthentication(self.username, password, self.region, verify=verify), + MyBMWAuthentication(self.username, password, self.region, verify=verify, hcaptcha_token=hcaptcha_token), log_responses=log_responses, observer_position=observer_position, verify=verify, diff --git a/bimmer_connected/api/authentication.py b/bimmer_connected/api/authentication.py index 324f8635..882db9f6 100644 --- a/bimmer_connected/api/authentication.py +++ b/bimmer_connected/api/authentication.py @@ -53,6 +53,7 @@ def __init__( expires_at: Optional[datetime.datetime] = None, refresh_token: Optional[str] = None, gcid: Optional[str] = None, + hcaptcha_token: Optional[str] = None, verify: httpx._types.VerifyTypes = True, ): self.username: str = username @@ -64,6 +65,7 @@ def __init__( self.session_id: str = str(uuid4()) self._lock: Optional[asyncio.Lock] = None self.gcid: Optional[str] = gcid + self.hcaptcha_token: Optional[str] = hcaptcha_token # Use external SSL context. Required in Home Assistant due to event loop blocking when httpx loads # SSL certificates from disk. If not given, uses httpx defaults. self.verify: Optional[httpx._types.VerifyTypes] = verify @@ -183,19 +185,31 @@ async def _login_row_na(self): "code_challenge_method": "S256", } + authenticate_headers = {} + if self.region == Regions.NORTH_AMERICA: + if not self.hcaptcha_token: + raise MyBMWAPIError("Missing hCaptcha token for North America login") + authenticate_headers = { + "hcaptchatoken": self.hcaptcha_token, + } # Call authenticate endpoint first time (with user/pw) and get authentication - response = await client.post( - authenticate_url, - data=dict( - oauth_base_values, - **{ - "grant_type": "authorization_code", - "username": self.username, - "password": self.password, - }, - ), - ) - authorization = httpx.URL(response.json()["redirect_to"]).params["authorization"] + try: + response = await client.post( + authenticate_url, + headers=authenticate_headers, + data=dict( + oauth_base_values, + **{ + "grant_type": "authorization_code", + "username": self.username, + "password": self.password, + }, + ), + ) + authorization = httpx.URL(response.json()["redirect_to"]).params["authorization"] + finally: + # Always reset hCaptcha token after first login attempt + self.hcaptcha_token = None # With authorization, call authenticate endpoint second time to get code response = await client.post( diff --git a/bimmer_connected/const.py b/bimmer_connected/const.py index a007f66c..3ce11169 100644 --- a/bimmer_connected/const.py +++ b/bimmer_connected/const.py @@ -39,9 +39,9 @@ class Regions(str, Enum): } APP_VERSIONS = { - Regions.NORTH_AMERICA: "4.7.2(35379)", - Regions.REST_OF_WORLD: "4.7.2(35379)", - Regions.CHINA: "4.7.2(35379)", + Regions.NORTH_AMERICA: "4.9.2(36892)", + Regions.REST_OF_WORLD: "4.9.2(36892)", + Regions.CHINA: "4.9.2(36892)", } HTTPX_TIMEOUT = 30.0 diff --git a/bimmer_connected/tests/test_account.py b/bimmer_connected/tests/test_account.py index 5d27fa23..784976b6 100644 --- a/bimmer_connected/tests/test_account.py +++ b/bimmer_connected/tests/test_account.py @@ -13,7 +13,7 @@ from bimmer_connected.api.authentication import MyBMWAuthentication, MyBMWLoginRetry from bimmer_connected.api.client import MyBMWClient from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.const import ATTR_CAPABILITIES, VEHICLES_URL, CarBrands +from bimmer_connected.const import ATTR_CAPABILITIES, VEHICLES_URL, CarBrands, Regions from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError from . import ( @@ -32,13 +32,29 @@ @pytest.mark.asyncio -async def test_login_row_na(bmw_fixture: respx.Router): +async def test_login_row(bmw_fixture: respx.Router): """Test the login flow.""" account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING)) await account.get_vehicles() assert account is not None +@pytest.mark.asyncio +async def test_login_na(bmw_fixture: respx.Router): + """Test the login flow for North America.""" + account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA, hcaptcha_token="SOME_TOKEN") + await account.get_vehicles() + assert account is not None + + +@pytest.mark.asyncio +async def test_login_na_without_hcaptcha(bmw_fixture: respx.Router): + """Test the login flow.""" + with pytest.raises(MyBMWAPIError): + account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA) + await account.get_vehicles() + + @pytest.mark.asyncio async def test_login_refresh_token_row_na_expired(bmw_fixture: respx.Router): """Test the login flow using refresh_token.""" @@ -745,8 +761,3 @@ async def test_pillow_unavailable(monkeypatch: pytest.MonkeyPatch, bmw_fixture: await account.get_vehicles() assert account is not None assert len(account.vehicles) > 0 - - account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name("north_america")) - await account.get_vehicles() - assert account is not None - assert len(account.vehicles) > 0 diff --git a/docs/source/_static/captcha_north_america.html b/docs/source/_static/captcha_north_america.html new file mode 100644 index 00000000..982ddcbd --- /dev/null +++ b/docs/source/_static/captcha_north_america.html @@ -0,0 +1,39 @@ + + + + + + Form with hCaptcha + + +

+
+
+
+ +

+ +
+ + + +
+
+

+ + + \ No newline at end of file diff --git a/docs/source/captcha.rst b/docs/source/captcha.rst new file mode 100644 index 00000000..f5012c64 --- /dev/null +++ b/docs/source/captcha.rst @@ -0,0 +1,15 @@ +Captcha (North America) +======================= + +Login to the :code:`north_america` region requires a captcha to be solved. Submit below form and use the returned token when creating the account object. + +:: + + account = MyBMWAccount(USERNAME, PASSWORD, Regions.REST_OF_WORLD, hcaptcha_token=HCAPTCHA_TOKEN) + +.. note:: + Only the first login requires a captcha to be solved. Follow-up logins using refresh token do not require a captcha. + This requires the tokens to be stored in a file (see :ref:`cli`) or in the python object itself. + +.. raw:: html + :file: _static/captcha_north_america.html \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 2765f4ae..738a79c8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ :maxdepth: 2 :glob: + captcha development/*