diff --git a/README.md b/README.md index e937402..5ec6ffd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Python library for interacting with a Vivint security and smart home system. -This was built to support the `Vivint` integration in [Home-Assistant](https://www.home-assistant.io/) but _should_ work outside of it too. Currently, it can be utilized via [HACS](https://hacs.xyz/) by adding the [hacs-vivint](https://github.com/natekspencer/hacs-vivint) custom repository. +This was built to support the [`Vivint`](https://github.com/natekspencer/hacs-vivint) integration in [Home-Assistant](https://www.home-assistant.io/) but _should_ work outside of it too. Currently, it can be utilized via [HACS](https://hacs.xyz/) by adding the [hacs-vivint](https://github.com/natekspencer/hacs-vivint) custom repository. ## Credit diff --git a/poetry.lock b/poetry.lock index dfb954f..07bef58 100644 --- a/poetry.lock +++ b/poetry.lock @@ -972,6 +972,23 @@ files = [ {file = "pycryptodomex-3.19.1.tar.gz", hash = "sha256:0b7154aff2272962355f8941fd514104a88cb29db2d8f43a29af900d6398eb1c"}, ] +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyproject-api" version = "1.8.0" @@ -1065,13 +1082,13 @@ pytest = ">=7.0.0" [[package]] name = "requests" -version = "2.32.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" files = [ - {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, - {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1321,4 +1338,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.9.0" -content-hash = "8a4cf8e1edc20827fa61af797625fda855631f4477fd67175501ee3f84d6a48b" +content-hash = "f879b53dab93d35c2b2fa2a583a864c6ff61b64013e8c3c2f443a920503d793a" diff --git a/pyproject.toml b/pyproject.toml index f256e44..206bb11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ certifi = ">=2022.9.24,<2025.0.0" pubnub = ">=7,<9" grpcio = "^1.51.1" protobuf = "^4.23.1" +pyjwt = "^2.9.0" [tool.poetry.group.dev.dependencies] tox = ">=3.28,<5.0" diff --git a/vivintpy/account.py b/vivintpy/account.py index 6d3ecbd..654428f 100644 --- a/vivintpy/account.py +++ b/vivintpy/account.py @@ -32,8 +32,8 @@ class Account: def __init__( self, username: str, - password: str, - persist_session: bool = False, + password: str | None = None, + refresh_token: str | None = None, client_session: aiohttp.ClientSession | None = None, ): """Initialize an account.""" @@ -44,7 +44,7 @@ def __init__( self._api = VivintSkyApi( username=username, password=password, - persist_session=persist_session, + refresh_token=refresh_token, client_session=client_session, ) self.systems: list[System] = [] diff --git a/vivintpy/const.py b/vivintpy/const.py index d7162db..6a269a3 100644 --- a/vivintpy/const.py +++ b/vivintpy/const.py @@ -4,6 +4,7 @@ class AuthenticationResponse: """Authentication response constants.""" + ERROR = "error" INVALID = "Invalid username and/or password" MESSAGE = "msg" MFA_REQUIRED = "Multi-factor authentication required" diff --git a/vivintpy/devices/door_lock.py b/vivintpy/devices/door_lock.py index 76bff84..a1efcfc 100644 --- a/vivintpy/devices/door_lock.py +++ b/vivintpy/devices/door_lock.py @@ -4,10 +4,10 @@ from ..const import ZWaveDeviceAttribute as Attribute from ..utils import send_deprecation_warning -from . import BypassTamperDevice, VivintDevice +from . import BypassTamperDevice -class DoorLock(BypassTamperDevice, VivintDevice): +class DoorLock(BypassTamperDevice): """Represents a vivint door lock device.""" @property diff --git a/vivintpy/entity.py b/vivintpy/entity.py index 5e3974d..aaf80b5 100644 --- a/vivintpy/entity.py +++ b/vivintpy/entity.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging from collections.abc import Callable +_LOGGER = logging.getLogger(__name__) + UPDATE = "update" @@ -31,6 +34,7 @@ def update_data(self, new_val: dict, override: bool = False) -> None: def handle_pubnub_message(self, message: dict) -> None: """Handle a pubnub message directed to this entity.""" + _LOGGER.debug("Message received by %s: %s", self.name, message) self.update_data(message) def on( # pylint: disable=invalid-name diff --git a/vivintpy/utils.py b/vivintpy/utils.py index 7bb4dce..e431456 100644 --- a/vivintpy/utils.py +++ b/vivintpy/utils.py @@ -3,7 +3,11 @@ from __future__ import annotations import asyncio +import base64 +import hashlib import logging +import os +import re from typing import Any, Callable, Coroutine, Iterable, TypeVar from warnings import warn @@ -44,3 +48,20 @@ def send_deprecation_warning(old_name: str, new_name: str) -> None: stacklevel=2, ) _LOGGER.warning(message) + + +def generate_code_challenge() -> tuple[str, str]: + """Generate PKCE code verifier and challenge for authentication.""" + code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") + code_verifier = re.sub("[^a-zA-Z0-9]+", "", code_verifier) + + code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest() + code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8") + code_challenge = code_challenge.replace("=", "") + + return (code_verifier, code_challenge) + + +def generate_state() -> str: + """Generate state.""" + return base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") diff --git a/vivintpy/vivintskyapi.py b/vivintpy/vivintskyapi.py index cffe118..79012a2 100644 --- a/vivintpy/vivintskyapi.py +++ b/vivintpy/vivintskyapi.py @@ -5,13 +5,14 @@ import json import logging import ssl +import urllib.parse from collections.abc import Callable -from http.cookies import Morsel, SimpleCookie -from typing import Any, cast +from typing import Any import aiohttp import certifi import grpc +import jwt from aiohttp import ClientResponseError from aiohttp.client import _RequestContextManager from google.protobuf.message import Message # type: ignore @@ -29,14 +30,13 @@ VivintSkyApiMfaRequiredError, ) from .proto import beam_pb2, beam_pb2_grpc +from .utils import generate_code_challenge, generate_state _LOGGER = logging.getLogger(__name__) API_ENDPOINT = "https://www.vivintsky.com/api" +AUTH_ENDPOINT = "https://id.vivint.com" GRPC_ENDPOINT = "grpc.vivintsky.com:50051" -MFA_ENDPOINT = ( - "https://www.vivintsky.com/platform-user-api/v0/platformusers/2fa/validate" -) class VivintSkyApi: @@ -45,35 +45,40 @@ class VivintSkyApi: def __init__( self, username: str, - password: str, - persist_session: bool = False, + password: str | None = None, + refresh_token: str | None = None, client_session: aiohttp.ClientSession | None = None, ) -> None: """Initialize the VivintSky API.""" self.__username = username self.__password = password - self.__persist_session = persist_session + self.__refresh_token = refresh_token self.__client_session = client_session or self.__get_new_client_session() self.__has_custom_client_session = client_session is not None + self.__code_verifier: str | None = None self.__mfa_pending = False - - def _get_session_cookie(self) -> Morsel | None: - """Get the session cookie.""" - cookie = self.__client_session.cookie_jar.filter_cookies(API_ENDPOINT) - return cast(SimpleCookie, cookie).get("s") + self.__mfa_type = "code" + self.__token: dict | None = None def is_session_valid(self) -> bool: - """Return the state of the current session.""" - return self._get_session_cookie() is not None + """Return `True` if the token is still valid.""" + if self.__token is None: + return False + try: + jwt.decode( + self.__token["id_token"], + options={"verify_signature": False, "verify_exp": True}, + leeway=-30, + ) + except jwt.ExpiredSignatureError: + return False + return True async def connect(self) -> dict: """Connect to VivintSky Cloud Service.""" - if self.__has_custom_client_session and self.is_session_valid(): - authuser_data = await self.get_authuser_data() - else: - authuser_data = await self.__get_vivintsky_session( - self.__username, self.__password, self.__persist_session - ) + if not (self.__has_custom_client_session and self.is_session_valid()): + await self.__get_vivintsky_session(self.__username, self.__password) + authuser_data = await self.get_authuser_data() if not authuser_data: raise VivintSkyApiAuthenticationError("Unable to login to Vivint") return authuser_data @@ -85,9 +90,38 @@ async def disconnect(self) -> None: async def verify_mfa(self, code: str) -> None: """Verify multi-factor authentication code.""" - resp = await self.__post(MFA_ENDPOINT, data=json.dumps({"code": code})) - if resp is not None: - self.__mfa_pending = False + self.__mfa_pending = False + endpoint = f"{AUTH_ENDPOINT}/idp/api/{"validate" if self.__mfa_type == "code" else "submit"}" + resp = await self.__post( + endpoint, + params={"client_id": "ios"}, + data=json.dumps( + { + self.__mfa_type: code, + "username": self.__username, + "password": self.__password, + } + ), + ) + if resp and "url" in resp: + resp = await self.__get(path=f"{AUTH_ENDPOINT}{resp['url']}") + + if "location" in resp: + query = urllib.parse.urlparse(resp["location"]).query + redirect_params = urllib.parse.parse_qs(query) + auth_code = redirect_params["code"][0] + + await self.__exchange_auth_code(auth_code) + + async def refresh_token(self, refresh_token: str) -> None: + """Refresh the token.""" + resp = await self.__post( + path=f"{AUTH_ENDPOINT}/oauth2/token", + params={"client_id": "ios"}, + data={"grant_type": "refresh_token", "refresh_token": refresh_token}, + ) + assert resp + self.__token = resp async def get_authuser_data(self) -> dict: """ @@ -441,25 +475,78 @@ def __get_new_client_session(self) -> aiohttp.ClientSession: return aiohttp.ClientSession(connector=connector) - async def __get_vivintsky_session( - self, username: str, password: str, persist_session: bool = False - ) -> dict: - """Login into the Vivint Sky platform with the given username, password and, optionally, persist the session. + async def __get_vivintsky_session(self, username: str, password: str) -> None: + """Perform PKCE oauth login.""" + if self.__refresh_token: + await self.refresh_token(self.__refresh_token) + if self.is_session_valid(): + return - Returns auth user data if successful. - """ + client_id = "ios" + redirect_uri = "vivint://app/oauth_redirect" + self.__code_verifier, code_challenge = generate_code_challenge() + state = generate_state() + + # Signal PKCE to OAuth endpoint to get appropriate cookies + resp = await self.__get( + path=f"{AUTH_ENDPOINT}/oauth2/auth", + params={ + "response_type": "code", + "client_id": client_id, + "scope": "openid email devices email_verified", + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + }, + allow_redirects=False, + ) + + if "location" in resp and redirect_uri in resp["location"]: + query = urllib.parse.urlparse(resp["location"]).query + redirect_params = urllib.parse.parse_qs(query) + auth_code = redirect_params["code"][0] + + return await self.__exchange_auth_code(auth_code) + + # Authenticate with username/password resp = await self.__post( - "login", - data=json.dumps( - { - "username": username, - "password": password, - "persist_session": persist_session, - } - ), + path=f"{AUTH_ENDPOINT}/idp/api/submit", + params={ + "client_id": client_id, + }, + data=json.dumps({"username": username, "password": password}), ) + + # Check for TOTP/MFA requirement + if "validate" in resp: + # SMS/emailed code + self.__mfa_pending = True + self.__mfa_type = "code" + raise VivintSkyApiMfaRequiredError(AuthenticationResponse.MFA_REQUIRED) + if "mfa" in resp: + # Authenticator app code + self.__mfa_pending = True + self.__mfa_type = "mfa" + raise VivintSkyApiMfaRequiredError(AuthenticationResponse.MFA_REQUIRED) + assert resp - return resp + self.__token = resp + + async def __exchange_auth_code(self, auth_code: str) -> None: + """Exchange an authorization code for an access token.""" + resp = await self.__post( + path=f"{AUTH_ENDPOINT}/oauth2/token", + data={ + "grant_type": "authorization_code", + "client_id": "ios", + "redirect_uri": "vivint://app/oauth_redirect", + "code": auth_code, + "code_verifier": self.__code_verifier, + }, + ) + assert resp + self.__token = resp async def __get( self, @@ -477,12 +564,16 @@ async def __get( allow_redirects=allow_redirects, ) - async def __post(self, path: str, data: str | None = None) -> dict | None: + async def __post( + self, path: str, data: Any | None = None, params: dict | None = None + ) -> dict | None: """Perform a post request.""" - return await self.__call(self.__client_session.post, path, data=data) + return await self.__call( + self.__client_session.post, path, data=data, params=params + ) async def __put( - self, path: str, headers: dict | None = None, data: str | None = None + self, path: str, headers: dict | None = None, data: Any | None = None ) -> dict | None: """Perform a put request.""" return await self.__call( @@ -495,23 +586,30 @@ async def __call( path: str, headers: dict | None = None, params: dict | None = None, - data: str | None = None, + data: Any | None = None, allow_redirects: bool | None = None, ) -> dict | None: """Perform a request with supplied parameters and reauthenticate if necessary.""" - if path != "login" and not self.is_session_valid(): + if AUTH_ENDPOINT not in path and not self.is_session_valid(): await self.connect() if self.__client_session.closed: raise VivintSkyApiError("The client session has been closed") - is_mfa_request = path == MFA_ENDPOINT + is_mfa_request = data and "code" in data if self.__mfa_pending and not is_mfa_request: raise VivintSkyApiMfaRequiredError(AuthenticationResponse.MFA_REQUIRED) + if AUTH_ENDPOINT not in path and self.__token: + if not headers: + headers = {} + headers["Authorization"] = f"Bearer {self.__token['access_token']}" + resp = await method( - path if is_mfa_request else f"{API_ENDPOINT}/{path}", + path + if is_mfa_request or AUTH_ENDPOINT in path + else f"{API_ENDPOINT}/{path}", headers=headers, params=params, data=data, @@ -532,12 +630,15 @@ async def __call( if is_mfa_request else resp_data.get(AuthenticationResponse.MESSAGE) ) + if not message: + message = resp_data.get(AuthenticationResponse.ERROR) if message == AuthenticationResponse.MFA_REQUIRED or is_mfa_request: self.__mfa_pending = True raise VivintSkyApiMfaRequiredError(message) - if resp.status == 400: - raise VivintSkyApiError(message) - raise VivintSkyApiAuthenticationError(message) + cls = VivintSkyApiError + if AUTH_ENDPOINT in path: + cls = VivintSkyApiAuthenticationError + raise cls(message) resp.raise_for_status() return None @@ -546,10 +647,10 @@ async def _send_grpc( callback: Callable[[beam_pb2_grpc.BeamStub, list[tuple[str, str]]], Message], ) -> None: """Send gRPC.""" + assert self.is_session_valid() creds = grpc.ssl_channel_credentials() - assert (cookie := self._get_session_cookie()) async with grpc.aio.secure_channel(GRPC_ENDPOINT, credentials=creds) as channel: stub: beam_pb2_grpc.BeamStub = beam_pb2_grpc.BeamStub(channel) # type: ignore - response = await callback(stub, [("session", cookie.value)]) + response = await callback(stub, [("token", self.__token["access_token"])]) _LOGGER.debug("Response received: %s", str(response))