From 89bd76e981e768128b05d6ef7b55ddf45d5675c3 Mon Sep 17 00:00:00 2001 From: Linus Dietz <45101649+Dielee@users.noreply.github.com> Date: Mon, 27 May 2024 08:17:52 +0200 Subject: [PATCH] Move to OTP auth (#190) * Rebuild app OTP authentication * Adding OTP readme * Fix error message // Remove duplicated scopes * Add dynamic header building including latest app version * Fix missing startupcheck if using token from file * Change version --- README.md | 17 ++++- requirements.txt | 5 +- src/config.yaml | 2 +- src/const.py | 7 ++- src/mqtt.py | 44 ++++++++++--- src/util.py | 17 +++++ src/volvo.py | 159 +++++++++++++++++++++++++++++++++++++++-------- 7 files changed, 210 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 80655f8..7e79688 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,22 @@ Please let me know if your car works with this addon so I can expand the list!v1.9.0, this addon uses the same OTP authentication as the Volvo app. +The following steps are required for authentication in exactly this order: + +1. Setup volvo2Mqtt, either via Docker, or via HA addon (take a look at the "Setup" section below) +2. Fill in your settings and start volvo2Mqtt +3. Your log will show the following lines
+```Waiting for otp code... Please check your mailbox and post your otp code to the following mqtt topic "volvoAAOS2mqtt/otp_code". Retry 0/15```
+```Waiting for otp code... Please check your mailbox and post your otp code to the following mqtt topic "volvoAAOS2mqtt/otp_code". Retry 1/15```
+```etc ...``` +4. Now, open your mailbox and copy your OTP Code +5. Open HomeAssistant and search for the entity ID ```text.volvo_otp``` +6. Paste your OTP into the text entity and press Enter +7. If everything has worked, your addon is now authenticated. In the future, OTP authentication only needs to be done when updating, not when restarting the container. + ## Setup Docker: @@ -106,7 +122,6 @@ Here is what every option means: | `TZ` | `string` | | **required** | Container timezone eg "Europe/Berlin" from [here](https://docs.diladele.com/docker/timezones.html)| - [aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg [amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg [armhf-shield]: https://img.shields.io/badge/armhf-yes-green.svg diff --git a/requirements.txt b/requirements.txt index fc8e167..ab85721 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -requests~=2.29.0 +requests~=2.31.0 dynaconf~=3.1.12 paho-mqtt~=1.6.1 Babel~=2.12.1 -pytz~=2023.3 \ No newline at end of file +pytz~=2023.3 +google_play_scraper~=1.2.6 \ No newline at end of file diff --git a/src/config.yaml b/src/config.yaml index 2e3abc7..73645ba 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -1,6 +1,6 @@ name: "Volvo2Mqtt" description: "Volvo AAOS MQTT bridge" -version: "1.8.27" +version: "1.9.0" slug: "volvo2mqtt" init: false url: "https://github.com/Dielee/volvo2mqtt" diff --git a/src/const.py b/src/const.py index 6543473..0c8de31 100644 --- a/src/const.py +++ b/src/const.py @@ -1,8 +1,9 @@ from config import settings -VERSION = "v1.8.27" +VERSION = "v1.9.0" -OAUTH_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2" +OAUTH_TOKEN_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2" +OAUTH_AUTH_URL = "https://volvoid.eu.volvocars.com/as/authorization.oauth2" VEHICLES_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles" VEHICLE_DETAILS_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}" WINDOWS_STATE_URL = "https://api.volvocars.com/connected-vehicle/v2/vehicles/{0}/windows" @@ -137,3 +138,5 @@ supported_entities = [ ] old_entity_ids = ["months_to_service", "service_warning_trigger", "distance_to_empty"] +otp_max_loops = 15 +otp_mqtt_topic = "volvoAAOS2mqtt/otp_code" \ No newline at end of file diff --git a/src/mqtt.py b/src/mqtt.py index 4115195..043a657 100644 --- a/src/mqtt.py +++ b/src/mqtt.py @@ -10,7 +10,7 @@ from datetime import datetime from babel.dates import format_datetime from config import settings from const import CLIMATE_START_URL, CLIMATE_STOP_URL, CAR_LOCK_URL, \ - CAR_UNLOCK_URL, availability_topic, icon_states, old_entity_ids + CAR_UNLOCK_URL, availability_topic, icon_states, old_entity_ids, otp_mqtt_topic mqtt_client: mqtt.Client subscribed_topics = [] @@ -20,7 +20,7 @@ climate_timer = {} engine_status = {} devices = {} active_schedules = {} - +otp_code = None def connect(): client = mqtt.Client("volvoAAOS2mqtt") if os.environ.get("IS_HA_ADDON") \ @@ -37,6 +37,7 @@ def connect(): port = settings["mqtt"]["port"] client.connect(settings["mqtt"]["broker"], port) client.loop_start() + client.subscribe("volvoAAOS2mqtt/otp_code") client.on_message = on_message client.on_disconnect = on_disconnect client.on_connect = on_connect @@ -45,6 +46,28 @@ def connect(): mqtt_client = client +def create_otp_input(): + config = { + "name": "Volvo OTP", + "object_id": f"volvo_otp", + "schema": "state", + "command_topic": otp_mqtt_topic, + "unique_id": f"volvoAAOS2mqtt_otp", + "pattern": "\d{6}", + "icon": "mdi:two-factor-authentication" + } + + mqtt_client.publish( + f"homeassistant/text/volvoAAOS2mqtt/volvo_otp/config", + json.dumps(config), + retain=True + ) + +def delete_otp_input(): + topic = "homeassistant/text/volvoAAOS2mqtt/volvo_otp/config" + mqtt_client.publish(topic, payload="", retain=True) + + def send_car_images(vin, data, device): if util.keys_exists(data, "images"): for entity in [{"name": "Exterior Image", "id": "exterior_image"}, @@ -94,13 +117,18 @@ def on_disconnect(client, userdata, rc): def on_message(client, userdata, msg): - try: - vin = msg.topic.split('/')[2].split('_')[0] - except IndexError: - logging.error("Error - Cannot get vin from MQTT topic!") - return None - payload = msg.payload.decode("UTF-8") + if msg.topic == otp_mqtt_topic: + global otp_code + otp_code = payload + return None + else: + try: + vin = msg.topic.split('/')[2].split('_')[0] + except IndexError: + logging.error("Error - Cannot get vin from MQTT topic!") + return None + if "climate_status" in msg.topic: if payload == "ON": start_climate(vin) diff --git a/src/util.py b/src/util.py index f20eff6..2a40f26 100644 --- a/src/util.py +++ b/src/util.py @@ -4,6 +4,8 @@ import os import re import sys import config +import json +from google_play_scraper import app from logging import handlers from datetime import datetime from const import units @@ -28,6 +30,21 @@ class SensitiveDataFilter(logging.Filter): record.msg = re.sub(pattern, "", record.msg) return True +def get_volvo_app_version(): + result = app( + 'se.volvo.vcc', + lang='en', + country='us' + ) + + if "version" in result: + return result["version"] + else: + return "5.37.0" + +def save_to_json(data): + with open('.token', 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=4) def get_icon_between(icon_list, state): icon = None diff --git a/src/volvo.py b/src/volvo.py index 15bbdb3..ed02db4 100644 --- a/src/volvo.py +++ b/src/volvo.py @@ -1,18 +1,21 @@ +import json import logging import requests import mqtt import util import time import re +import os.path from threading import current_thread, Thread from datetime import datetime, timedelta from config import settings from babel.dates import format_datetime from json import JSONDecodeError from const import charging_system_states, charging_connection_states, door_states, window_states, \ - OAUTH_URL, VEHICLES_URL, VEHICLE_DETAILS_URL, RECHARGE_STATE_URL, CLIMATE_START_URL, \ + OAUTH_AUTH_URL, OAUTH_TOKEN_URL, VEHICLES_URL, VEHICLE_DETAILS_URL, RECHARGE_STATE_URL, CLIMATE_START_URL, \ WINDOWS_STATE_URL, LOCK_STATE_URL, TYRE_STATE_URL, supported_entities, FUEL_BATTERY_STATE_URL, \ - STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, VEHICLE_DIAGNOSTICS_URL, API_BACKEND_STATUS, WARNINGS_URL, engine_states + STATISTICS_URL, ENGINE_DIAGNOSTICS_URL, VEHICLE_DIAGNOSTICS_URL, API_BACKEND_STATUS, WARNINGS_URL, engine_states, \ + otp_max_loops session = requests.Session() session.headers = { @@ -30,37 +33,136 @@ vcc_api_keys = [] backend_status = "" -def authorize(): - headers = { - "authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=", - "content-type": "application/x-www-form-urlencoded", - "accept": "application/json" - } - - body = { - "username": settings.volvoData["username"], - "password": settings.volvoData["password"], - "grant_type": "password", - "scope": "openid email profile care_by_volvo:financial_information:invoice:read care_by_volvo:financial_information:payment_method care_by_volvo:subscription:read customer:attributes customer:attributes:write order:attributes vehicle:attributes tsp_customer_api:all conve:brake_status conve:climatization_start_stop conve:command_accessibility conve:commands conve:diagnostics_engine_status conve:diagnostics_workshop conve:doors_status conve:engine_status conve:environment conve:fuel_status conve:honk_flash conve:lock conve:lock_status conve:navigation conve:odometer_status conve:trip_statistics conve:tyre_status conve:unlock conve:vehicle_relation conve:warnings conve:windows_status energy:battery_charge_level energy:charging_connection_status energy:charging_system_status energy:electric_range energy:estimated_charging_time energy:recharge_status vehicle:attributes" - } - auth = requests.post(OAUTH_URL, data=body, headers=headers) - if auth.status_code == 200: - data = auth.json() - session.headers.update({"authorization": "Bearer " + data["access_token"]}) - - global token_expires_at, refresh_token - token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(data["expires_in"] - 30)) +def authorize(renew_tokenfile=False): + global refresh_token + if os.path.isfile(".token") and not renew_tokenfile: + logging.info("Using login from token file") + f = open('.token') + data = json.load(f) refresh_token = data["refresh_token"] + refresh_auth() + else: + logging.info("Starting login with OTP") + auth_session = requests.session() + auth_session.headers = { + "authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=", + "User-Agent": "vca-android/" + util.get_volvo_app_version(), + "Accept-Encoding": "gzip", + "Content-Type": "application/json; charset=utf-8" + } - get_vcc_api_keys() - get_vehicles() - check_supported_endpoints() - Thread(target=backend_status_loop).start() + url_params = ("?client_id=h4Yf0b" + "&response_type=code" + "&acr_values=urn:volvoid:aal:bronze:2sv" + "&response_mode=pi.flow" + "&scope=openid email profile care_by_volvo:financial_information:invoice:read care_by_volvo:financial_information:payment_method care_by_volvo:subscription:read customer:attributes customer:attributes:write order:attributes vehicle:attributes tsp_customer_api:all conve:brake_status conve:climatization_start_stop conve:command_accessibility conve:commands conve:diagnostics_engine_status conve:diagnostics_workshop conve:doors_status conve:engine_status conve:environment conve:fuel_status conve:honk_flash conve:lock conve:lock_status conve:navigation conve:odometer_status conve:trip_statistics conve:tyre_status conve:unlock conve:vehicle_relation conve:warnings conve:windows_status energy:battery_charge_level energy:charging_connection_status energy:charging_system_status energy:electric_range energy:estimated_charging_time energy:recharge_status vehicle:attributes") + + auth = auth_session.get(OAUTH_AUTH_URL + url_params) + if auth.status_code == 200: + response = auth.json() + auth_state = response["status"] + + if auth_state == "USERNAME_PASSWORD_REQUIRED": + auth_session.headers.update({"x-xsrf-header": "PingFederate"}) + response = check_username_password(auth_session, response) + auth_state = response["status"] + + if auth_state == "OTP_REQUIRED": + response = send_otp(auth_session, response) + response = continue_auth(auth_session, response) + token_data = get_token(auth_session, response) + else: + raise Exception("Unkown auth state " + auth_state) + elif auth_state == "OTP_REQUIRED": + response = send_otp(auth_session, response) + response = continue_auth(auth_session, response) + token_data = get_token(auth_session, response) + elif auth_state == "OTP_VERIFIED": + response = continue_auth(auth_session, response) + token_data = get_token(auth_session, response) + elif auth_state == "COMPLETED": + token_data = get_token(auth_session, response) + else: + raise Exception("Unkown auth state " + auth_state) + + session.headers.update({"authorization": "Bearer " + token_data["access_token"]}) + + global token_expires_at + token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(token_data["expires_in"] - 30)) + refresh_token = token_data["refresh_token"] + + util.save_to_json(token_data) + else: + message = auth.json() + raise Exception(message["details"][0]["userMessage"]) + + get_vcc_api_keys() + get_vehicles() + check_supported_endpoints() + Thread(target=backend_status_loop).start() + +def continue_auth(auth_session, data): + next_url = data["_links"]["continueAuthentication"]["href"] + "?action=continueAuthentication" + auth = auth_session.get(next_url) + + if auth.status_code == 200: + return auth.json() else: message = auth.json() + raise Exception(message["details"][0]["userMessage"]) + + +def get_token(auth_session, data): + auth_session.headers.update({"content-type": "application/x-www-form-urlencoded"}) + body = {"code": data["authorizeResponse"]["code"], "grant_type": "authorization_code"} + token_auth = auth_session.post(OAUTH_TOKEN_URL, data=body) + + if token_auth.status_code == 200: + return token_auth.json() + else: + message = token_auth.json() raise Exception(message["error_description"]) +def check_username_password(auth_session, data): + next_url = data["_links"]["checkUsernamePassword"]["href"] + "?action=checkUsernamePassword" + body = {"username": settings.volvoData["username"], + "password": settings.volvoData["password"]} + auth = auth_session.post(next_url, data=json.dumps(body)) + + if auth.status_code == 200: + return auth.json() + else: + message = auth.json() + raise Exception(message["details"][0]["userMessage"]) + + +def send_otp(auth_session, data): + mqtt.create_otp_input() + next_url = data["_links"]["checkOtp"]["href"] + "?action=checkOtp" + body = {"otp": ""} + + for i in range(otp_max_loops): + if mqtt.otp_code: + body["otp"] = mqtt.otp_code + break + + logging.info("Waiting for otp code... Please check your mailbox and post your otp code to the following " + "mqtt topic \"volvoAAOS2mqtt/otp_code\". Retry " + str(i) + "/" + str(otp_max_loops)) + time.sleep(5) + + if not mqtt.otp_code: + raise Exception ("No OTP found, exting...") + + mqtt.delete_otp_input() + auth = auth_session.post(next_url, data=json.dumps(body)) + if auth.status_code == 200: + return auth.json() + else: + message = auth.json() + raise Exception(message["details"][0]["userMessage"]) + + def refresh_auth(): logging.info("Refreshing credentials") global refresh_token @@ -76,18 +178,21 @@ def refresh_auth(): } try: - auth = requests.post(OAUTH_URL, data=body, headers=headers) + auth = requests.post(OAUTH_TOKEN_URL, data=body, headers=headers) except requests.exceptions.RequestException as e: logging.error("Error refreshing credentials data: " + str(e)) return None if auth.status_code == 200: data = auth.json() + util.save_to_json(data) session.headers.update({"authorization": "Bearer " + data["access_token"]}) global token_expires_at token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(data["expires_in"] - 30)) refresh_token = data["refresh_token"] + else: + authorize(renew_tokenfile=True) def get_vehicles():