From 5c251771000d4198a4ce64ef617902a1da646d93 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Nov 2024 00:46:17 +0100 Subject: [PATCH 1/5] Add external flow for captcha --- .../bmw_connected_drive/config_flow.py | 147 ++++++++++++++++-- .../components/bmw_connected_drive/const.py | 18 +++ .../bmw_connected_drive/manifest.json | 3 +- .../bmw_connected_drive/strings.json | 6 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 161 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 37ff1eb374c794..5a9e8898b6fd18 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -4,13 +4,22 @@ from collections.abc import Mapping from typing import Any +from urllib.parse import parse_qsl +from aiohttp.web import Request, Response from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.const import HCAPTCHA_SITE_KEYS, Regions +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError import voluptuous as vol +from homeassistant.components import http +from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -23,9 +32,20 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.util.ssl import get_default_context from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN +from .const import ( + CONF_ALLOWED_REGIONS, + CONF_CAPTCHA_TOKEN, + CONF_GCID, + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, + TEMPLATE_HCAPTCHA, +) + +HEADER_FRONTEND_BASE = "HA-Frontend-Base" +CAPTCHA_URL = "/auth/bmw-connected-drive/captcha" DATA_SCHEMA = vol.Schema( { @@ -37,7 +57,8 @@ translation_key="regions", ) ), - } + }, + extra=vol.ALLOW_EXTRA, ) @@ -50,10 +71,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, data[CONF_USERNAME], data[CONF_PASSWORD], get_region_from_name(data[CONF_REGION]), + hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN), + verify=get_default_context(), ) try: await auth.login() + except MyBMWCaptchaMissingError as ex: + raise MissingCaptcha from ex except MyBMWAuthError as ex: raise InvalidAuth from ex except (MyBMWAPIError, RequestError) as ex: @@ -73,15 +98,17 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + data: dict[str, Any] = {} + _existing_entry_data: Mapping[str, Any] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} + errors: dict[str, str] = self.data.pop("errors", {}) - if user_input is not None: + if user_input is not None and not errors: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" await self.async_set_unique_id(unique_id) @@ -90,20 +117,34 @@ async def async_step_user( else: self._abort_if_unique_id_configured() + # Store user input for later use + self.data.update(user_input) + + # North America requires captcha token with external step + if get_region_from_name( + self.data.get(CONF_REGION) or "" + ) == Regions.NORTH_AMERICA and not self.data.get(CONF_CAPTCHA_TOKEN): + return await self._async_step_captcha() + info = None try: - info = await validate_input(self.hass, user_input) - entry_data = { - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - CONF_GCID: info.get(CONF_GCID), - } + info = await validate_input(self.hass, self.data) + except MissingCaptcha: + errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + finally: + self.data.pop(CONF_CAPTCHA_TOKEN, None) if info: + entry_data = { + **self.data, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + CONF_GCID: info.get(CONF_GCID), + } + if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=entry_data @@ -120,7 +161,7 @@ async def async_step_user( schema = self.add_suggested_values_to_schema( DATA_SCHEMA, - self._existing_entry_data, + self._existing_entry_data or self.data, ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @@ -139,6 +180,42 @@ async def async_step_reconfigure( self._existing_entry_data = self._get_reconfigure_entry().data return await self.async_step_user() + async def _async_step_captcha(self) -> ConfigFlowResult: + """Show captcha form.""" + self.hass.http.register_view(BmwCaptchaView) + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + if (hass_url := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + forward_url = f"{hass_url}{CAPTCHA_URL}?flow_id={self.flow_id}®ion={self.data[CONF_REGION]}" + return self.async_external_step(step_id="obtain_captcha", url=forward_url) + + async def async_step_obtain_captcha( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Obtain token after external auth completed.""" + + if user_input and (captcha_token := user_input.get(CONF_CAPTCHA_TOKEN)): + self.data[CONF_CAPTCHA_TOKEN] = captcha_token + else: + self.data["errors"] = {"base": "missing_captcha"} + return self.async_external_step_done(next_step_id="obtain_captcha_done") + + async def async_step_obtain_captcha_done( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Finalize external step and forward back to step_user.""" + + return await self.async_step_user(user_input=self.data) + + # async def async_step_obtain_captcha_missing( + # self, user_input: dict[str, Any] | None = None + # ) -> ConfigFlowResult: + # """Abort if captcha is missing.""" + + # return self.async_abort(reason="missing_captcha") + @staticmethod @callback def async_get_options_flow( @@ -192,3 +269,49 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class MissingCaptcha(HomeAssistantError): + """Error to indicate the captcha token is missing.""" + + +class BmwCaptchaView(HomeAssistantView): + """Generate views for hcaptcha.""" + + url = CAPTCHA_URL + name = "auth:bmw-connected-drive:captcha:form" + requires_auth = False + + async def get( + self, + request: Request, + ) -> Response: + """Form to display hcaptcha token.""" + return_url = f"{CAPTCHA_URL}?flow_id={request.query['flow_id']}®ion={request.query['region']}" + return Response( + text=TEMPLATE_HCAPTCHA.format( + return_url=return_url, + sitekey=HCAPTCHA_SITE_KEYS[ + get_region_from_name(request.query["region"]) + ], + ), + content_type="text/html", + ) + + async def post( + self, + request: Request, + ) -> Response: + """Retrieve hcaptcha token and close window.""" + hass = request.app[KEY_HASS] + + form_data = dict(parse_qsl(await request.text())) + await hass.config_entries.flow.async_configure( + flow_id=request.query["flow_id"], + user_input={CONF_CAPTCHA_TOKEN: form_data.get("h-captcha-response")}, + ) + + return Response( + headers={"content-type": "text/html"}, + text="Success! This window can be closed", + ) diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 98d4acbfc91d86..aec99ea30089b0 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -12,6 +12,7 @@ CONF_ACCOUNT = "account" CONF_REFRESH_TOKEN = "refresh_token" CONF_GCID = "gcid" +CONF_CAPTCHA_TOKEN = "hcaptcha_token" DATA_HASS_CONFIG = "hass_config" @@ -27,3 +28,20 @@ "north_america": 600, "rest_of_world": 300, } + +TEMPLATE_HCAPTCHA = """ + + + + hCaptcha + + +
+
+

+ +
+
+ + +""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 6bc9027ac19983..f438d9b2c6808c 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -3,9 +3,10 @@ "name": "BMW Connected Drive", "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "quality_scale": "platinum", - "requirements": ["bimmer-connected[china]==0.16.3"] + "requirements": ["bimmer-connected[china]==0.16.4.0b2"] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index fed71f85e3552e..2708914aa77fcb 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -11,13 +11,15 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_captcha": "Captcha validation missing. Please try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "account_mismatch": "Username and region are not allowed to change" + "account_mismatch": "Username and region are not allowed to change", + "missing_captcha": "Captcha validation missing. Please try again." } }, "options": { diff --git a/requirements_all.txt b/requirements_all.txt index b684846a66a3c0..f82cf86ed1610b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -572,7 +572,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4.0b2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f06860ab66ecea..0ed672528e7ddc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4.0b2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome From 83720902d3cdb6938b6962aa1607e2c1727e3832 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Nov 2024 00:46:34 +0100 Subject: [PATCH 2/5] Add/fix tests --- .../bmw_connected_drive/__init__.py | 4 +- .../snapshots/test_diagnostics.ambr | 6 +- .../bmw_connected_drive/test_config_flow.py | 215 +++++++++++++++++- 3 files changed, 218 insertions(+), 7 deletions(-) diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 4d280a1d0e5c8d..35eb96ffec032b 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -24,8 +24,8 @@ CONF_PASSWORD: "p4ssw0rd", CONF_REGION: "rest_of_world", } -FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" -FIXTURE_GCID = "SOME_GCID" +FIXTURE_REFRESH_TOKEN = "another_token_string" +FIXTURE_GCID = "DUMMY" FIXTURE_CONFIG_ENTRY = { "entry_id": "1", diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 81ef1220069763..b87da22a332eef 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -4833,7 +4833,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -7202,7 +7202,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -8925,7 +8925,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9d4d15703f271b..cdc961ce06066c 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -1,15 +1,22 @@ """Test the for the BMW Connected Drive config flow.""" from copy import deepcopy +from http import HTTPStatus from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError +import pytest from homeassistant import config_entries -from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN +from homeassistant.components.bmw_connected_drive.config_flow import CAPTCHA_URL, DOMAIN from homeassistant.components.bmw_connected_drive.const import ( + CONF_CAPTCHA_TOKEN, CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) @@ -25,6 +32,7 @@ ) from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator FIXTURE_COMPLETE_ENTRY = FIXTURE_CONFIG_ENTRY["data"] FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None} @@ -311,3 +319,206 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "account_mismatch" assert config_entry.data == FIXTURE_COMPLETE_ENTRY + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_success(hass: HomeAssistant) -> None: + """Test the external flow with captcha.""" + + TEST_REGION = "north_america" + + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + # External step with hcaptcha return value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CAPTCHA_TOKEN: "token"} + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE + + # Validation successful, create entry + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == (FIXTURE_COMPLETE_ENTRY | {CONF_REGION: TEST_REGION}) + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_missing_and_success(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + TEST_REGION = "north_america" + + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + # External step without hcaptcha return value + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE + + # Validation successful, create entry + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "missing_captcha" + + # Second try, add login data + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + # External step with hcaptcha return value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CAPTCHA_TOKEN: "token"} + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE + + # Validation successful, create entry + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == (FIXTURE_COMPLETE_ENTRY | {CONF_REGION: TEST_REGION}) + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_not_set(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + TEST_REGION = "north_america" + + # Start flow and open form + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + # External step with hcaptcha return value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CAPTCHA_TOKEN: "token"} + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE + + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["errors"]["base"] == "missing_captcha" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_webviews( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test the external flow webviews.""" + + TEST_REGION = "north_america" + + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + client = await hass_client_no_auth() + forward_url = f"{CAPTCHA_URL}?flow_id={result['flow_id']}®ion={TEST_REGION}" + + resp = await client.get(forward_url) + assert resp.status == HTTPStatus.OK + + resp = await client.post(forward_url, data={CONF_CAPTCHA_TOKEN: "token"}) + assert resp.status == HTTPStatus.OK + + +async def test_captcha_flow_client_request_missing(hass: HomeAssistant) -> None: + """Test when client headers are not set properly.""" + TEST_REGION = "north_america" + + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with ( + pytest.raises(RuntimeError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_captcha_flow_client_header_issues(hass: HomeAssistant) -> None: + """Test when client headers are not set properly.""" + + class MockRequest: + headers = {} + + TEST_REGION = "north_america" + + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with ( + patch( + "homeassistant.components.http.current_request.get", + return_value=MockRequest(), + ), + pytest.raises(RuntimeError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) From 5b01b8976154d55f73bff73b0e931fe85e303cbd Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Nov 2024 09:25:02 +0100 Subject: [PATCH 3/5] Raise reauth flow with captcha-specific error message --- .../bmw_connected_drive/coordinator.py | 13 +++++- .../bmw_connected_drive/strings.json | 3 ++ .../bmw_connected_drive/test_coordinator.py | 43 ++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 992e7dea6b263c..d38b7ffacc2a7c 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -7,7 +7,12 @@ from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + GPSPosition, + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError from homeassistant.config_entries import ConfigEntry @@ -61,6 +66,12 @@ async def _async_update_data(self) -> None: try: await self.account.get_vehicles() + except MyBMWCaptchaMissingError as err: + # If a captcha is required (user/password login flow), always trigger the reauth flow + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_captcha", + ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 2708914aa77fcb..ce1facb5d38ef5 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -202,6 +202,9 @@ "exceptions": { "invalid_poi": { "message": "Invalid data for point of interest: {poi_exception}" + }, + "missing_captcha": { + "message": "Captcha requried. Please prove you are human" } } } diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index b0f507bbfc2b29..774a85eb6da03c 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,13 +1,19 @@ """Test BMW coordinator.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import patch -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir @@ -122,3 +128,38 @@ async def test_init_reauth( f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_reauth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the reauth form.""" + TEST_REGION = "north_america" + + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REGION] = TEST_REGION + config_entry = MockConfigEntry(**config_entry_fixure) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = config_entry.runtime_data.coordinator + + assert coordinator.last_update_success is True + + freezer.tick(timedelta(minutes=10, seconds=1)) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + assert coordinator.last_exception.translation_key == "missing_captcha" From 37aa8fd9f6c4b160e0ea971061f1dcb2b28a1eb5 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Nov 2024 09:35:47 +0100 Subject: [PATCH 4/5] Cleanup --- .../bmw_connected_drive/config_flow.py | 43 ++++++++----------- .../bmw_connected_drive/strings.json | 5 +-- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 5a9e8898b6fd18..6371563e8ebf9d 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -124,7 +124,7 @@ async def async_step_user( if get_region_from_name( self.data.get(CONF_REGION) or "" ) == Regions.NORTH_AMERICA and not self.data.get(CONF_CAPTCHA_TOKEN): - return await self._async_step_captcha() + return await self._async_step_captcha_show() info = None try: @@ -180,7 +180,7 @@ async def async_step_reconfigure( self._existing_entry_data = self._get_reconfigure_entry().data return await self.async_step_user() - async def _async_step_captcha(self) -> ConfigFlowResult: + async def _async_step_captcha_show(self) -> ConfigFlowResult: """Show captcha form.""" self.hass.http.register_view(BmwCaptchaView) if (req := http.current_request.get()) is None: @@ -189,9 +189,9 @@ async def _async_step_captcha(self) -> ConfigFlowResult: raise RuntimeError("No header in request") forward_url = f"{hass_url}{CAPTCHA_URL}?flow_id={self.flow_id}®ion={self.data[CONF_REGION]}" - return self.async_external_step(step_id="obtain_captcha", url=forward_url) + return self.async_external_step(step_id="captcha_retrieve", url=forward_url) - async def async_step_obtain_captcha( + async def async_step_captcha_retrieve( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain token after external auth completed.""" @@ -200,22 +200,15 @@ async def async_step_obtain_captcha( self.data[CONF_CAPTCHA_TOKEN] = captcha_token else: self.data["errors"] = {"base": "missing_captcha"} - return self.async_external_step_done(next_step_id="obtain_captcha_done") + return self.async_external_step_done(next_step_id="captcha_done") - async def async_step_obtain_captcha_done( + async def async_step_captcha_done( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Finalize external step and forward back to step_user.""" return await self.async_step_user(user_input=self.data) - # async def async_step_obtain_captcha_missing( - # self, user_input: dict[str, Any] | None = None - # ) -> ConfigFlowResult: - # """Abort if captcha is missing.""" - - # return self.async_abort(reason="missing_captcha") - @staticmethod @callback def async_get_options_flow( @@ -263,18 +256,6 @@ async def async_step_account_options( ) -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class MissingCaptcha(HomeAssistantError): - """Error to indicate the captcha token is missing.""" - - class BmwCaptchaView(HomeAssistantView): """Generate views for hcaptcha.""" @@ -315,3 +296,15 @@ async def post( headers={"content-type": "text/html"}, text="Success! This window can be closed", ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class MissingCaptcha(HomeAssistantError): + """Error to indicate the captcha token is missing.""" diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index ce1facb5d38ef5..f8d81195373c00 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -18,8 +18,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "account_mismatch": "Username and region are not allowed to change", - "missing_captcha": "Captcha validation missing. Please try again." + "account_mismatch": "Username and region are not allowed to change" } }, "options": { @@ -204,7 +203,7 @@ "message": "Invalid data for point of interest: {poi_exception}" }, "missing_captcha": { - "message": "Captcha requried. Please prove you are human" + "message": "Login requires captcha validation" } } } From 411925066eb6c572483755a619e29bceed3d23d5 Mon Sep 17 00:00:00 2001 From: Richard <42204099+rikroe@users.noreply.github.com> Date: Sun, 3 Nov 2024 10:27:10 +0100 Subject: [PATCH 5/5] Bump bimmer_connected to 0.16.4 --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index f438d9b2c6808c..b11c4c9cfadf35 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "quality_scale": "platinum", - "requirements": ["bimmer-connected[china]==0.16.4.0b2"] + "requirements": ["bimmer-connected[china]==0.16.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f82cf86ed1610b..de8e00b4feeda8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -572,7 +572,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.4.0b2 +bimmer-connected[china]==0.16.4 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ed672528e7ddc..3062616cdc5451 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.4.0b2 +bimmer-connected[china]==0.16.4 # homeassistant.components.eq3btsmart # homeassistant.components.esphome