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 #665

Merged
merged 6 commits into from
Nov 3, 2024
Merged
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
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
Loading