Skip to content

Commit

Permalink
Handle captcha in North America (#665)
Browse files Browse the repository at this point in the history
* Allow for captcha in North America

* Fix for sphinx-rtd-theme>=3.0

* Add MyBMWCaptchaMissingError

* Add sitekey to const

* Add --captcha-token to CLI

* Update CLI documentation
  • Loading branch information
rikroe authored Nov 3, 2024
1 parent a1c80ca commit eb06aea
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 30 deletions.
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<http://bimmer-connected.readthedocs.io/en/stable/#module>`_.

.. note::
Login to **north american** accounts requires a captcha to be solved. See `Captcha (North America) <http://bimmer-connected.readthedocs.io/en/stable/captcha.html>`_ for more information.

Example in an :code:`asyncio` event loop
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
::
Expand Down
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
7 changes: 5 additions & 2 deletions bimmer_connected/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -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):
Expand All @@ -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):
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
30 changes: 29 additions & 1 deletion bimmer_connected/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
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>
17 changes: 17 additions & 0 deletions docs/source/captcha.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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)

When using the CLI, pass the token via the :code:`--hcaptcha-token` argument (see `CLI documentation <cli.html#named-arguments>`_).

.. 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 (default behavior when using the 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

0 comments on commit eb06aea

Please sign in to comment.