Repository: fronzbot/blinkpy

Issue:
New Login Method - blinkpy will stop working May 11th unless fixed!
**Describe the bug**
As of March 30, 2020 (according to the "What's New" section in the Blink App), 2FA will be available for blink.  What this means for the blinkpy library is uncertain, but I wouldn't be surprised if current login methods begin to fail.  A solution will need to be discovered.

**Additional Information**
I'm hoping we'll be able to send a REST command with some 2FA code to continue logging in and that we get some sort of long-term token to use (rather than the current ones that expire after ~24hrs) so users don't have to constantly input of 2FA code.  But we'll see what the implementation looks like.  Even if 2FA is optional, I want this library to work WITH it enabled.



Discussion:

Patch:
diff --git a/README.rst b/README.rst
index e551b8c..d31f9fe 100644
--- a/README.rst
+++ b/README.rst
@@ -53,7 +53,12 @@ The simplest way to use this package from a terminal is to call ``Blink.start()`
     blink = blinkpy.Blink(username='YOUR USER NAME', password='YOUR PASSWORD', refresh_rate=30)
     blink.start()
 
-If you would like to log in without setting up the cameras or system, you can simply call the ``Blink.login()`` function which will prompt for a username and password and then authenticate with the server.  This is useful if you want to avoid use of the ``start()`` function which simply acts as a wrapper for more targeted API methods.
+At startup, you may ne prompted for a verification key.  Just enter this in the command-line prompt.  If you just receive a verification email asking to validate access for your device, enter nothing at this prompt.  To avoid any command-line interaction, call the ``Blink`` class with the ``no_prompt=True`` flag.  Instead, once you receive the verification email, call the following functions:
+
+.. code:: python
+
+    blink.login_handler.send_auth_key(blink, VERIFICATION_KEY)
+    blink.setup_post_verify()
 
 In addition, you can also save your credentials in a json file and initialize Blink with the credential file as follows:
 
diff --git a/blinkpy/api.py b/blinkpy/api.py
index 2e7b7e7..fc81fa5 100644
--- a/blinkpy/api.py
+++ b/blinkpy/api.py
@@ -21,6 +21,8 @@ def request_login(
     :param url: Login url.
     :param username: Blink username.
     :param password: Blink password.
+    :param notification_key: Randomly genereated key.
+    :param uid: Randomoly genreated unique id key.
     :param is_retry: Is this part of a re-authorization attempt?
     """
     headers = {"Host": DEFAULT_URL, "Content-Type": "application/json"}
@@ -34,6 +36,7 @@ def request_login(
             "client_name": "Computer",
             "client_type": "android",
             "device_identifier": "Blinkpy",
+            "device_name": "Blinkpy",
             "os_version": "5.1.1",
             "reauth": "true",
         }
@@ -51,10 +54,18 @@ def request_login(
 
 def request_verify(blink, verify_key):
     """Send verification key to blink servers."""
-    url = "api/v4/account/{}/client/{}/pin/verify".format(
-        blink.account_id, blink.client_id
+    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 http_post(blink, url)
 
 
 def request_networks(blink):
diff --git a/blinkpy/blinkpy.py b/blinkpy/blinkpy.py
index a9eca1d..93befba 100644
--- a/blinkpy/blinkpy.py
+++ b/blinkpy/blinkpy.py
@@ -56,6 +56,8 @@ class Blink:
         refresh_rate=DEFAULT_REFRESH,
         motion_interval=DEFAULT_MOTION_INTERVAL,
         legacy_subdomain=False,
+        no_prompt=False,
+        persist_key=None,
     ):
         """
         Initialize Blink system.
@@ -75,9 +77,15 @@ class Blink:
         :param legacy_subdomain: Set to TRUE to use old 'rest.region'
                              endpoints (only use if you are having
                              api issues).
+        :param no_prompt: Set to TRUE if using an implementation that needs to
+                             suppress command-line output.
+        :param persist_key: Location of persistant identifier.
         """
         self.login_handler = LoginHandler(
-            username=username, password=password, cred_file=cred_file
+            username=username,
+            password=password,
+            cred_file=cred_file,
+            persist_key=persist_key,
         )
         self._token = None
         self._auth_header = None
@@ -100,7 +108,10 @@ class Blink:
         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):
@@ -114,7 +125,20 @@ class Blink:
         Method logs in and sets auth token, urls, and ids for future requests.
         Essentially this is just a wrapper function for ease of use.
         """
-        self.get_auth_token()
+        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():
@@ -126,10 +150,10 @@ class Blink:
             )
             sync_module.start()
             self.sync[network_name] = sync_module
-        self.cameras = self.merge_cameras()
+            self.cameras = self.merge_cameras()
 
     def login(self):
-        """Login method. DEPRECATED."""
+        """Perform server login. DEPRECATED."""
         _LOGGER.warning(
             "Method is deprecated and will be removed in a future version.  Please use the LoginHandler.login() method instead."
         )
@@ -137,22 +161,33 @@ class Blink:
 
     def get_auth_token(self, is_retry=False):
         """Retrieve the authentication token from Blink."""
-        response = self.login_handler.login(self)
-
-        if not response:
+        self.login_response = self.login_handler.login(self)
+        if not self.login_response:
             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.networks = response["networks"]
-        self.client_id = response["client"]["id"]
-        self.account_id = response["account"]["id"]
         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"]
 
-        return self._auth_header
+    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."""
diff --git a/blinkpy/helpers/constants.py b/blinkpy/helpers/constants.py
index 6099f02..3d51e8c 100644
--- a/blinkpy/helpers/constants.py
+++ b/blinkpy/helpers/constants.py
@@ -50,6 +50,7 @@ DEFAULT_URL = "{}.{}".format("rest-prod", BLINK_URL)
 BASE_URL = "https://{}".format(DEFAULT_URL)
 LOGIN_URLS = [
     "{}/api/v4/login".format(BASE_URL),
+    "{}/api/v3/login".format(BASE_URL),
     "{}/api/v2/login".format(BASE_URL),
 ]
 
diff --git a/blinkpy/login_handler.py b/blinkpy/login_handler.py
index b2e22a0..8041cef 100644
--- a/blinkpy/login_handler.py
+++ b/blinkpy/login_handler.py
@@ -13,26 +13,43 @@ _LOGGER = logging.getLogger(__name__)
 class LoginHandler:
     """Class to handle login communication."""
 
-    def __init__(
-        self, username=None, password=None, cred_file=None,
-    ):
+    def __init__(self, username=None, password=None, cred_file=None, persist_key=None):
         """
         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.
         """
         self.login_url = None
         self.login_urls = const.LOGIN_URLS
         self.cred_file = cred_file
+        self.persist_key = persist_key
         self.data = {
             "username": username,
             "password": password,
-            "uid": util.gen_uid(const.SIZE_UID),
-            "notification_key": util.gen_uid(const.SIZE_NOTIFICATION_KEY),
+            "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):
@@ -104,3 +121,24 @@ class LoginHandler:
 
         _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
diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py
index ebed95a..def9810 100644
--- a/blinkpy/sync_module.py
+++ b/blinkpy/sync_module.py
@@ -38,6 +38,7 @@ class BlinkSyncModule:
         self.motion = {}
         self.last_record = {}
         self.camera_list = camera_list
+        self.available = False
 
     @property
     def attributes(self):
@@ -102,8 +103,7 @@ class BlinkSyncModule:
                 "Could not extract some sync module info: %s", response, exc_info=True
             )
 
-        self.network_info = api.request_network_status(self.blink, self.network_id)
-
+        self.get_network_info()
         self.check_new_videos()
         try:
             for camera_config in self.camera_list:
@@ -120,6 +120,7 @@ class BlinkSyncModule:
             )
             return False
 
+        self.available = True
         return True
 
     def get_events(self, **kwargs):
@@ -141,9 +142,21 @@ class BlinkSyncModule:
             _LOGGER.error("Could not extract camera info: %s", response, exc_info=True)
             return []
 
+    def get_network_info(self):
+        """Retrieve network status."""
+        is_errored = False
+        self.network_info = api.request_network_status(self.blink, self.network_id)
+        try:
+            is_errored = self.network_info["network"]["sync_module_error"]
+        except KeyError:
+            is_errored = True
+
+        if is_errored:
+            self.available = False
+
     def refresh(self, force_cache=False):
         """Get all blink cameras and pulls their most recent status."""
-        self.network_info = api.request_network_status(self.blink, self.network_id)
+        self.get_network_info()
         self.check_new_videos()
         for camera_name in self.cameras.keys():
             camera_id = self.cameras[camera_name].camera_id


Test patch:
diff --git a/tests/test_blinkpy.py b/tests/test_blinkpy.py
index 5a13482..cfda5e7 100644
--- a/tests/test_blinkpy.py
+++ b/tests/test_blinkpy.py
@@ -11,6 +11,7 @@ from unittest import mock
 from blinkpy import api
 from blinkpy.blinkpy import Blink
 from blinkpy.sync_module import BlinkSyncModule
+from blinkpy.login_handler import LoginHandler
 from blinkpy.helpers.util import (
     http_req,
     create_session,
@@ -148,3 +149,23 @@ class TestBlinkSetup(unittest.TestCase):
         mock_home.return_value = {}
         result = self.blink.get_cameras()
         self.assertEqual(result, {})
+
+    @mock.patch.object(LoginHandler, "send_auth_key")
+    @mock.patch.object(Blink, "setup_post_verify")
+    def test_startup_prompt(self, mock_send_key, mock_verify, mock_sess):
+        """Test startup logic with command-line prompt."""
+        mock_send_key.return_value = True
+        mock_verify.return_value = True
+        self.blink.no_prompt = False
+        self.blink.key_required = True
+        self.blink.available = True
+        with mock.patch("builtins.input", return_value="1234"):
+            self.blink.start()
+        self.assertFalse(self.blink.key_required)
+
+    def test_startup_no_prompt(self, mock_sess):
+        """Test startup with no_prompt flag set."""
+        self.blink.key_required = True
+        self.blink.no_prompt = True
+        self.blink.start()
+        self.assertTrue(self.blink.key_required)
diff --git a/tests/test_login_handler.py b/tests/test_login_handler.py
index 79fb355..11bfc16 100644
--- a/tests/test_login_handler.py
+++ b/tests/test_login_handler.py
@@ -68,3 +68,68 @@ class TestLoginHandler(unittest.TestCase):
         """Check good response code from server."""
         fake_resp = mresp.MockResponse(None, 200)
         self.assertTrue(self.login_handler.validate_response(None, fake_resp))
+
+    @mock.patch("blinkpy.login_handler.util.gen_uid")
+    def test_check_keys_no_persist(self, mock_uid, mock_sess):
+        """Check key generation."""
+        uid_value = "abc123"
+        mock_uid.return_value = "abc123"
+        self.login_handler.persist_key = None
+        data = self.login_handler.check_keys()
+        self.assertEqual(data["uid"], uid_value)
+        self.assertEqual(data["notification_key"], uid_value)
+
+    @mock.patch("blinkpy.login_handler.util.gen_uid")
+    @mock.patch("blinkpy.login_handler.json.load")
+    @mock.patch("blinkpy.login_handler.isfile")
+    def test_check_keys_persist(self, mockisfile, mockjson, mock_uid, mock_sess):
+        """Check key load from file."""
+        uid_value = "abc123"
+        mock_file = {"uid": "321cba", "notification_key": "foobar123"}
+        mock_uid.return_value = uid_value
+        mockjson.return_value = mock_file
+        mockisfile.return_value = True
+        self.login_handler.persist_key = True
+        data = self.login_handler.check_keys()
+        self.assertEqual(mock_file["uid"], data["uid"])
+        self.assertEqual(mock_file["notification_key"], data["notification_key"])
+
+    def test_check_key_required(self, mock_sess):
+        """Check key required method."""
+        response_true = {"client": {"verification_required": True}}
+        response_false = {"client": {"verification_required": False}}
+        response_nokey = {}
+
+        mock_blink = MockBlink(response_nokey)
+        self.assertFalse(self.login_handler.check_key_required(mock_blink))
+
+        mock_blink = MockBlink(response_false)
+        self.assertFalse(self.login_handler.check_key_required(mock_blink))
+
+        mock_blink = MockBlink(response_true)
+        self.assertTrue(self.login_handler.check_key_required(mock_blink))
+
+    @mock.patch("blinkpy.login_handler.api.request_verify")
+    def test_send_auth_key(self, mock_req, mock_sess):
+        """Check sending of auth key."""
+        mock_blink = MockBlink(None)
+        mock_req.return_value = mresp.MockResponse({"valid": True}, 200)
+        self.assertTrue(self.login_handler.send_auth_key(mock_blink, 1234))
+        self.assertTrue(mock_blink.available)
+
+        mock_req.return_value = mresp.MockResponse(None, 200)
+        self.assertFalse(self.login_handler.send_auth_key(mock_blink, 1234))
+
+        mock_req.return_value = mresp.MockResponse({}, 200)
+        self.assertFalse(self.login_handler.send_auth_key(mock_blink, 1234))
+
+        self.assertTrue(self.login_handler.send_auth_key(mock_blink, None))
+
+
+class MockBlink:
+    """Object to mock basic blink class."""
+
+    def __init__(self, login_response):
+        """Initialize mock blink class."""
+        self.available = False
+        self.login_response = login_response