276 lines
9.0 KiB
Python
276 lines
9.0 KiB
Python
"""Login handler for blink."""
|
|
|
|
import logging
|
|
from aiohttp import (
|
|
ClientSession,
|
|
ClientConnectionError,
|
|
ContentTypeError,
|
|
ClientResponse,
|
|
)
|
|
from blinkpy import api
|
|
from blinkpy.helpers import util
|
|
from blinkpy.helpers.constants import (
|
|
BLINK_URL,
|
|
APP_BUILD,
|
|
DEFAULT_USER_AGENT,
|
|
LOGIN_ENDPOINT,
|
|
TIMEOUT,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class Auth:
|
|
"""Class to handle login communication."""
|
|
|
|
def __init__(
|
|
self,
|
|
login_data=None,
|
|
no_prompt=False,
|
|
session=None,
|
|
agent=DEFAULT_USER_AGENT,
|
|
app_build=APP_BUILD,
|
|
):
|
|
"""
|
|
Initialize auth handler.
|
|
|
|
:param login_data: dictionary for login data
|
|
must contain the following:
|
|
- username
|
|
- password
|
|
:param no_prompt: Should any user input prompts
|
|
be suppressed? True/FALSE
|
|
"""
|
|
if login_data is None:
|
|
login_data = {}
|
|
self.data = login_data
|
|
self.token = login_data.get("token", None)
|
|
self.host = login_data.get("host", None)
|
|
self.region_id = login_data.get("region_id", None)
|
|
self.client_id = login_data.get("client_id", None)
|
|
self.account_id = login_data.get("account_id", None)
|
|
self.user_id = login_data.get("user_id", None)
|
|
self.login_response = None
|
|
self.is_errored = False
|
|
self.no_prompt = no_prompt
|
|
self._agent = agent
|
|
self._app_build = app_build
|
|
self.session = session if session else ClientSession()
|
|
|
|
@property
|
|
def login_attributes(self):
|
|
"""Return a dictionary of login attributes."""
|
|
self.data["token"] = self.token
|
|
self.data["host"] = self.host
|
|
self.data["region_id"] = self.region_id
|
|
self.data["client_id"] = self.client_id
|
|
self.data["account_id"] = self.account_id
|
|
self.data["user_id"] = self.user_id
|
|
return self.data
|
|
|
|
@property
|
|
def header(self):
|
|
"""Return authorization header."""
|
|
if self.token is None:
|
|
return None
|
|
return {
|
|
"APP-BUILD": self._app_build,
|
|
"TOKEN_AUTH": self.token,
|
|
"User-Agent": self._agent,
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
def validate_login(self):
|
|
"""Check login information and prompt if not available."""
|
|
self.data["username"] = self.data.get("username", None)
|
|
self.data["password"] = self.data.get("password", None)
|
|
if not self.no_prompt:
|
|
self.data = util.prompt_login_data(self.data)
|
|
self.data = util.validate_login_data(self.data)
|
|
|
|
async def login(self, login_url=LOGIN_ENDPOINT):
|
|
"""Attempt login to blink servers."""
|
|
self.validate_login()
|
|
_LOGGER.info("Attempting login with %s", login_url)
|
|
response = await api.request_login(
|
|
self,
|
|
login_url,
|
|
self.data,
|
|
is_retry=False,
|
|
)
|
|
try:
|
|
if response.status == 200:
|
|
return await response.json()
|
|
raise LoginError
|
|
except AttributeError as error:
|
|
raise LoginError from error
|
|
|
|
def logout(self, blink):
|
|
"""Log out."""
|
|
return api.request_logout(blink)
|
|
|
|
async def refresh_token(self):
|
|
"""Refresh auth token."""
|
|
self.is_errored = True
|
|
try:
|
|
_LOGGER.info("Token expired, attempting automatic refresh.")
|
|
self.login_response = await self.login()
|
|
self.extract_login_info()
|
|
self.is_errored = False
|
|
except LoginError as error:
|
|
_LOGGER.error("Login endpoint failed. Try again later.")
|
|
raise TokenRefreshFailed from error
|
|
except (TypeError, KeyError) as error:
|
|
_LOGGER.error("Malformed login response: %s", self.login_response)
|
|
raise TokenRefreshFailed from error
|
|
return True
|
|
|
|
def extract_login_info(self):
|
|
"""Extract login info from login response."""
|
|
self.region_id = self.login_response["account"]["tier"]
|
|
self.host = f"{self.region_id}.{BLINK_URL}"
|
|
self.token = self.login_response["auth"]["token"]
|
|
self.client_id = self.login_response["account"]["client_id"]
|
|
self.account_id = self.login_response["account"]["account_id"]
|
|
self.user_id = self.login_response["account"].get("user_id", None)
|
|
|
|
async def startup(self):
|
|
"""Initialize tokens for communication."""
|
|
self.validate_login()
|
|
if None in self.login_attributes.values():
|
|
await self.refresh_token()
|
|
|
|
async def validate_response(self, response: ClientResponse, json_resp):
|
|
"""Check for valid response."""
|
|
if not json_resp:
|
|
self.is_errored = False
|
|
return response
|
|
self.is_errored = True
|
|
try:
|
|
if response.status in [101, 401]:
|
|
raise UnauthorizedError
|
|
if response.status == 404:
|
|
raise ClientConnectionError
|
|
json_data = await response.json()
|
|
except (AttributeError, ValueError) as error:
|
|
raise BlinkBadResponse from error
|
|
except ContentTypeError as error:
|
|
_LOGGER.warning("Got text for JSON response: %s", await response.text())
|
|
raise BlinkBadResponse from error
|
|
|
|
self.is_errored = False
|
|
return json_data
|
|
|
|
async def query(
|
|
self,
|
|
url=None,
|
|
data=None,
|
|
headers=None,
|
|
reqtype="get",
|
|
stream=False,
|
|
json_resp=True,
|
|
is_retry=False,
|
|
timeout=TIMEOUT,
|
|
):
|
|
"""Perform server requests.
|
|
|
|
:param url: URL to perform request
|
|
:param data: Data to send
|
|
:param headers: Headers to send
|
|
:param reqtype: Can be 'get' or 'post' (default: 'get')
|
|
:param stream: Stream response? True/FALSE
|
|
:param json_resp: Return JSON response? TRUE/False
|
|
:param is_retry: Is this part of a re-auth attempt? True/FALSE
|
|
"""
|
|
try:
|
|
if reqtype == "get":
|
|
response = await self.session.get(
|
|
url=url, data=data, headers=headers, timeout=timeout
|
|
)
|
|
else:
|
|
response = await self.session.post(
|
|
url=url, data=data, headers=headers, timeout=timeout
|
|
)
|
|
return await self.validate_response(response, json_resp)
|
|
except (ClientConnectionError, TimeoutError) as er:
|
|
_LOGGER.error(
|
|
"Connection error. Endpoint %s possibly down or throttled. Error: %s",
|
|
url,
|
|
er,
|
|
)
|
|
except BlinkBadResponse:
|
|
code = None
|
|
reason = None
|
|
try:
|
|
code = response.status
|
|
reason = response.reason
|
|
except AttributeError:
|
|
pass
|
|
_LOGGER.error(
|
|
"Expected json response from %s, but received: %s: %s",
|
|
url,
|
|
code,
|
|
reason,
|
|
)
|
|
except UnauthorizedError:
|
|
try:
|
|
if not is_retry:
|
|
await self.refresh_token()
|
|
return await self.query(
|
|
url=url,
|
|
data=data,
|
|
headers=self.header,
|
|
reqtype=reqtype,
|
|
stream=stream,
|
|
json_resp=json_resp,
|
|
is_retry=True,
|
|
timeout=timeout,
|
|
)
|
|
_LOGGER.error("Unable to access %s after token refresh.", url)
|
|
except TokenRefreshFailed:
|
|
_LOGGER.error("Unable to refresh token.")
|
|
return None
|
|
|
|
async def send_auth_key(self, blink, key):
|
|
"""Send 2FA key to blink servers."""
|
|
if key is not None:
|
|
response = await api.request_verify(self, blink, key)
|
|
try:
|
|
json_resp = await response.json()
|
|
blink.available = json_resp["valid"]
|
|
if not blink.available:
|
|
_LOGGER.error("%s", json_resp["message"])
|
|
return False
|
|
except (KeyError, TypeError, ContentTypeError) as er:
|
|
_LOGGER.error(
|
|
"Did not receive valid response from server. Error: %s",
|
|
er,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def check_key_required(self):
|
|
"""Check if 2FA key is required."""
|
|
try:
|
|
if self.login_response["account"]["client_verification_required"]:
|
|
return True
|
|
except (KeyError, TypeError):
|
|
pass
|
|
return False
|
|
|
|
|
|
class TokenRefreshFailed(Exception):
|
|
"""Class to throw failed refresh exception."""
|
|
|
|
|
|
class LoginError(Exception):
|
|
"""Class to throw failed login exception."""
|
|
|
|
|
|
class BlinkBadResponse(Exception):
|
|
"""Class to throw bad json response exception."""
|
|
|
|
|
|
class UnauthorizedError(Exception):
|
|
"""Class to throw an unauthorized access error."""
|