Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Captcha for BMW North America #129667

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 128 additions & 12 deletions homeassistant/components/bmw_connected_drive/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
{
Expand All @@ -37,7 +57,8 @@
translation_key="regions",
)
),
}
},
extra=vol.ALLOW_EXTRA,
)


Expand All @@ -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:
Expand All @@ -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)

Expand All @@ -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_show()

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
Expand All @@ -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)
Expand All @@ -139,6 +180,35 @@ 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_show(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}&region={self.data[CONF_REGION]}"
return self.async_external_step(step_id="captcha_retrieve", url=forward_url)

async def async_step_captcha_retrieve(
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="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)

@staticmethod
@callback
def async_get_options_flow(
Expand Down Expand Up @@ -186,9 +256,55 @@ async def async_step_account_options(
)


class BmwCaptchaView(HomeAssistantView):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move that to view.py

Copy link
Contributor Author

@rikroe rikroe Nov 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept it in config_flow.py as I got many ideas from the Plex integration.

There, the views related to the config flow are in config_flow.py and only views that are used after component initialization are in views.py.

Maybe someone from the core team can give some feedback here as well because it seems this is the first time an actual HTML page is rendered from HA - I was only able to find images/API-like functionality.

"""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']}&region={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="<script>window.close()</script>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."""
18 changes: 18 additions & 0 deletions homeassistant/components/bmw_connected_drive/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -27,3 +28,20 @@
"north_america": 600,
"rest_of_world": 300,
}

TEMPLATE_HCAPTCHA = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>hCaptcha</title>
</head>
<body>
<form id="captcha" action="{return_url}" method="post">
<center>
<div class="h-captcha" data-sitekey="{sitekey}"></div><br>
<button type="submit">Submit</button>
</center>
</form>
<script src="https://hcaptcha.com/1/api.js" async defer></script>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External javascript included into the Home Assistant frontend.

</body>
</html>"""
13 changes: 12 additions & 1 deletion homeassistant/components/bmw_connected_drive/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/bmw_connected_drive/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
6 changes: 5 additions & 1 deletion homeassistant/components/bmw_connected_drive/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
},
"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%]",
Expand Down Expand Up @@ -200,6 +201,9 @@
"exceptions": {
"invalid_poi": {
"message": "Invalid data for point of interest: {poi_exception}"
},
"missing_captcha": {
"message": "Login requires captcha validation"
}
}
}
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/components/bmw_connected_drive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4833,7 +4833,7 @@
}),
]),
'info': dict({
'gcid': 'SOME_GCID',
'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
Expand Down Expand Up @@ -7202,7 +7202,7 @@
}),
]),
'info': dict({
'gcid': 'SOME_GCID',
'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
Expand Down Expand Up @@ -8925,7 +8925,7 @@
}),
]),
'info': dict({
'gcid': 'SOME_GCID',
'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
Expand Down
Loading