Add Abode MFA support (#43572)

This commit is contained in:
shred86 2020-11-27 04:39:26 -08:00 committed by GitHub
parent ea55051161
commit bdb04dcb9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 318 additions and 59 deletions

View file

@ -4,12 +4,12 @@ from copy import deepcopy
from functools import partial
from abodepy import Abode
from abodepy.exceptions import AbodeException
from abodepy.exceptions import AbodeAuthenticationException, AbodeException
import abodepy.helpers.timeline as TIMELINE
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_DATE,
@ -110,18 +110,34 @@ async def async_setup_entry(hass, config_entry):
username = config_entry.data.get(CONF_USERNAME)
password = config_entry.data.get(CONF_PASSWORD)
polling = config_entry.data.get(CONF_POLLING)
cache = hass.config.path(DEFAULT_CACHEDB)
# For previous config entries where unique_id is None
if config_entry.unique_id is None:
hass.config_entries.async_update_entry(
config_entry, unique_id=config_entry.data[CONF_USERNAME]
)
try:
cache = hass.config.path(DEFAULT_CACHEDB)
abode = await hass.async_add_executor_job(
Abode, username, password, True, True, True, cache
)
hass.data[DOMAIN] = AbodeSystem(abode, polling)
except AbodeAuthenticationException as ex:
LOGGER.error("Invalid credentials: %s", ex)
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data=config_entry.data,
)
return False
except (AbodeException, ConnectTimeout, HTTPError) as ex:
LOGGER.error("Unable to connect to Abode: %s", str(ex))
LOGGER.error("Unable to connect to Abode: %s", ex)
raise ConfigEntryNotReady from ex
hass.data[DOMAIN] = AbodeSystem(abode, polling)
for platform in ABODE_PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, platform)

View file

