Total refactoring of auth logic
- Still need major test updates - Still needs more thorough testing - Want to add an option to override login endpoint
This commit is contained in:
+17
-42
@@ -2,8 +2,7 @@
|
||||
|
||||
import logging
|
||||
from json import dumps
|
||||
import blinkpy.helpers.errors as ERROR
|
||||
from blinkpy.helpers.util import http_req, get_time, BlinkException, Throttle
|
||||
from blinkpy.helpers.util import get_time, Throttle
|
||||
from blinkpy.helpers.constants import DEFAULT_URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -12,45 +11,31 @@ MIN_THROTTLE_TIME = 2
|
||||
|
||||
|
||||
def request_login(
|
||||
blink,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
notification_key,
|
||||
uid,
|
||||
is_retry=False,
|
||||
device_id="Blinkpy",
|
||||
auth, url, login_data, is_retry=False,
|
||||
):
|
||||
"""
|
||||
Login request.
|
||||
|
||||
:param blink: Blink instance.
|
||||
:param auth: Auth instance.
|
||||
:param url: Login url.
|
||||
:param username: Blink username.
|
||||
:param password: Blink password.
|
||||
:param notification_key: Randomly genereated key.
|
||||
:param uid: Randomly generated unique id key.
|
||||
:param is_retry: Is this part of a re-authorization attempt?
|
||||
:param device_id: Name of application to send at login.
|
||||
:login_data: Dictionary containing blink login data.
|
||||
"""
|
||||
headers = {"Host": DEFAULT_URL, "Content-Type": "application/json"}
|
||||
data = dumps(
|
||||
{
|
||||
"email": username,
|
||||
"password": password,
|
||||
"notification_key": notification_key,
|
||||
"unique_id": uid,
|
||||
"email": login_data["username"],
|
||||
"password": login_data["password"],
|
||||
"notification_key": login_data["notification_key"],
|
||||
"unique_id": login_data["uid"],
|
||||
"app_version": "6.0.7 (520300) #afb0be72a",
|
||||
"device_identifier": login_data["device_id"],
|
||||
"client_name": "Computer",
|
||||
"client_type": "android",
|
||||
"device_identifier": device_id,
|
||||
"device_name": "Blinkpy",
|
||||
"os_version": "5.1.1",
|
||||
"reauth": "true",
|
||||
}
|
||||
)
|
||||
return http_req(
|
||||
blink,
|
||||
return auth.query(
|
||||
url=url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
@@ -60,19 +45,14 @@ def request_login(
|
||||
)
|
||||
|
||||
|
||||
def request_verify(blink, verify_key):
|
||||
def request_verify(auth, blink, verify_key):
|
||||
"""Send verification key to blink servers."""
|
||||
url = "{}/api/v4/account/{}/client/{}/pin/verify".format(
|
||||
blink.urls.base_url, blink.account_id, blink.client_id
|
||||
)
|
||||
data = dumps({"pin": verify_key})
|
||||
return http_req(
|
||||
blink,
|
||||
url=url,
|
||||
headers=blink.auth_header,
|
||||
data=data,
|
||||
json_resp=False,
|
||||
reqtype="post",
|
||||
return auth.query(
|
||||
url=url, headers=auth.header, data=data, json_resp=False, reqtype="post",
|
||||
)
|
||||
|
||||
|
||||
@@ -288,13 +268,10 @@ def http_get(blink, url, stream=False, json=True, is_retry=False):
|
||||
:param json: Return json response? TRUE/False
|
||||
:param is_retry: Is this part of a re-auth attempt?
|
||||
"""
|
||||
if blink.auth_header is None:
|
||||
raise BlinkException(ERROR.AUTH_TOKEN)
|
||||
_LOGGER.debug("Making GET request to %s", url)
|
||||
return http_req(
|
||||
blink,
|
||||
return blink.auth.query(
|
||||
url=url,
|
||||
headers=blink.auth_header,
|
||||
headers=blink.auth.header,
|
||||
reqtype="get",
|
||||
stream=stream,
|
||||
json_resp=json,
|
||||
@@ -309,9 +286,7 @@ def http_post(blink, url, is_retry=False):
|
||||
:param url: URL to perfom post request.
|
||||
:param is_retry: Is this part of a re-auth attempt?
|
||||
"""
|
||||
if blink.auth_header is None:
|
||||
raise BlinkException(ERROR.AUTH_TOKEN)
|
||||
_LOGGER.debug("Making POST request to %s", url)
|
||||
return http_req(
|
||||
blink, url=url, headers=blink.auth_header, reqtype="post", is_retry=is_retry
|
||||
return blink.auth.query(
|
||||
url=url, headers=blink.auth.header, reqtype="post", is_retry=is_retry
|
||||
)
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
"""Login handler for blink."""
|
||||
import logging
|
||||
from functools import partial
|
||||
from requests import Request, Session, exceptions
|
||||
from blinkpy import api
|
||||
from blinkpy.helpers import util
|
||||
from blinkpy.helpers.constants import BLINK_URL, LOGIN_ENDPOINT
|
||||
from blinkpy.helpers import errors as ERROR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Auth:
|
||||
"""Class to handle login communication."""
|
||||
|
||||
def __init__(self, login_data=None, no_prompt=False):
|
||||
"""
|
||||
Initialize auth handler.
|
||||
|
||||
:param login_data: dictionary for login data
|
||||
can contain the following:
|
||||
- username
|
||||
- password
|
||||
- device_id
|
||||
- uid
|
||||
- notification_key
|
||||
:param no_prompt: Should any user input prompts
|
||||
be supressed? True/FALSE
|
||||
"""
|
||||
self.data = login_data
|
||||
self.token = None
|
||||
self.host = None
|
||||
self.region_id = None
|
||||
self.login_response = None
|
||||
self.no_prompt = no_prompt
|
||||
self.session = self.create_session()
|
||||
self.validate_login()
|
||||
|
||||
@property
|
||||
def login_attributes(self):
|
||||
"""Return a dictionary of login attributes."""
|
||||
attributes = {
|
||||
"token": self.token,
|
||||
"host": self.host,
|
||||
"region_id": self.region_id,
|
||||
}
|
||||
return util.merge_dicts(attributes, self.data)
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
"""Return authorization header."""
|
||||
if self.token is None:
|
||||
return None
|
||||
return {"Host": self.host, "TOKEN_AUTH": self.token}
|
||||
|
||||
def create_session(self):
|
||||
"""Create a session for blink communication."""
|
||||
sess = Session()
|
||||
sess.get = partial(sess.get, timeout=10)
|
||||
return sess
|
||||
|
||||
def prepare_request(self, url, headers, data, reqtype):
|
||||
"""Prepare a request."""
|
||||
req = Request(reqtype.upper(), url, headers=headers, data=data)
|
||||
return req.prepare()
|
||||
|
||||
def validate_login(self):
|
||||
"""Check login information and prompt if not available."""
|
||||
if "username" not in self.data:
|
||||
self.data["username"] = None
|
||||
if "password" not in self.data:
|
||||
self.data["password"] = None
|
||||
if not self.no_prompt:
|
||||
self.data = util.prompt_login_data(self.data)
|
||||
|
||||
self.data = util.validate_login_data(self.data)
|
||||
|
||||
def login(self, login_url=LOGIN_ENDPOINT):
|
||||
"""Attempt login to blink servers."""
|
||||
_LOGGER.info("Attempting login with %s", login_url)
|
||||
response = api.request_login(self, login_url, self.data, is_retry=False,)
|
||||
try:
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
raise LoginError
|
||||
except AttributeError:
|
||||
raise LoginError
|
||||
|
||||
def refresh_token(self):
|
||||
"""Refresh auth token."""
|
||||
try:
|
||||
_LOGGER.info("Token expired, attempting automatic refresh.")
|
||||
self.login_response = self.login()
|
||||
region_ids = (*self.login_response["region"],)
|
||||
self.region_id = region_ids[0]
|
||||
self.host = f"{self.region_id}.{BLINK_URL}"
|
||||
self.token = self.login_response["authtoken"]["authtoken"]
|
||||
except KeyError:
|
||||
_LOGGER.error("Malformed login response: %s", self.login_response)
|
||||
raise TokenRefreshFailed
|
||||
return True
|
||||
|
||||
def validate_response(self, response, json_resp):
|
||||
"""Check for valid response."""
|
||||
if not json_resp:
|
||||
return response
|
||||
|
||||
json_data = response.json()
|
||||
try:
|
||||
if json_data["code"] in ERROR.BLINK_ERRORS:
|
||||
raise exceptions.ConnectionError
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return json_data
|
||||
|
||||
def query(
|
||||
self,
|
||||
url=None,
|
||||
data=None,
|
||||
headers=None,
|
||||
reqtype="get",
|
||||
stream=False,
|
||||
json_resp=True,
|
||||
is_retry=False,
|
||||
):
|
||||
"""
|
||||
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 a retry attempt? True/FALSE
|
||||
"""
|
||||
|
||||
req = self.prepare_request(url, headers, data, reqtype)
|
||||
try:
|
||||
response = self.session.send(req, stream=stream)
|
||||
return self.validate_response(response, json_resp)
|
||||
|
||||
except (exceptions.ConnectionError, exceptions.Timeout, TokenRefreshFailed):
|
||||
try:
|
||||
if not is_retry:
|
||||
self.refresh_token()
|
||||
return self.query(
|
||||
url=url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
reqtype=reqtype,
|
||||
stream=stream,
|
||||
json_resp=json_resp,
|
||||
is_retry=True,
|
||||
)
|
||||
except (TokenRefreshFailed, LoginError):
|
||||
_LOGGER.error("Endpoint %s failed. Unable to refresh login tokens", url)
|
||||
_LOGGER.error("Endpoint %s failed", url)
|
||||
return None
|
||||
|
||||
def send_auth_key(self, blink, key):
|
||||
"""Send 2FA key to blink servers."""
|
||||
if key is not None:
|
||||
response = api.request_verify(self, blink, key)
|
||||
try:
|
||||
json_resp = response.json()
|
||||
blink.available = json_resp["valid"]
|
||||
except (KeyError, TypeError):
|
||||
_LOGGER.error("Did not receive valid response from server.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_key_required(self):
|
||||
"""Check if 2FA key is required."""
|
||||
try:
|
||||
if self.login_response["client"]["verification_required"]:
|
||||
return True
|
||||
except KeyError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class TokenRefreshFailed(Exception):
|
||||
"""Class to throw failed refresh exception."""
|
||||
|
||||
|
||||
class LoginError(Exception):
|
||||
"""Class to throw failed login exception."""
|
||||
+145
-152
@@ -24,22 +24,15 @@ from slugify import slugify
|
||||
|
||||
from blinkpy import api
|
||||
from blinkpy.sync_module import BlinkSyncModule
|
||||
from blinkpy.helpers.util import (
|
||||
create_session,
|
||||
merge_dicts,
|
||||
get_time,
|
||||
BlinkURLHandler,
|
||||
Throttle,
|
||||
)
|
||||
from blinkpy.helpers import util
|
||||
from blinkpy.helpers.constants import (
|
||||
BLINK_URL,
|
||||
DEFAULT_MOTION_INTERVAL,
|
||||
DEFAULT_REFRESH,
|
||||
DEVICE_ID,
|
||||
MIN_THROTTLE_TIME,
|
||||
LOGIN_URLS,
|
||||
)
|
||||
from blinkpy.helpers.constants import __version__
|
||||
from blinkpy.login_handler import LoginHandler
|
||||
from blinkpy.auth import Auth, TokenRefreshFailed, LoginError
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -50,25 +43,22 @@ class Blink:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data=None,
|
||||
username=None,
|
||||
password=None,
|
||||
cred_file=None,
|
||||
refresh_rate=DEFAULT_REFRESH,
|
||||
motion_interval=DEFAULT_MOTION_INTERVAL,
|
||||
legacy_subdomain=False,
|
||||
no_prompt=False,
|
||||
persist_key=None,
|
||||
device_id="Blinkpy",
|
||||
device_id=DEVICE_ID,
|
||||
):
|
||||
"""
|
||||
Initialize Blink system.
|
||||
|
||||
:param data: Blink login data. Ignores username, password,
|
||||
and device_id if supplied.
|
||||
:param username: Blink username (usually email address)
|
||||
:param password: Blink password
|
||||
:param cred_file: JSON formatted file to store credentials.
|
||||
If username and password are given, file
|
||||
is ignored. Otherwise, username and password
|
||||
are loaded from file.
|
||||
:param refresh_rate: Refresh rate of blink information.
|
||||
Defaults to 15 (seconds)
|
||||
:param motion_interval: How far back to register motion in minutes.
|
||||
@@ -86,166 +76,161 @@ class Blink:
|
||||
fit the implementation (ie. "Home Assistant" in a
|
||||
Home Assistant integration).
|
||||
"""
|
||||
self.login_handler = LoginHandler(
|
||||
username=username,
|
||||
password=password,
|
||||
cred_file=cred_file,
|
||||
persist_key=persist_key,
|
||||
device_id=device_id,
|
||||
)
|
||||
self._token = None
|
||||
self._auth_header = None
|
||||
self._host = None
|
||||
if data is None:
|
||||
data = {}
|
||||
data["username"] = username
|
||||
data["password"] = password
|
||||
data["device_id"] = device_id
|
||||
|
||||
self.auth = Auth(login_data=data, no_prompt=no_prompt)
|
||||
self.account_id = None
|
||||
self.client_id = None
|
||||
self.network_ids = []
|
||||
self.urls = None
|
||||
self.sync = CaseInsensitiveDict({})
|
||||
self.region = None
|
||||
self.region_id = None
|
||||
self.last_refresh = None
|
||||
self.refresh_rate = refresh_rate
|
||||
self.session = create_session()
|
||||
self.networks = []
|
||||
self.cameras = CaseInsensitiveDict({})
|
||||
self.video_list = CaseInsensitiveDict({})
|
||||
self.login_url = LOGIN_URLS[0]
|
||||
self.login_urls = []
|
||||
self.motion_interval = motion_interval
|
||||
self.version = __version__
|
||||
self.legacy = legacy_subdomain
|
||||
self.no_prompt = no_prompt
|
||||
self.available = False
|
||||
self.key_required = False
|
||||
self.login_response = {}
|
||||
|
||||
@property
|
||||
def auth_header(self):
|
||||
"""Return the authentication header."""
|
||||
return self._auth_header
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Perform full system setup.
|
||||
|
||||
Method logs in and sets auth token, urls, and ids for future requests.
|
||||
Essentially this is just a wrapper function for ease of use.
|
||||
"""
|
||||
if not self.available:
|
||||
self.get_auth_token()
|
||||
|
||||
if self.key_required and not self.no_prompt:
|
||||
email = self.login_handler.data["username"]
|
||||
key = input("Enter code sent to {}: ".format(email))
|
||||
result = self.login_handler.send_auth_key(self, key)
|
||||
self.key_required = not result
|
||||
self.setup_post_verify()
|
||||
elif not self.key_required:
|
||||
self.setup_post_verify()
|
||||
|
||||
def setup_post_verify(self):
|
||||
"""Initialize blink system after verification."""
|
||||
camera_list = self.get_cameras()
|
||||
networks = self.get_ids()
|
||||
for network_name, network_id in networks.items():
|
||||
if network_id not in camera_list.keys():
|
||||
camera_list[network_id] = {}
|
||||
_LOGGER.warning("No cameras found for %s", network_name)
|
||||
sync_module = BlinkSyncModule(
|
||||
self, network_name, network_id, camera_list[network_id]
|
||||
)
|
||||
sync_module.start()
|
||||
self.sync[network_name] = sync_module
|
||||
self.cameras = self.merge_cameras()
|
||||
self.available = self.refresh()
|
||||
self.key_required = False
|
||||
|
||||
def login(self):
|
||||
"""Perform server login. DEPRECATED."""
|
||||
_LOGGER.warning(
|
||||
"Method is deprecated and will be removed in a future version. Please use the LoginHandler.login() method instead."
|
||||
)
|
||||
return self.login_handler.login(self)
|
||||
|
||||
def get_auth_token(self, is_retry=False):
|
||||
"""Retrieve the authentication token from Blink."""
|
||||
self.login_response = self.login_handler.login(self)
|
||||
if not self.login_response:
|
||||
self.available = False
|
||||
return False
|
||||
self.setup_params(self.login_response)
|
||||
if self.login_handler.check_key_required(self):
|
||||
self.key_required = True
|
||||
return self._auth_header
|
||||
|
||||
def setup_params(self, response):
|
||||
"""Retrieve blink parameters from login response."""
|
||||
self.login_url = self.login_handler.login_url
|
||||
((self.region_id, self.region),) = response["region"].items()
|
||||
self._host = "{}.{}".format(self.region_id, BLINK_URL)
|
||||
self._token = response["authtoken"]["authtoken"]
|
||||
self._auth_header = {"Host": self._host, "TOKEN_AUTH": self._token}
|
||||
self.urls = BlinkURLHandler(self.region_id, legacy=self.legacy)
|
||||
self.networks = self.get_networks()
|
||||
self.client_id = response["client"]["id"]
|
||||
self.account_id = response["account"]["id"]
|
||||
|
||||
def get_networks(self):
|
||||
"""Get network information."""
|
||||
response = api.request_networks(self)
|
||||
try:
|
||||
return response["summary"]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def get_ids(self):
|
||||
"""Set the network ID and Account ID."""
|
||||
all_networks = []
|
||||
network_dict = {}
|
||||
for network, status in self.networks.items():
|
||||
if status["onboarded"]:
|
||||
all_networks.append("{}".format(network))
|
||||
network_dict[status["name"]] = network
|
||||
|
||||
self.network_ids = all_networks
|
||||
return network_dict
|
||||
|
||||
def get_cameras(self):
|
||||
"""Retrieve a camera list for each onboarded network."""
|
||||
response = api.request_homescreen(self)
|
||||
try:
|
||||
all_cameras = {}
|
||||
for camera in response["cameras"]:
|
||||
camera_network = str(camera["network_id"])
|
||||
camera_name = camera["name"]
|
||||
camera_id = camera["id"]
|
||||
camera_info = {"name": camera_name, "id": camera_id}
|
||||
if camera_network not in all_cameras:
|
||||
all_cameras[camera_network] = []
|
||||
|
||||
all_cameras[camera_network].append(camera_info)
|
||||
return all_cameras
|
||||
except KeyError:
|
||||
_LOGGER.error("Initialization failue. Could not retrieve cameras.")
|
||||
return {}
|
||||
|
||||
@Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def refresh(self, force_cache=False):
|
||||
@util.Throttle(seconds=MIN_THROTTLE_TIME)
|
||||
def refresh(self, force=False):
|
||||
"""
|
||||
Perform a system refresh.
|
||||
|
||||
:param force_cache: Force an update of the camera cache
|
||||
:param force: Force an update of the camera data
|
||||
"""
|
||||
if self.check_if_ok_to_update() or force_cache:
|
||||
if self.check_if_ok_to_update() or force:
|
||||
if not self.available:
|
||||
self.auth.refresh_token()
|
||||
self.setup_post_verify()
|
||||
|
||||
for sync_name, sync_module in self.sync.items():
|
||||
_LOGGER.debug("Attempting refresh of sync %s", sync_name)
|
||||
sync_module.refresh(force_cache=force_cache)
|
||||
if not force_cache:
|
||||
sync_module.refresh(force_cache=force)
|
||||
if not force:
|
||||
# Prevents rapid clearing of motion detect property
|
||||
self.last_refresh = int(time.time())
|
||||
return True
|
||||
return False
|
||||
|
||||
def start(self):
|
||||
"""Perform full system setup."""
|
||||
try:
|
||||
self.auth.refresh_token()
|
||||
self.setup_login_ids()
|
||||
self.setup_urls()
|
||||
except (LoginError, TokenRefreshFailed, BlinkSetupError):
|
||||
_LOGGER.error("Cannot setup Blink platform.")
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
self.key_required = self.auth.check_key_required()
|
||||
if self.key_required:
|
||||
if self.no_prompt:
|
||||
return True
|
||||
self.setup_prompt_2fa()
|
||||
return self.setup_post_verify()
|
||||
|
||||
def setup_prompt_2fa(self):
|
||||
"""Prompt for 2FA."""
|
||||
email = self.auth.data["username"]
|
||||
pin = input(f"Enter code sent to {email}: ")
|
||||
result = self.auth.send_auth_key(self, pin)
|
||||
self.key_required = not result
|
||||
|
||||
def setup_post_verify(self):
|
||||
"""Initialize blink system after verification."""
|
||||
try:
|
||||
self.setup_networks()
|
||||
networks = self.setup_network_ids()
|
||||
cameras = self.setup_camera_list()
|
||||
except BlinkSetupError:
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
for name, network_id in networks.items():
|
||||
sync_cameras = cameras.get(network_id, {})
|
||||
self.setup_sync_module(name, network_id, sync_cameras)
|
||||
self.cameras = self.merge_cameras()
|
||||
|
||||
self.available = True
|
||||
self.key_required = False
|
||||
return True
|
||||
|
||||
def setup_sync_module(self, name, network_id, cameras):
|
||||
"""Initialize a sync module."""
|
||||
self.sync[name] = BlinkSyncModule(self, name, network_id, cameras)
|
||||
self.sync[name].start()
|
||||
|
||||
def setup_camera_list(self):
|
||||
"""Create camera list for onboarded networks."""
|
||||
all_cameras = {}
|
||||
response = api.request_homescreen(self)
|
||||
try:
|
||||
for camera in response["cameras"]:
|
||||
camera_network = str(camera["network_id"])
|
||||
if camera_network not in all_cameras:
|
||||
all_cameras[camera_network] = []
|
||||
all_cameras[camera_network].append(
|
||||
{"name": camera["name"], "id": camera["id"]}
|
||||
)
|
||||
return all_cameras
|
||||
except KeyError:
|
||||
_LOGGER.error("Unable to retrieve cameras from response %s", response)
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_login_ids(self):
|
||||
"""Retrieve login id numbers from login response."""
|
||||
try:
|
||||
self.client_id = self.auth.login_response["client"]["id"]
|
||||
self.account_id = self.auth.login_response["account"]["id"]
|
||||
except KeyError:
|
||||
_LOGGER.error("Malformed login response of %s", self.auth.login_response)
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_urls(self):
|
||||
"""Create urls for api."""
|
||||
try:
|
||||
self.urls = util.BlinkURLHandler(self.auth.region_id)
|
||||
except TypeError:
|
||||
_LOGGER.error(
|
||||
"Unable to extract region is from response %s", self.auth.login_response
|
||||
)
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_networks(self):
|
||||
"""Get network information."""
|
||||
response = api.request_networks(self)
|
||||
try:
|
||||
self.networks = response["summary"]
|
||||
except KeyError:
|
||||
raise BlinkSetupError
|
||||
|
||||
def setup_network_ids(self):
|
||||
"""Create the network ids for onboarded networks."""
|
||||
all_networks = []
|
||||
network_dict = {}
|
||||
try:
|
||||
for network, status in self.networks.items():
|
||||
if status["onboarded"]:
|
||||
all_networks.append(f"{network}")
|
||||
network_dict[status["name"]] = network
|
||||
except AttributeError:
|
||||
_LOGGER.error(
|
||||
"Unable to retrieve network information from %s", self.networks
|
||||
)
|
||||
raise BlinkSetupError
|
||||
|
||||
self.network_ids = all_networks
|
||||
return network_dict
|
||||
|
||||
def check_if_ok_to_update(self):
|
||||
"""Check if it is ok to perform an http request."""
|
||||
current_time = int(time.time())
|
||||
@@ -260,9 +245,13 @@ class Blink:
|
||||
"""Merge all sync camera dicts into one."""
|
||||
combined = CaseInsensitiveDict({})
|
||||
for sync in self.sync:
|
||||
combined = merge_dicts(combined, self.sync[sync].cameras)
|
||||
combined = util.merge_dicts(combined, self.sync[sync].cameras)
|
||||
return combined
|
||||
|
||||
def save(self, file_name):
|
||||
"""Save login data to file."""
|
||||
util.json_save(self.auth.login_attributes, file_name)
|
||||
|
||||
def download_videos(self, path, since=None, camera="all", stop=10, debug=False):
|
||||
"""
|
||||
Download all videos from server since specified time.
|
||||
@@ -283,7 +272,7 @@ class Blink:
|
||||
parsed_datetime = parse(since, fuzzy=True)
|
||||
since_epochs = parsed_datetime.timestamp()
|
||||
|
||||
formatted_date = get_time(time_to_convert=since_epochs)
|
||||
formatted_date = util.get_time(time_to_convert=since_epochs)
|
||||
_LOGGER.info("Retrieving videos since %s", formatted_date)
|
||||
|
||||
if not isinstance(camera, list):
|
||||
@@ -343,3 +332,7 @@ class Blink:
|
||||
camera_name, created_at, address, filename
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class BlinkSetupError(Exception):
|
||||
"""Class to handle setup errors."""
|
||||
|
||||
@@ -48,10 +48,10 @@ URLS
|
||||
BLINK_URL = "immedia-semi.com"
|
||||
DEFAULT_URL = "{}.{}".format("rest-prod", BLINK_URL)
|
||||
BASE_URL = "https://{}".format(DEFAULT_URL)
|
||||
LOGIN_ENDPOINT = "{}/api/v3/login".format(BASE_URL)
|
||||
LOGIN_URLS = [
|
||||
"{}/api/v4/login".format(BASE_URL),
|
||||
"{}/api/v3/login".format(BASE_URL),
|
||||
"{}/api/v2/login".format(BASE_URL),
|
||||
]
|
||||
|
||||
"""
|
||||
@@ -62,6 +62,7 @@ ONLINE = {"online": True, "offline": False}
|
||||
"""
|
||||
OTHER
|
||||
"""
|
||||
DEVICE_ID = "Blinkpy"
|
||||
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
||||
DEFAULT_MOTION_INTERVAL = 1
|
||||
DEFAULT_REFRESH = 30
|
||||
|
||||
+43
-99
@@ -1,19 +1,38 @@
|
||||
"""Useful functions for blinkpy."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import secrets
|
||||
from calendar import timegm
|
||||
from functools import partial, wraps
|
||||
from requests import Request, Session, exceptions
|
||||
from functools import wraps
|
||||
from getpass import getpass
|
||||
import dateutil.parser
|
||||
from blinkpy.helpers.constants import BLINK_URL, TIMESTAMP_FORMAT
|
||||
import blinkpy.helpers.errors as ERROR
|
||||
from blinkpy.helpers import constants as const
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def json_load(file_name):
|
||||
"""Load json credentials from file."""
|
||||
try:
|
||||
with open(file_name, "r") as json_file:
|
||||
data = json.load(json_file)
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
_LOGGER.error("Could not find %s", file_name)
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error("File %s has improperly formatted json", file_name)
|
||||
return None
|
||||
|
||||
|
||||
def json_save(data, file_name):
|
||||
"""Save data to file location."""
|
||||
with open(file_name, "w") as json_file:
|
||||
json.dump(data, json_file, indent=4)
|
||||
|
||||
|
||||
def gen_uid(size):
|
||||
"""Create a random sring."""
|
||||
full_token = secrets.token_hex(size)
|
||||
@@ -34,7 +53,7 @@ def get_time(time_to_convert=None):
|
||||
"""Create blink-compatible timestamp."""
|
||||
if time_to_convert is None:
|
||||
time_to_convert = time.time()
|
||||
return time.strftime(TIMESTAMP_FORMAT, time.gmtime(time_to_convert))
|
||||
return time.strftime(const.TIMESTAMP_FORMAT, time.gmtime(time_to_convert))
|
||||
|
||||
|
||||
def merge_dicts(dict_a, dict_b):
|
||||
@@ -48,103 +67,28 @@ def merge_dicts(dict_a, dict_b):
|
||||
return {**dict_a, **dict_b}
|
||||
|
||||
|
||||
def create_session():
|
||||
"""
|
||||
Create a session for blink communication.
|
||||
def prompt_login_data(data):
|
||||
"""Prompt user for username and password."""
|
||||
if data["username"] is None:
|
||||
data["username"] = input("Username:")
|
||||
if data["password"] is None:
|
||||
data["password"] = getpass("Password:")
|
||||
|
||||
From @ericfrederich via
|
||||
https://github.com/kennethreitz/requests/issues/2011
|
||||
"""
|
||||
sess = Session()
|
||||
sess.get = partial(sess.get, timeout=5)
|
||||
return sess
|
||||
return data
|
||||
|
||||
|
||||
def attempt_reauthorization(blink):
|
||||
"""Attempt to refresh auth token and links."""
|
||||
_LOGGER.info("Auth token expired, attempting reauthorization.")
|
||||
headers = blink.get_auth_token(is_retry=True)
|
||||
return headers
|
||||
def validate_login_data(data):
|
||||
"""Check for missing keys."""
|
||||
valid_keys = {
|
||||
"uid": gen_uid(const.SIZE_UID),
|
||||
"notification_key": gen_uid(const.SIZE_NOTIFICATION_KEY),
|
||||
"device_id": const.DEVICE_ID,
|
||||
}
|
||||
for key in valid_keys:
|
||||
if key not in data:
|
||||
data[key] = valid_keys[key]
|
||||
|
||||
|
||||
def http_req(
|
||||
blink,
|
||||
url="http://example.com",
|
||||
data=None,
|
||||
headers=None,
|
||||
reqtype="get",
|
||||
stream=False,
|
||||
json_resp=True,
|
||||
is_retry=False,
|
||||
):
|
||||
"""
|
||||
Perform server requests and check if reauthorization neccessary.
|
||||
|
||||
:param blink: Blink instance
|
||||
:param url: URL to perform request
|
||||
:param data: Data to send (default: None)
|
||||
:param headers: Headers to send (default: None)
|
||||
: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 a retry attempt? True/FALSE
|
||||
"""
|
||||
if reqtype == "post":
|
||||
req = Request("POST", url, headers=headers, data=data)
|
||||
elif reqtype == "get":
|
||||
req = Request("GET", url, headers=headers)
|
||||
else:
|
||||
_LOGGER.error("Invalid request type: %s", reqtype)
|
||||
raise BlinkException(ERROR.REQUEST)
|
||||
|
||||
prepped = req.prepare()
|
||||
|
||||
try:
|
||||
response = blink.session.send(prepped, stream=stream)
|
||||
if json_resp and "code" in response.json():
|
||||
resp_dict = response.json()
|
||||
code = resp_dict["code"]
|
||||
message = resp_dict["message"]
|
||||
if is_retry and code in ERROR.BLINK_ERRORS:
|
||||
_LOGGER.error("Cannot obtain new token for server auth.")
|
||||
return None
|
||||
elif code in ERROR.BLINK_ERRORS:
|
||||
headers = attempt_reauthorization(blink)
|
||||
if not headers:
|
||||
raise exceptions.ConnectionError
|
||||
return http_req(
|
||||
blink,
|
||||
url=url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
reqtype=reqtype,
|
||||
stream=stream,
|
||||
json_resp=json_resp,
|
||||
is_retry=True,
|
||||
)
|
||||
_LOGGER.warning("Response from server: %s - %s", code, message)
|
||||
|
||||
except (exceptions.ConnectionError, exceptions.Timeout):
|
||||
_LOGGER.info("Cannot connect to server with url %s.", url)
|
||||
if not is_retry:
|
||||
headers = attempt_reauthorization(blink)
|
||||
return http_req(
|
||||
blink,
|
||||
url=url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
reqtype=reqtype,
|
||||
stream=stream,
|
||||
json_resp=json_resp,
|
||||
is_retry=True,
|
||||
)
|
||||
_LOGGER.error("Endpoint %s failed. Possible issue with Blink servers.", url)
|
||||
return None
|
||||
|
||||
if json_resp:
|
||||
return response.json()
|
||||
|
||||
return response
|
||||
return data
|
||||
|
||||
|
||||
class BlinkException(Exception):
|
||||
@@ -169,7 +113,7 @@ class BlinkURLHandler:
|
||||
self.subdomain = "rest-{}".format(region_id)
|
||||
if legacy:
|
||||
self.subdomain = "rest.{}".format(region_id)
|
||||
self.base_url = "https://{}.{}".format(self.subdomain, BLINK_URL)
|
||||
self.base_url = "https://{}.{}".format(self.subdomain, const.BLINK_URL)
|
||||
self.home_url = "{}/homescreen".format(self.base_url)
|
||||
self.event_url = "{}/events/network".format(self.base_url)
|
||||
self.network_url = "{}/network".format(self.base_url)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Login handler for blink."""
|
||||
import json
|
||||
import logging
|
||||
from os.path import isfile
|
||||
from getpass import getpass
|
||||
from blinkpy import api
|
||||
from blinkpy.helpers import util
|
||||
from blinkpy.helpers import constants as const
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginHandler:
|
||||
"""Class to handle login communication."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username=None,
|
||||
password=None,
|
||||
cred_file=None,
|
||||
persist_key=None,
|
||||
device_id="Blinkpy",
|
||||
):
|
||||
"""
|
||||
Initialize login handler.
|
||||
|
||||
:param username: Blink username
|
||||
:param password: Blink password
|
||||
:param cred_file: JSON formatted credential file.
|
||||
:param persist_key: File location of persistant key.
|
||||
:param device_id: Name of application to send at login.
|
||||
"""
|
||||
self.login_url = None
|
||||
self.login_urls = const.LOGIN_URLS
|
||||
self.cred_file = cred_file
|
||||
self.persist_key = persist_key
|
||||
self.device_id = device_id
|
||||
self.data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"uid": None,
|
||||
"notification_key": None,
|
||||
}
|
||||
|
||||
self.check_keys()
|
||||
|
||||
def check_keys(self):
|
||||
"""Check if uid exists, if not create."""
|
||||
uid = util.gen_uid(const.SIZE_UID)
|
||||
notification_key = util.gen_uid(const.SIZE_NOTIFICATION_KEY)
|
||||
data = {"uid": uid, "notification_key": notification_key}
|
||||
if self.persist_key is None:
|
||||
return data
|
||||
if not isfile(self.persist_key):
|
||||
with open(self.persist_key, "w") as json_file:
|
||||
json.dump(data, json_file)
|
||||
else:
|
||||
with open(self.persist_key, "r") as json_file:
|
||||
data = json.load(json_file)
|
||||
return data
|
||||
|
||||
def check_cred_file(self):
|
||||
"""Check if credential file supplied and use if so."""
|
||||
if isfile(self.cred_file):
|
||||
try:
|
||||
with open(self.cred_file, "r") as json_file:
|
||||
creds = json.load(json_file)
|
||||
self.data["username"] = creds["username"]
|
||||
self.data["password"] = creds["password"]
|
||||
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Improperly formatted json file %s.", self.cred_file, exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
except KeyError:
|
||||
_LOGGER.error("JSON file information incomplete %s.", exc_info=True)
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_login(self):
|
||||
"""Check login information and prompt if not available."""
|
||||
if self.data["username"] is None:
|
||||
self.data["username"] = input("Username:")
|
||||
if self.data["password"] is None:
|
||||
self.data["password"] = getpass("Password:")
|
||||
|
||||
if self.data["username"] and self.data["password"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def validate_response(self, url, response):
|
||||
"""Validate response from login endpoint."""
|
||||
try:
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
except AttributeError:
|
||||
_LOGGER.error(
|
||||
"Response for %s did not return a status code. Deprecated endpoint?",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def login(self, blink):
|
||||
"""Attempt login to blink servers."""
|
||||
if self.cred_file is not None:
|
||||
self.check_cred_file()
|
||||
if not self.check_login():
|
||||
_LOGGER.error("Cannot login with username %s", self.data["username"])
|
||||
return False
|
||||
|
||||
for url in self.login_urls:
|
||||
_LOGGER.info("Attempting login with %s", url)
|
||||
response = api.request_login(
|
||||
blink,
|
||||
url,
|
||||
self.data["username"],
|
||||
self.data["password"],
|
||||
self.data["notification_key"],
|
||||
self.data["uid"],
|
||||
is_retry=False,
|
||||
device_id=self.device_id,
|
||||
)
|
||||
|
||||
if self.validate_response(url, response):
|
||||
self.login_url = url
|
||||
return response.json()
|
||||
|
||||
_LOGGER.error("Failed to login to Blink servers. Last response: %s", response)
|
||||
return False
|
||||
|
||||
def send_auth_key(self, blink, key):
|
||||
"""Send 2FA key to blink servers."""
|
||||
if key is not None:
|
||||
response = api.request_verify(blink, key)
|
||||
try:
|
||||
json_resp = response.json()
|
||||
blink.available = json_resp["valid"]
|
||||
except (KeyError, TypeError):
|
||||
_LOGGER.error("Did not receive valid response from server.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_key_required(self, blink):
|
||||
"""Check if 2FA key is required."""
|
||||
try:
|
||||
if blink.login_response["client"]["verification_required"]:
|
||||
return True
|
||||
except KeyError:
|
||||
pass
|
||||
return False
|
||||
@@ -21,10 +21,8 @@ class BlinkSyncModule:
|
||||
:param blink: Blink class instantiation
|
||||
"""
|
||||
self.blink = blink
|
||||
self._auth_header = blink.auth_header
|
||||
self.network_id = network_id
|
||||
self.region = blink.region
|
||||
self.region_id = blink.region_id
|
||||
self.region_id = blink.auth.region_id
|
||||
self.name = network_name
|
||||
self.serial = None
|
||||
self.status = None
|
||||
@@ -49,7 +47,6 @@ class BlinkSyncModule:
|
||||
"network_id": self.network_id,
|
||||
"serial": self.serial,
|
||||
"status": self.status,
|
||||
"region": self.region,
|
||||
"region_id": self.region_id,
|
||||
}
|
||||
return attr
|
||||
|
||||
Reference in New Issue
Block a user