Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions abodepy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -137,6 +148,14 @@ def login(self, username=None, password=None):

response_object = json.loads(response.text)

if response_object.get('mfa_type', None) == "google_authenticator":
raise AbodeAuthenticationException(ERROR.MFA_CODE_REQUIRED)

# 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:
Expand Down
18 changes: 15 additions & 3 deletions abodepy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions abodepy/helpers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down
3 changes: 3 additions & 0 deletions abodepy/helpers/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,6 @@

SET_PRIVACY_MODE = (
31, "Device privacy mode value does not match request value.")

MFA_CODE_REQUIRED = (
32, "Multifactor authentication code required for login.")
18 changes: 18 additions & 0 deletions tests/mock/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,21 @@ 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
}'''
49 changes: 48 additions & 1 deletion tests/test_abode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -153,6 +168,29 @@ 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_logout_failure(self, m):
"""Test logout failed."""
Expand Down Expand Up @@ -508,16 +546,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))
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = build, py35, py36, py37, lint
envlist = build, py35, py36, py37, py38, lint
skip_missing_interpreters = True
skipsdist = True

Expand Down