diff --git a/abodepy/__init__.py b/abodepy/__init__.py index f8633ac..61b105b 100644 --- a/abodepy/__init__.py +++ b/abodepy/__init__.py @@ -76,7 +76,8 @@ def __init__(self, username=None, password=None, self._cache = { CONST.ID: None, CONST.PASSWORD: None, - CONST.UUID: UTILS.gen_uuid() + CONST.UUID: UTILS.gen_uuid(), + CONST.COOKIES: None } # Load and merge an existing cache @@ -93,6 +94,12 @@ def __init__(self, username=None, password=None, self._save_cache() + # Load persisted cookies (which contains the UUID and the session ID) + # if available + if (CONST.COOKIES in self._cache and + self._cache[CONST.COOKIES] is not None): + self._session.cookies = self._cache[CONST.COOKIES] + if (self._cache[CONST.ID] is not None and self._cache[CONST.PASSWORD] is not None and auto_login): @@ -104,7 +111,7 @@ def __init__(self, username=None, password=None, if get_automations: self.get_automations() - def login(self, username=None, password=None): + def login(self, username=None, password=None, mfa_code=None): """Explicit Abode login.""" if username is not None: self._cache[CONST.ID] = username @@ -129,6 +136,10 @@ def login(self, username=None, password=None): CONST.UUID: self._cache[CONST.UUID] } + if mfa_code is not None: + login_data[CONST.MFA_CODE] = mfa_code + login_data['remember_me'] = 1 + response = self._session.post(CONST.LOGIN_URL, json=login_data) if response.status_code != 200: @@ -137,6 +148,17 @@ def login(self, username=None, password=None): response_object = json.loads(response.text) + if 'mfa_type' in response_object: + if response_object['mfa_type'] == "google_authenticator": + raise AbodeAuthenticationException(ERROR.MFA_CODE_REQUIRED) + else: + raise AbodeAuthenticationException(ERROR.UNKNOWN_MFA_TYPE) + + # Persist cookies (which contains the UUID and the session ID) to disk + if self._session.cookies.get_dict(): + self._cache[CONST.COOKIES] = self._session.cookies + self._save_cache() + oauth_response = self._session.get(CONST.OAUTH_TOKEN_URL) if oauth_response.status_code != 200: diff --git a/abodepy/__main__.py b/abodepy/__main__.py index 76a606c..4299740 100644 --- a/abodepy/__main__.py +++ b/abodepy/__main__.py @@ -77,6 +77,11 @@ def get_arguments(): help='Password', required=False) + parser.add_argument( + '--mfa', + help='Multifactor authentication code', + required=False) + parser.add_argument( '--cache', metavar='pickle_file', @@ -220,15 +225,22 @@ def call(): if args.cache and args.username and args.password: abode = abodepy.Abode(username=args.username, password=args.password, - get_devices=True, + get_devices=args.mfa is None, cache_path=args.cache) elif args.cache and not (not args.username or not args.password): - abode = abodepy.Abode(get_devices=True, + abode = abodepy.Abode(get_devices=args.mfa is None, cache_path=args.cache) else: abode = abodepy.Abode(username=args.username, password=args.password, - get_devices=True) + get_devices=args.mfa is None) + + # Since the MFA code is very time sensitive, if the user has provided + # one we should use it to log in as soon as possible + if args.mfa: + abode.login(mfa_code=args.mfa) + # Now we can fetch devices from Abode + abode.get_devices() # Output current mode. if args.mode: diff --git a/abodepy/helpers/constants.py b/abodepy/helpers/constants.py index 8b9bdbc..e34d422 100644 --- a/abodepy/helpers/constants.py +++ b/abodepy/helpers/constants.py @@ -40,10 +40,12 @@ PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) CACHE_PATH = './abode.pickle' +COOKIES = "cookies" ID = 'id' PASSWORD = 'password' UUID = 'uuid' +MFA_CODE = 'mfa_code' # URLS BASE_URL = 'https://my.goabode.com/' diff --git a/abodepy/helpers/errors.py b/abodepy/helpers/errors.py index 7816122..9cc9476 100644 --- a/abodepy/helpers/errors.py +++ b/abodepy/helpers/errors.py @@ -85,3 +85,9 @@ SET_PRIVACY_MODE = ( 31, "Device privacy mode value does not match request value.") + +MFA_CODE_REQUIRED = ( + 32, "Multifactor authentication code required for login.") + +UNKNOWN_MFA_TYPE = ( + 33, "Unknown multifactor authentication type.") diff --git a/tests/mock/login.py b/tests/mock/login.py index f089f5e..d8ce0b5 100644 --- a/tests/mock/login.py +++ b/tests/mock/login.py @@ -51,3 +51,30 @@ def post_response_bad_request(): "code":400,"message":"Username and password do not match.", "detail":null }''' + + +def post_response_mfa_code_required(): + """Return the MFA code required login response json.""" + return ''' + { + "code":200,"mfa_type":"google_authenticator", + "detail":null + }''' + + +def post_response_bad_mfa_code(): + """Return the bad MFA code login response json.""" + return ''' + { + "code":400,"message":"Invalid authentication key.", + "detail":null + }''' + + +def post_response_unknown_mfa_type(): + """Return a login response json with an unknown mfa type.""" + return ''' + { + "code":200,"mfa_type":"sms", + "detail":null + }''' diff --git a/tests/test_abode.py b/tests/test_abode.py index c99ff89..2bae7a3 100644 --- a/tests/test_abode.py +++ b/tests/test_abode.py @@ -71,6 +71,21 @@ def tests_manual_login(self, m): # pylint: disable=protected-access self.assertEqual(self.abode_no_cred._cache[CONST.PASSWORD], PASSWORD) + @requests_mock.mock() + def tests_manual_login_with_mfa(self, m): + """Check that we can login with MFA code.""" + m.post(CONST.LOGIN_URL, text=LOGIN.post_response_ok()) + m.get(CONST.OAUTH_TOKEN_URL, text=OAUTH_CLAIMS.get_response_ok()) + + self.abode_no_cred.login(username=USERNAME, + password=PASSWORD, + mfa_code=654321) + + # pylint: disable=protected-access + self.assertEqual(self.abode_no_cred._cache[CONST.ID], USERNAME) + # pylint: disable=protected-access + self.assertEqual(self.abode_no_cred._cache[CONST.PASSWORD], PASSWORD) + @requests_mock.mock() def tests_auto_login(self, m): """Test that automatic login works.""" @@ -153,6 +168,40 @@ def tests_login_failure(self, m): with self.assertRaises(abodepy.AbodeAuthenticationException): self.abode_no_cred.login(username=USERNAME, password=PASSWORD) + @requests_mock.mock() + def tests_login_mfa_required(self, m): + """Tests login with MFA code required but not supplied.""" + m.post(CONST.LOGIN_URL, + text=LOGIN.post_response_mfa_code_required(), status_code=200) + + # Check that we raise an Exception when the MFA code is required + # but not supplied + with self.assertRaises(abodepy.AbodeAuthenticationException): + self.abode_no_cred.login(username=USERNAME, password=PASSWORD) + + @requests_mock.mock() + def tests_login_bad_mfa_code(self, m): + """Tests login with bad MFA code.""" + m.post(CONST.LOGIN_URL, + text=LOGIN.post_response_bad_mfa_code(), status_code=400) + + # Check that we raise an Exception with a bad MFA code + with self.assertRaises(abodepy.AbodeAuthenticationException): + self.abode_no_cred.login(username=USERNAME, + password=PASSWORD, + mfa_code=123456) + + @requests_mock.mock() + def tests_login_unknown_mfa_type(self, m): + """Tests login with unknown MFA type.""" + m.post(CONST.LOGIN_URL, + text=LOGIN.post_response_unknown_mfa_type(), status_code=200) + + # Check that we raise an Exception with an unknown MFA type + with self.assertRaises(abodepy.AbodeAuthenticationException): + self.abode_no_cred.login(username=USERNAME, + password=PASSWORD) + @requests_mock.mock() def tests_logout_failure(self, m): """Test logout failed.""" @@ -508,16 +557,25 @@ def tests_cookies(self, m): # Create abode abode = abodepy.Abode(username='fizz', password='buzz', - auto_login=True, + auto_login=False, get_devices=False, disable_cache=False, cache_path=cache_path) + # Mock cookie created by Abode after login + cookie = requests.cookies.create_cookie(name='SESSION', + value='COOKIE') + # pylint: disable=protected-access + abode._session.cookies.set_cookie(cookie) + + abode.login() + # Test that our cookies are fully realized prior to login # pylint: disable=W0212 self.assertIsNotNone(abode._cache['id']) self.assertIsNotNone(abode._cache['password']) self.assertIsNotNone(abode._cache['uuid']) + self.assertIsNotNone(abode._cache['cookies']) # Test that we now have a cookies file self.assertTrue(os.path.exists(cache_path)) diff --git a/tox.ini b/tox.ini index 8e4dc19..cdbabaa 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = build, py35, py36, py37, lint +envlist = build, py35, py36, py37, py38, lint skip_missing_interpreters = True skipsdist = True