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 1/4] 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/* From 9a59e72723ff13b7823f88af87af3918bc383844 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Thu, 31 Oct 2024 22:29:23 +0100 Subject: [PATCH 2/4] Fix for sphinx-rtd-theme>=3.0 --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 787c9625..37d7ccc6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -86,7 +86,7 @@ # documentation. # html_theme_options = { - 'display_version': True, + 'version_selector': True, } # Add any paths that contain custom static files (such as style sheets) here, From bf246840ca6dbb0bb6884cecc04cdacaa13ca967 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:08:41 +0100 Subject: [PATCH 3/4] Add MyBMWCaptchaMissingError --- bimmer_connected/api/authentication.py | 4 ++-- bimmer_connected/models.py | 4 ++++ bimmer_connected/tests/test_account.py | 10 ++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/bimmer_connected/api/authentication.py b/bimmer_connected/api/authentication.py index 882db9f6..55037e3c 100644 --- a/bimmer_connected/api/authentication.py +++ b/bimmer_connected/api/authentication.py @@ -34,7 +34,7 @@ OAUTH_CONFIG_URL, X_USER_AGENT, ) -from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.models import MyBMWAPIError, MyBMWCaptchaMissingError EXPIRES_AT_OFFSET = datetime.timedelta(seconds=HTTPX_TIMEOUT * 2) @@ -188,7 +188,7 @@ async def _login_row_na(self): authenticate_headers = {} if self.region == Regions.NORTH_AMERICA: if not self.hcaptcha_token: - raise MyBMWAPIError("Missing hCaptcha token for North America login") + raise MyBMWCaptchaMissingError("Missing hCaptcha token for North America login") authenticate_headers = { "hcaptchatoken": self.hcaptcha_token, } diff --git a/bimmer_connected/models.py b/bimmer_connected/models.py index b0b78b68..d2f8bf78 100644 --- a/bimmer_connected/models.py +++ b/bimmer_connected/models.py @@ -201,6 +201,10 @@ class MyBMWAuthError(MyBMWAPIError): """Auth-related error from BMW API (HTTP status codes 401 and 403).""" +class MyBMWCaptchaMissingError(MyBMWAPIError): + """Indicate missing captcha for login.""" + + class MyBMWQuotaError(MyBMWAPIError): """Quota exceeded on BMW API.""" diff --git a/bimmer_connected/tests/test_account.py b/bimmer_connected/tests/test_account.py index 784976b6..69b73de8 100644 --- a/bimmer_connected/tests/test_account.py +++ b/bimmer_connected/tests/test_account.py @@ -14,7 +14,13 @@ 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, Regions -from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError +from bimmer_connected.models import ( + GPSPosition, + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, + MyBMWQuotaError, +) from . import ( RESPONSE_DIR, @@ -50,7 +56,7 @@ async def test_login_na(bmw_fixture: respx.Router): @pytest.mark.asyncio async def test_login_na_without_hcaptcha(bmw_fixture: respx.Router): """Test the login flow.""" - with pytest.raises(MyBMWAPIError): + with pytest.raises(MyBMWCaptchaMissingError): account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA) await account.get_vehicles() From 0d7184412f12027be1dbec57680d80c0ea597ddf Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:08:49 +0100 Subject: [PATCH 4/4] Add sitekey to const --- bimmer_connected/const.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bimmer_connected/const.py b/bimmer_connected/const.py index 3ce11169..eda687d9 100644 --- a/bimmer_connected/const.py +++ b/bimmer_connected/const.py @@ -38,6 +38,11 @@ class Regions(str, Enum): Regions.REST_OF_WORLD: "NGYxYzg1YTMtNzU4Zi1hMzdkLWJiYjYtZjg3MDQ0OTRhY2Zh", } +HCAPTCHA_SITE_KEYS = { + Regions.NORTH_AMERICA: "dc24de9a-9844-438b-b542-60067ff4dbe9", + "_": "10000000-ffff-ffff-ffff-000000000001", +} + APP_VERSIONS = { Regions.NORTH_AMERICA: "4.9.2(36892)", Regions.REST_OF_WORLD: "4.9.2(36892)",