@ -1,15 +1,16 @@
"""Config flow for the Abode Security System component."""
from abodepy import Abode
from abodepy.exceptions import AbodeException
from abodepy.exceptions import AbodeAuthenticationException, AbodeException
from abodepy.helpers.errors import MFA_CODE_REQUIRED
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, HTTP_BAD_REQUEST
from homeassistant.core import callback
from .const import DEFAULT_CACHEDB, DOMAIN, LOGGER # pylint: disable=unused-import
CONF_MFA = "mfa_code"
CONF_POLLING = "polling"
@ -25,53 +26,146 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
self.mfa_data_schema = {
vol.Required(CONF_MFA): str,
}
self._cache = None
self._mfa_code = None
self._password = None
self._polling = False
self._username = None
async def _async_abode_login(self, step_id):
"""Handle login with Abode."""
self._cache = self.hass.config.path(DEFAULT_CACHEDB)
errors = {}
try:
await self.hass.async_add_executor_job(
Abode, self._username, self._password, True, False, False, self._cache
)
except (AbodeException, ConnectTimeout, HTTPError) as ex:
if ex.errcode == MFA_CODE_REQUIRED[0]:
return await self.async_step_mfa()
LOGGER.error("Unable to connect to Abode: %s", ex)
if ex.errcode == HTTP_BAD_REQUEST:
errors = {"base": "invalid_auth"}
else:
errors = {"base": "cannot_connect"}
if errors:
return self.async_show_form(
step_id=step_id, data_schema=vol.Schema(self.data_schema), errors=errors
)
return await self._async_create_entry()
async def _async_abode_mfa_login(self):
"""Handle multi-factor authentication (MFA) login with Abode."""
try:
# Create instance to access login method for passing MFA code
abode = Abode(
auto_login=False,
get_devices=False,
get_automations=False,
cache_path=self._cache,
)
await self.hass.async_add_executor_job(
abode.login, self._username, self._password, self._mfa_code
)
except AbodeAuthenticationException:
return self.async_show_form(
step_id="mfa",
data_schema=vol.Schema(self.mfa_data_schema),
errors={"base": "invalid_mfa_code"},
)
return await self._async_create_entry()
async def _async_create_entry(self):
"""Create the config entry."""
config_data = {
CONF_USERNAME: self._username,
CONF_PASSWORD: self._password,
CONF_POLLING: self._polling,
}
existing_entry = await self.async_set_unique_id(self._username)
if existing_entry:
self.hass.config_entries.async_update_entry(
existing_entry, data=config_data
)
# Reload the Abode config entry otherwise devices will remain unavailable
self.hass.async_create_task(
self.hass.config_entries.async_reload(existing_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=self._username, data=config_data)
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if not user_input:
return self._show_form()
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
polling = user_input.get(CONF_POLLING, False)
cache = self.hass.config.path(DEFAULT_CACHEDB)
try:
await self.hass.async_add_executor_job(
Abode, username, password, True, True, True, cache
)
except (AbodeException, ConnectTimeout, HTTPError) as ex:
LOGGER.error("Unable to connect to Abode: %s", str(ex))
if ex.errcode == HTTP_BAD_REQUEST:
return self._show_form({"base": "invalid_auth"})
return self._show_form({"base": "cannot_connect"})
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data={
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_POLLING: polling,
},
)
@callback
def _show_form(self, errors=None):
"""Show the form to the user."""
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(self.data_schema),
errors=errors if errors else {},
step_id="user", data_schema=vol.Schema(self.data_schema)
)
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
return await self._async_abode_login(step_id="user")
async def async_step_mfa(self, user_input=None):
"""Handle a multi-factor authentication (MFA) flow."""
if user_input is None:
return self.async_show_form(
step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema)
)
self._mfa_code = user_input[CONF_MFA]
return await self._async_abode_mfa_login()
async def async_step_reauth(self, config):
"""Handle reauthorization request from Abode."""
self._username = config[CONF_USERNAME]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(self, user_input=None):
"""Handle reauthorization flow."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME, default=self._username): str,
vol.Required(CONF_PASSWORD): str,
}
),
)
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
return await self._async_abode_login(step_id="reauth_confirm")
async def async_step_import(self, import_config):
"""Import a config entry from configuration.yaml."""
if self._async_current_entries():
LOGGER.warning("Only one configuration of abode is allowed.")
LOGGER.warning("Already configured. Only a single configuration possible.")
return self.async_abort(reason="single_instance_allowed")
self._polling = import_config.get(CONF_POLLING, False)
return await self.async_step_user(import_config)

View file

@ -3,7 +3,7 @@
"name": "Abode",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/abode",
"requirements": ["abodepy==1.1.0"],
"requirements": ["abodepy==1.2.0"],
"codeowners": ["@shred86"],
"homekit": {
"models": ["Abode", "Iota"]

View file

@ -7,14 +7,30 @@
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"mfa": {
"title": "Enter your MFA code for Abode",
"data": {
"mfa_code": "MFA code (6-digits)"
}
},
"reauth_confirm": {
"title": "Fill in your Abode login information",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_mfa_code": "Invalid MFA code"
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View file

@ -1,13 +1,28 @@
{
"config": {
"abort": {
"reauth_successful": "Re-authentication was successful",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
"invalid_auth": "Invalid authentication",
"invalid_mfa_code": "Invalid MFA code"
},
"step": {
"mfa": {
"data": {
"mfa_code": "MFA code (6-digits)"
},
"title": "Enter your MFA code for Abode"
},
"reauth_confirm": {
"data": {
"password": "Password",
"username": "Email"
},
"title": "Fill in your Abode login information"
},
"user": {
"data": {
"password": "Password",

View file

@ -93,7 +93,7 @@ WSDiscovery==2.0.0
WazeRouteCalculator==0.12
# homeassistant.components.abode
abodepy==1.1.0
abodepy==1.2.0
# homeassistant.components.accuweather
accuweather==0.0.11

View file

@ -36,7 +36,7 @@ RtmAPI==0.7.2
WSDiscovery==2.0.0
# homeassistant.components.abode
abodepy==1.1.0
abodepy==1.2.0
# homeassistant.components.accuweather
accuweather==0.0.11

View file

@ -1,9 +1,17 @@
"""Tests for the Abode config flow."""
from abodepy.exceptions import AbodeAuthenticationException
from abodepy.helpers.errors import MFA_CODE_REQUIRED
from homeassistant import data_entry_flow
from homeassistant.components.abode import config_flow
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, HTTP_INTERNAL_SERVER_ERROR
from homeassistant.components.abode.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
HTTP_BAD_REQUEST,
HTTP_INTERNAL_SERVER_ERROR,
)
from tests.async_mock import patch
from tests.common import MockConfigEntry
@ -28,7 +36,7 @@ async def test_one_config_allowed(hass):
flow.hass = hass
MockConfigEntry(
domain="abode",
domain=DOMAIN,
data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"},
).add_to_hass(hass)
@ -58,7 +66,7 @@ async def test_invalid_credentials(hass):
with patch(
"homeassistant.components.abode.config_flow.Abode",
side_effect=AbodeAuthenticationException((400, "auth error")),
side_effect=AbodeAuthenticationException((HTTP_BAD_REQUEST, "auth error")),
):
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "invalid_auth"}
@ -89,13 +97,13 @@ async def test_step_import(hass):
CONF_POLLING: False,
}
flow = config_flow.AbodeFlowHandler()
flow.hass = hass
with patch("homeassistant.components.abode.config_flow.Abode"):
result = await flow.async_step_import(import_config=conf)
with patch("homeassistant.components.abode.config_flow.Abode"), patch(
"abodepy.UTILS"
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
result = await flow.async_step_user(user_input=result["data"])
assert result["title"] == "user@email.com"
assert result["data"] == {
CONF_USERNAME: "user@email.com",
@ -108,11 +116,14 @@ async def test_step_user(hass):
"""Test that the user step works."""
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
flow = config_flow.AbodeFlowHandler()
flow.hass = hass
with patch("homeassistant.components.abode.config_flow.Abode"), patch(
"abodepy.UTILS"
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
)
with patch("homeassistant.components.abode.config_flow.Abode"):
result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "user@email.com"
assert result["data"] == {
@ -120,3 +131,78 @@ async def test_step_user(hass):
CONF_PASSWORD: "password",
CONF_POLLING: False,
}
async def test_step_mfa(hass):
"""Test that the MFA step works."""
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
with patch(
"homeassistant.components.abode.config_flow.Abode",
side_effect=AbodeAuthenticationException(MFA_CODE_REQUIRED),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "mfa"
with patch(
"homeassistant.components.abode.config_flow.Abode",
side_effect=AbodeAuthenticationException((HTTP_BAD_REQUEST, "invalid mfa")),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"mfa_code": "123456"}
)
assert result["errors"] == {"base": "invalid_mfa_code"}
with patch("homeassistant.components.abode.config_flow.Abode"), patch(
"abodepy.UTILS"
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"mfa_code": "123456"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "user@email.com"
assert result["data"] == {
CONF_USERNAME: "user@email.com",
CONF_PASSWORD: "password",
CONF_POLLING: False,
}
async def test_step_reauth(hass):
"""Test the reauth flow."""
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
MockConfigEntry(
domain=DOMAIN,
unique_id="user@email.com",
data=conf,
).add_to_hass(hass)
with patch("homeassistant.components.abode.config_flow.Abode"), patch(
"abodepy.UTILS"
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "reauth"},
data=conf,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth_confirm"
with patch("homeassistant.config_entries.ConfigEntries.async_reload"):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=conf,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "reauth_successful"
assert len(hass.config_entries.async_entries()) == 1

View file

@ -1,4 +1,6 @@
"""Tests for the Abode module."""
from abodepy.exceptions import AbodeAuthenticationException
from homeassistant.components.abode import (
DOMAIN as ABODE_DOMAIN,
SERVICE_CAPTURE_IMAGE,
@ -6,6 +8,7 @@ from homeassistant.components.abode import (
SERVICE_TRIGGER_AUTOMATION,
)
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
from homeassistant.const import CONF_USERNAME, HTTP_BAD_REQUEST
from .common import setup_platform
@ -27,6 +30,22 @@ async def test_change_settings(hass):
mock_set_setting.assert_called_once()
async def test_add_unique_id(hass):
"""Test unique_id is set to Abode username."""
mock_entry = await setup_platform(hass, ALARM_DOMAIN)
# Set unique_id to None to match previous config entries
hass.config_entries.async_update_entry(entry=mock_entry, unique_id=None)
await hass.async_block_till_done()
assert mock_entry.unique_id is None
with patch("abodepy.UTILS"):
await hass.config_entries.async_reload(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.unique_id == mock_entry.data[CONF_USERNAME]
async def test_unload_entry(hass):
"""Test unloading the Abode entry."""
mock_entry = await setup_platform(hass, ALARM_DOMAIN)
@ -41,3 +60,16 @@ async def test_unload_entry(hass):
assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_SETTINGS)
assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_CAPTURE_IMAGE)
assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_TRIGGER_AUTOMATION)
async def test_invalid_credentials(hass):
"""Test Abode credentials changing."""
with patch(
"homeassistant.components.abode.Abode",
side_effect=AbodeAuthenticationException((HTTP_BAD_REQUEST, "auth error")),
), patch(
"homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth"
) as mock_async_step_reauth:
await setup_platform(hass, ALARM_DOMAIN)
mock_async_step_reauth.assert_called_once()