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/6] 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/6] 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/6] 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/6] 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)", From 8d0092ad94e0b98eced85532126d5fc6e5b1c99e Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Nov 2024 11:05:08 +0100 Subject: [PATCH 5/6] Add --captcha-token to CLI --- bimmer_connected/cli.py | 7 +++++-- bimmer_connected/tests/test_cli.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/bimmer_connected/cli.py b/bimmer_connected/cli.py index a23d3b81..f74e5335 100644 --- a/bimmer_connected/cli.py +++ b/bimmer_connected/cli.py @@ -40,7 +40,7 @@ def main_parser() -> argparse.ArgumentParser: ) parser.add_argument("--disable-oauth-store", help="Disable storing the OAuth2 tokens.", action="store_true") - subparsers = parser.add_subparsers(dest="cmd") + subparsers = parser.add_subparsers(dest="cmd", description="Command", required=True) subparsers.required = True status_parser = subparsers.add_parser("status", description="Get the current status of the vehicle.") @@ -311,6 +311,7 @@ def _add_default_arguments(parser: argparse.ArgumentParser): parser.add_argument("username", help="Connected Drive username") parser.add_argument("password", help="Connected Drive password") parser.add_argument("region", choices=valid_regions(), help="Region of the Connected Drive account") + parser.add_argument("--captcha-token", type=str, nargs="?", help="Captcha token required for North America.") def _add_position_arguments(parser: argparse.ArgumentParser): @@ -331,7 +332,9 @@ def main(): logging.basicConfig(level=logging.DEBUG) logging.getLogger("asyncio").setLevel(logging.WARNING) - account = MyBMWAccount(args.username, args.password, get_region_from_name(args.region)) + account = MyBMWAccount( + args.username, args.password, get_region_from_name(args.region), hcaptcha_token=args.captcha_token + ) if args.oauth_store.exists(): with contextlib.suppress(json.JSONDecodeError): diff --git a/bimmer_connected/tests/test_cli.py b/bimmer_connected/tests/test_cli.py index ebe0cef1..2a31d528 100644 --- a/bimmer_connected/tests/test_cli.py +++ b/bimmer_connected/tests/test_cli.py @@ -64,7 +64,7 @@ def test_status_json_filtered(capsys: pytest.CaptureFixture, vin, expected_count @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.usefixtures("cli_home_dir") def test_status_json_unfiltered(capsys: pytest.CaptureFixture): - """Test the status command JSON output filtered by VIN.""" + """Test the status command JSON output without filtering by VIN.""" sys.argv = ["bimmerconnected", "status", "-j", *ARGS_USER_PW_REGION] bimmer_connected.cli.main() @@ -296,3 +296,31 @@ def test_login_invalid_refresh_token(cli_home_dir: Path, bmw_fixture: respx.Rout assert bmw_fixture.routes["vehicles"].calls[0].request.headers["authorization"] == "Bearer some_token_string" assert (cli_home_dir / ".bimmer_connected.json").exists() is True + + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("cli_home_dir") +def test_captcha_set(capsys: pytest.CaptureFixture): + """Test login for North America if captcha is given.""" + + ARGS_USER_PW_REGION = ["myuser", "mypassword", "north_america"] + sys.argv = ["bimmerconnected", "status", "-j", "--captcha-token", "SOME_CAPTCHA_TOKEN", *ARGS_USER_PW_REGION] + bimmer_connected.cli.main() + result = capsys.readouterr() + + result_json = json.loads(result.out) + assert isinstance(result_json, list) + assert len(result_json) == get_fingerprint_count("states") + + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("cli_home_dir") +def test_captcha_unavailable(capsys: pytest.CaptureFixture): + """Test login for North America failing if no captcha token was given.""" + + ARGS_USER_PW_REGION = ["myuser", "mypassword", "north_america"] + sys.argv = ["bimmerconnected", "status", "-j", *ARGS_USER_PW_REGION] + with contextlib.suppress(SystemExit): + bimmer_connected.cli.main() + result = capsys.readouterr() + assert result.err.strip() == "MyBMWCaptchaMissingError: Missing hCaptcha token for North America login" From 3a533aadf4c07b3f4dc9b3830173e60ff83cd006 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Nov 2024 11:14:29 +0100 Subject: [PATCH 6/6] Update CLI documentation --- README.rst | 3 +++ docs/source/captcha.rst | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 6ee08580..b544cda8 100644 --- a/README.rst +++ b/README.rst @@ -43,6 +43,9 @@ Please be aware that :code:`bimmer_connected` is an :code:`async` library when u The description of the :code:`modules` can be found in the `module documentation `_. +.. note:: + Login to **north american** accounts requires a captcha to be solved. See `Captcha (North America) `_ for more information. + Example in an :code:`asyncio` event loop ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :: diff --git a/docs/source/captcha.rst b/docs/source/captcha.rst index f5012c64..b820c8db 100644 --- a/docs/source/captcha.rst +++ b/docs/source/captcha.rst @@ -7,9 +7,11 @@ Login to the :code:`north_america` region requires a captcha to be solved. Submi account = MyBMWAccount(USERNAME, PASSWORD, Regions.REST_OF_WORLD, hcaptcha_token=HCAPTCHA_TOKEN) +When using the CLI, pass the token via the :code:`--hcaptcha-token` argument (see `CLI documentation `_). + .. 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. + This requires the tokens to be stored in a file (default behavior when using the CLI) or in the python object itself. .. raw:: html :file: _static/captcha_north_america.html \ No newline at end of file