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

Handle captcha in North America #664

Closed
wants to merge 4 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
7 changes: 5 additions & 2 deletions bimmer_connected/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
40 changes: 27 additions & 13 deletions bimmer_connected/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 MyBMWCaptchaMissingError("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(
Expand Down
11 changes: 8 additions & 3 deletions bimmer_connected/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,15 @@ 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.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
Expand Down
4 changes: 4 additions & 0 deletions bimmer_connected/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
33 changes: 25 additions & 8 deletions bimmer_connected/tests/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@
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.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError
from bimmer_connected.const import ATTR_CAPABILITIES, VEHICLES_URL, CarBrands, Regions
from bimmer_connected.models import (
GPSPosition,
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
MyBMWQuotaError,
)

from . import (
RESPONSE_DIR,
Expand All @@ -32,13 +38,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(MyBMWCaptchaMissingError):
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."""
Expand Down Expand Up @@ -745,8 +767,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
39 changes: 39 additions & 0 deletions docs/source/_static/captcha_north_america.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Form with hCaptcha</title>
</head>
<body>
<p></p>
<div id="captchaResponse">
<div style="text-align: center;">
<form id="captcha_form" action="#" method="post">
<!-- hCaptcha widget -->
<div class="h-captcha" data-sitekey="dc24de9a-9844-438b-b542-60067ff4dbe9"></div><br>
<button type="submit" class="btn">Submit</button>
</form>

<!-- hCaptcha script -->
<script src="https://hcaptcha.com/1/api.js" async defer></script>
</div>
</div>
<p></p>
<script>
document.getElementById('captcha_form').addEventListener('submit', function(event) {
event.preventDefault(); // Prevent the default form submission

const hCaptchaResponse = document.querySelector('[name="h-captcha-response"]').value;
const responseElement = document.getElementById('captchaResponse');

if (hCaptchaResponse) {
content = '<div class="highlight"><pre style="word-break: break-all; white-space: pre-wrap;">'
content += hCaptchaResponse
content += '</pre></div>';
responseElement.innerHTML = content;
}
});
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions docs/source/captcha.rst
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
:maxdepth: 2
:glob:

captcha
development/*


Expand Down
Loading