Skip to content

Commit

Permalink
Rebuild app OTP authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
Dielee committed Mar 14, 2024
1 parent 03755ca commit e6861ab
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 35 deletions.
7 changes: 5 additions & 2 deletions src/const.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from config import settings

VERSION = "v1.8.27"
VERSION = "v1.9.0b1"

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"
Expand Down Expand Up @@ -137,3 +138,5 @@
]

old_entity_ids = ["months_to_service", "service_warning_trigger", "distance_to_empty"]
otp_max_loops = 15
otp_mqtt_topic = "volvoAAOS2mqtt/otp_code"
42 changes: 35 additions & 7 deletions src/mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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 = []
Expand All @@ -20,7 +20,7 @@
engine_status = {}
devices = {}
active_schedules = {}

otp_code = None

def connect():
client = mqtt.Client("volvoAAOS2mqtt") if os.environ.get("IS_HA_ADDON") \
Expand All @@ -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
Expand All @@ -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"},
Expand Down Expand Up @@ -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!")
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

payload = msg.payload.decode("UTF-8")
if "climate_status" in msg.topic:
if payload == "ON":
start_climate(vin)
Expand Down
5 changes: 5 additions & 0 deletions src/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re
import sys
import config
import json
from logging import handlers
from datetime import datetime
from const import units
Expand All @@ -29,6 +30,10 @@ def filter(self, record: logging.LogRecord) -> bool:
return True


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
for s in icon_list:
Expand Down
156 changes: 130 additions & 26 deletions src/volvo.py
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -30,37 +33,135 @@
backend_status = ""


def authorize():
headers = {
"authorization": "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=",
"content-type": "application/x-www-form-urlencoded",
"accept": "application/json"
}
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/5.37.0",
"Accept-Encoding": "gzip",
"Content-Type": "application/json; charset=utf-8"
}

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"]})
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 conve:engine_status")

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)

global token_expires_at, refresh_token
token_expires_at = datetime.now(util.TZ) + timedelta(seconds=(data["expires_in"] - 30))
refresh_token = data["refresh_token"]
session.headers.update({"authorization": "Bearer " + token_data["access_token"]})

get_vcc_api_keys()
get_vehicles()
check_supported_endpoints()
Thread(target=backend_status_loop).start()
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)
get_vcc_api_keys()
get_vehicles()
check_supported_endpoints()
Thread(target=backend_status_loop).start()
else:
message = auth.json()
raise Exception(message["error_description"])

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
Expand All @@ -76,18 +177,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():
Expand Down

0 comments on commit e6861ab

Please sign in to comment.