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 all 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
126 changes: 115 additions & 11 deletions homeassistant/components/bmw_connected_drive/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

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.const import HCAPTCHA_SITE_KEYS, Regions
from bimmer_connected.models import (
MyBMWAPIError,
MyBMWAuthError,
Expand All @@ -15,6 +18,8 @@
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 @@ -27,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 @@ -41,7 +57,8 @@
translation_key="regions",
)
),
}
},
extra=vol.ALLOW_EXTRA,
)


Expand All @@ -54,6 +71,8 @@ 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:
Expand All @@ -79,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 @@ -96,22 +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 @@ -128,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 @@ -147,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 @@ -194,6 +256,48 @@ 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."""

Expand Down
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>"""
1 change: 1 addition & 0 deletions homeassistant/components/bmw_connected_drive/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"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"],
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
Loading