diff --git a/homeassistant/components/synology_dsm/.translations/en.json b/homeassistant/components/synology_dsm/.translations/en.json index 316d715e0b5..57ee3455840 100644 --- a/homeassistant/components/synology_dsm/.translations/en.json +++ b/homeassistant/components/synology_dsm/.translations/en.json @@ -5,10 +5,17 @@ }, "error": { "login": "Login error: please check your username & password", - "missing_data": "Missing data: please retry later or an other configuration" + "missing_data": "Missing data: please retry later or an other configuration", + "otp_failed": "Two-step authentication failed, retry with a new pass code" }, "flow_title": "Synology DSM {name} ({host})", "step": { + "2sa": { + "data": { + "otp_code": "Code" + }, + "title": "Synology DSM: two-step authentication" + }, "link": { "data": { "api_version": "DSM version", diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 59173e72fd5..f2fd3d3af0c 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -71,8 +71,11 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): unit = hass.config.units.temperature_unit use_ssl = entry.data[CONF_SSL] api_version = entry.data.get(CONF_API_VERSION, DEFAULT_DSM_VERSION) + device_token = entry.data.get("device_token") - api = SynoApi(hass, host, port, username, password, unit, use_ssl, api_version) + api = SynoApi( + hass, host, port, username, password, unit, use_ssl, device_token, api_version + ) await api.async_setup() @@ -105,6 +108,7 @@ class SynoApi: password: str, temp_unit: str, use_ssl: bool, + device_token: str, api_version: int, ): """Initialize the API wrapper class.""" @@ -114,6 +118,7 @@ class SynoApi: self._username = username self._password = password self._use_ssl = use_ssl + self._device_token = device_token self._api_version = api_version self.temp_unit = temp_unit @@ -137,6 +142,7 @@ class SynoApi: self._username, self._password, self._use_ssl, + device_token=self._device_token, dsm_version=self._api_version, ) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index de36e1298a6..c478270f8b0 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -3,6 +3,11 @@ import logging from urllib.parse import urlparse from synology_dsm import SynologyDSM +from synology_dsm.exceptions import ( + SynologyDSMLogin2SAFailedException, + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginInvalidException, +) import voluptuous as vol from homeassistant import config_entries, exceptions @@ -29,6 +34,8 @@ from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) +CONF_OTP_CODE = "otp_code" + def _discovery_schema_with_defaults(discovery_info): return vol.Schema(_ordered_shared_schema(discovery_info)) @@ -66,6 +73,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the synology_dsm config flow.""" + self.saved_user_input = {} self.discovered_conf = {} async def _show_setup_form(self, user_input=None, errors=None): @@ -104,6 +112,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = user_input[CONF_PASSWORD] use_ssl = user_input.get(CONF_SSL, DEFAULT_SSL) api_version = user_input.get(CONF_API_VERSION, DEFAULT_DSM_VERSION) + otp_code = user_input.get(CONF_OTP_CODE) if not port: if use_ssl is True: @@ -117,9 +126,15 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: serial = await self.hass.async_add_executor_job( - _login_and_fetch_syno_info, api + _login_and_fetch_syno_info, api, otp_code ) - except InvalidAuth: + except SynologyDSMLogin2SARequiredException: + return await self.async_step_2sa(user_input) + except SynologyDSMLogin2SAFailedException: + errors[CONF_OTP_CODE] = "otp_failed" + user_input[CONF_OTP_CODE] = None + return await self.async_step_2sa(user_input, errors) + except (SynologyDSMLoginInvalidException, InvalidAuth): errors[CONF_USERNAME] = "login" except InvalidData: errors["base"] = "missing_data" @@ -139,6 +154,8 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_PASSWORD: password, CONF_API_VERSION: api_version, } + if otp_code: + config_data["device_token"] = api.device_token if user_input.get(CONF_DISKS): config_data[CONF_DISKS] = user_input[CONF_DISKS] if user_input.get(CONF_VOLUMES): @@ -168,10 +185,27 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_link(self, user_input=None): + async def async_step_link(self, user_input): """Link a config entry from discovery.""" return await self.async_step_user(user_input) + async def async_step_2sa(self, user_input, errors=None): + """Enter 2SA code to anthenticate.""" + if not self.saved_user_input: + self.saved_user_input = user_input + + if not user_input.get(CONF_OTP_CODE): + return self.async_show_form( + step_id="2sa", + data_schema=vol.Schema({vol.Required(CONF_OTP_CODE): str}), + errors=errors or {}, + ) + + user_input = {**self.saved_user_input, **user_input} + self.saved_user_input = {} + + return await self.async_step_user(user_input) + def _host_already_configured(self, hostname): """See if we already have a host matching user input configured.""" existing_hosts = { @@ -180,9 +214,9 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return hostname in existing_hosts -def _login_and_fetch_syno_info(api): +def _login_and_fetch_syno_info(api, otp_code): """Login to the NAS and fetch basic data.""" - if not api.login(): + if not api.login(otp_code): raise InvalidAuth # These do i/o diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index dc58302fa32..c0b1fe782c4 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -6,9 +6,9 @@ from homeassistant.const import ( ) DOMAIN = "synology_dsm" +BASE_NAME = "Synology" CONF_VOLUMES = "volumes" -BASE_NAME = "Synology" DEFAULT_SSL = True DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index e2f730ddc8d..b54132c6897 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["python-synology==0.5.0"], + "requirements": ["python-synology==0.6.0"], "codeowners": ["@ProtoThis", "@Quentame"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index ec5644fe6fb..b90fd52fe5f 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -13,6 +13,12 @@ "password": "Password" } }, + "2sa": { + "title": "Synology DSM: two-step authentication", + "data": { + "otp_code": "Code" + } + }, "link": { "title": "Synology DSM", "description": "Do you want to setup {name} ({host})?", @@ -27,7 +33,8 @@ }, "error": { "login": "Login error: please check your username & password", - "missing_data": "Missing data: please retry later or an other configuration" + "missing_data": "Missing data: please retry later or an other configuration", + "otp_failed": "Two-step authentication failed, retry with a new pass code" }, "abort": { "already_configured": "Host already configured" } } diff --git a/requirements_all.txt b/requirements_all.txt index 77708b30d63..8bf58231c6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ python-sochain-api==0.0.2 python-songpal==0.11.2 # homeassistant.components.synology_dsm -python-synology==0.5.0 +python-synology==0.6.0 # homeassistant.components.tado python-tado==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaa954b6ac8..f5022d7b6ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ python-miio==0.5.0.1 python-nest==4.1.0 # homeassistant.components.synology_dsm -python-synology==0.5.0 +python-synology==0.6.0 # homeassistant.components.tado python-tado==0.6.0 diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 3daa4ce7b4e..17b98b7c910 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -3,9 +3,14 @@ import logging from unittest.mock import MagicMock, Mock, patch import pytest +from synology_dsm.exceptions import ( + SynologyDSMLogin2SAFailedException, + SynologyDSMLogin2SARequiredException, +) from homeassistant import data_entry_flow, setup from homeassistant.components import ssdp +from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( CONF_VOLUMES, DEFAULT_DSM_VERSION, @@ -39,6 +44,7 @@ PORT = 1234 SSL = True USERNAME = "Home_Assistant" PASSWORD = "password" +DEVICE_TOKEN = "Dév!cè_T0k€ñ" @pytest.fixture(name="service") @@ -54,6 +60,22 @@ def mock_controller_service(): yield service_mock +@pytest.fixture(name="service_2sa") +def mock_controller_service_2sa(): + """Mock a successful service with 2SA login.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM" + ) as service_mock: + service_mock.return_value.login = Mock( + side_effect=SynologyDSMLogin2SARequiredException(USERNAME) + ) + service_mock.return_value.information.serial = SERIAL + service_mock.return_value.utilisation.cpu_user_load = 1 + service_mock.return_value.storage.disks_ids = [] + service_mock.return_value.storage.volumes_ids = [] + yield service_mock + + @pytest.fixture(name="service_login_failed") def mock_controller_service_login_failed(): """Mock a failed login.""" @@ -107,6 +129,7 @@ async def test_user(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD assert result["data"][CONF_API_VERSION] == 5 + assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -131,6 +154,48 @@ async def test_user(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD assert result["data"][CONF_API_VERSION] == DEFAULT_DSM_VERSION + assert result["data"].get("device_token") is None + assert result["data"].get(CONF_DISKS) is None + assert result["data"].get(CONF_VOLUMES) is None + + +async def test_user_2sa(hass: HomeAssistantType, service_2sa: MagicMock): + """Test user with 2sa authentication config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "2sa" + + # Failed the first time because was too slow to enter the code + service_2sa.return_value.login = Mock( + side_effect=SynologyDSMLogin2SAFailedException + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP_CODE: "000000"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "2sa" + assert result["errors"] == {CONF_OTP_CODE: "otp_failed"} + + # Successful login with 2SA code + service_2sa.return_value.login = Mock(return_value=True) + service_2sa.return_value.device_token = DEVICE_TOKEN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP_CODE: "123456"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == HOST + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == DEFAULT_PORT_SSL + assert result["data"][CONF_SSL] == DEFAULT_SSL + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"].get("device_token") == DEVICE_TOKEN assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -152,6 +217,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD assert result["data"][CONF_API_VERSION] == DEFAULT_DSM_VERSION + assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -180,6 +246,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD assert result["data"][CONF_API_VERSION] == 5 + assert result["data"].get("device_token") is None assert result["data"][CONF_DISKS] == ["sda", "sdb", "sdc"] assert result["data"][CONF_VOLUMES] == ["volume_1"] @@ -263,5 +330,6 @@ async def test_form_ssdp(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD assert result["data"][CONF_API_VERSION] == DEFAULT_DSM_VERSION + assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None