diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 2ac52c87131..529e3ff7189 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -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) diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 72e7ec1d9eb..76c23f7f705 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -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 + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=vol.Schema(self.data_schema) ) - 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"}) + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_POLLING: polling, - }, - ) + return await self._async_abode_login(step_id="user") - @callback - def _show_form(self, errors=None): - """Show the form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(self.data_schema), - errors=errors if errors else {}, - ) + 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) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index e9a871035e6..b7c962dac38 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -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"] diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json index 63b62fefcec..14a60f827c3 100644 --- a/homeassistant/components/abode/strings.json +++ b/homeassistant/components/abode/strings.json @@ -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%]" } } } \ No newline at end of file diff --git a/homeassistant/components/abode/translations/en.json b/homeassistant/components/abode/translations/en.json index 36f8bbb10e4..c1deaf0a00c 100644 --- a/homeassistant/components/abode/translations/en.json +++ b/homeassistant/components/abode/translations/en.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index fced3f9cd5d..0e745d9995f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 797d69d336f..c858aea7be3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/abode/test_config_flow.py b/tests/components/abode/test_config_flow.py index 509a68eda4b..f1445db340f 100644 --- a/tests/components/abode/test_config_flow.py +++ b/tests/components/abode/test_config_flow.py @@ -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 diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index 1598e7bfa91..68f7ce9dd03 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -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()