Add multi-factor authentication support to Verisure (#75113)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Franck Nijhof 2022-07-15 10:52:40 +02:00 committed by GitHub
parent d2e5d01aca
commit 3f3ed3a2c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 479 additions and 29 deletions

View File

@ -3,9 +3,10 @@ from __future__ import annotations
from contextlib import suppress
import os
from pathlib import Path
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.const import CONF_EMAIL, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
import homeassistant.helpers.config_validation as cv
@ -28,6 +29,8 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Verisure from a config entry."""
await hass.async_add_executor_job(migrate_cookie_files, hass, entry)
coordinator = VerisureDataUpdateCoordinator(hass, entry=entry)
if not await coordinator.async_login():
@ -64,3 +67,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
del hass.data[DOMAIN]
return True
def migrate_cookie_files(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Migrate old cookie file to new location."""
cookie_file = Path(hass.config.path(STORAGE_DIR, f"verisure_{entry.unique_id}"))
if cookie_file.exists():
cookie_file.rename(
hass.config.path(STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}")
)

View File

@ -13,9 +13,10 @@ from verisure import (
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.storage import STORAGE_DIR
from .const import (
CONF_GIID,
@ -53,13 +54,34 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN):
self.email = user_input[CONF_EMAIL]
self.password = user_input[CONF_PASSWORD]
self.verisure = Verisure(
username=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
username=self.email,
password=self.password,
cookieFileName=self.hass.config.path(
STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}"
),
)
try:
await self.hass.async_add_executor_job(self.verisure.login)
except VerisureLoginError as ex:
LOGGER.debug("Could not log in to Verisure, %s", ex)
errors["base"] = "invalid_auth"
if "Multifactor authentication enabled" in str(ex):
try:
await self.hass.async_add_executor_job(self.verisure.login_mfa)
except (
VerisureLoginError,
VerisureError,
VerisureResponseError,
) as mfa_ex:
LOGGER.debug(
"Unexpected response from Verisure during MFA set up, %s",
mfa_ex,
)
errors["base"] = "unknown_mfa"
else:
return await self.async_step_mfa()
else:
LOGGER.debug("Could not log in to Verisure, %s", ex)
errors["base"] = "invalid_auth"
except (VerisureError, VerisureResponseError) as ex:
LOGGER.debug("Unexpected response from Verisure, %s", ex)
errors["base"] = "unknown"
@ -77,6 +99,39 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_mfa(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle multifactor authentication step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
await self.hass.async_add_executor_job(
self.verisure.mfa_validate, user_input[CONF_CODE], True
)
await self.hass.async_add_executor_job(self.verisure.login)
except VerisureLoginError as ex:
LOGGER.debug("Could not log in to Verisure, %s", ex)
errors["base"] = "invalid_auth"
except (VerisureError, VerisureResponseError) as ex:
LOGGER.debug("Unexpected response from Verisure, %s", ex)
errors["base"] = "unknown"
else:
return await self.async_step_installation()
return self.async_show_form(
step_id="mfa",
data_schema=vol.Schema(
{
vol.Required(CONF_CODE): vol.All(
vol.Coerce(str), vol.Length(min=6, max=6)
)
}
),
errors=errors,
)
async def async_step_installation(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@ -123,14 +178,38 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
verisure = Verisure(
username=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
self.email = user_input[CONF_EMAIL]
self.password = user_input[CONF_PASSWORD]
self.verisure = Verisure(
username=self.email,
password=self.password,
cookieFileName=self.hass.config.path(
STORAGE_DIR, f"verisure-{user_input[CONF_EMAIL]}"
),
)
try:
await self.hass.async_add_executor_job(verisure.login)
await self.hass.async_add_executor_job(self.verisure.login)
except VerisureLoginError as ex:
LOGGER.debug("Could not log in to Verisure, %s", ex)
errors["base"] = "invalid_auth"
if "Multifactor authentication enabled" in str(ex):
try:
await self.hass.async_add_executor_job(self.verisure.login_mfa)
except (
VerisureLoginError,
VerisureError,
VerisureResponseError,
) as mfa_ex:
LOGGER.debug(
"Unexpected response from Verisure during MFA set up, %s",
mfa_ex,
)
errors["base"] = "unknown_mfa"
else:
return await self.async_step_reauth_mfa()
else:
LOGGER.debug("Could not log in to Verisure, %s", ex)
errors["base"] = "invalid_auth"
except (VerisureError, VerisureResponseError) as ex:
LOGGER.debug("Unexpected response from Verisure, %s", ex)
errors["base"] = "unknown"
@ -160,6 +239,51 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth_mfa(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle multifactor authentication step during re-authentication."""
errors: dict[str, str] = {}
if user_input is not None:
try:
await self.hass.async_add_executor_job(
self.verisure.mfa_validate, user_input[CONF_CODE], True
)
await self.hass.async_add_executor_job(self.verisure.login)
except VerisureLoginError as ex:
LOGGER.debug("Could not log in to Verisure, %s", ex)
errors["base"] = "invalid_auth"
except (VerisureError, VerisureResponseError) as ex:
LOGGER.debug("Unexpected response from Verisure, %s", ex)
errors["base"] = "unknown"
else:
self.hass.config_entries.async_update_entry(
self.entry,
data={
**self.entry.data,
CONF_EMAIL: self.email,
CONF_PASSWORD: self.password,
},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_mfa",
data_schema=vol.Schema(
{
vol.Required(CONF_CODE): vol.All(
vol.Coerce(str),
vol.Length(min=6, max=6),
)
}
),
errors=errors,
)
class VerisureOptionsFlowHandler(OptionsFlow):
"""Handle Verisure options."""

View File

@ -31,7 +31,9 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator):
self.verisure = Verisure(
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
cookieFileName=hass.config.path(STORAGE_DIR, f"verisure_{entry.entry_id}"),
cookieFileName=hass.config.path(
STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}"
),
)
super().__init__(

View File

@ -2,7 +2,7 @@
"domain": "verisure",
"name": "Verisure",
"documentation": "https://www.home-assistant.io/integrations/verisure",
"requirements": ["vsure==1.7.3"],
"requirements": ["vsure==1.8.1"],
"codeowners": ["@frenck"],
"config_flow": true,
"dhcp": [

View File

@ -8,6 +8,12 @@
"password": "[%key:common::config_flow::data::password%]"
}
},
"mfa": {
"data": {
"description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you.",
"code": "Verification Code"
}
},
"installation": {
"description": "Home Assistant found multiple Verisure installations in your My Pages account. Please, select the installation to add to Home Assistant.",
"data": {
@ -20,11 +26,18 @@
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"reauth_mfa": {
"data": {
"description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you.",
"code": "Verification Code"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_mfa": "Unknown error occurred during MFA set up"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",

View File

@ -6,7 +6,8 @@
},
"error": {
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
"unknown": "Unexpected error",
"unknown_mfa": "Unknown error occurred during MFA set up"
},
"step": {
"installation": {
@ -15,6 +16,12 @@
},
"description": "Home Assistant found multiple Verisure installations in your My Pages account. Please, select the installation to add to Home Assistant."
},
"mfa": {
"data": {
"code": "Verification Code",
"description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you."
}
},
"reauth_confirm": {
"data": {
"description": "Re-authenticate with your Verisure My Pages account.",
@ -22,6 +29,12 @@
"password": "Password"
}
},
"reauth_mfa": {
"data": {
"code": "Verification Code",
"description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you."
}
},
"user": {
"data": {
"description": "Sign-in with your Verisure My Pages account.",

View File

@ -2402,7 +2402,7 @@ volkszaehler==0.3.2
volvooncall==0.10.0
# homeassistant.components.verisure
vsure==1.7.3
vsure==1.8.1
# homeassistant.components.vasttrafik
vtjp==0.1.14

View File

@ -1602,7 +1602,7 @@ venstarcolortouch==0.17
vilfo-api-client==0.3.2
# homeassistant.components.verisure
vsure==1.7.3
vsure==1.8.1
# homeassistant.components.vulcan
vulcan-api==2.1.1

View File

@ -106,6 +106,129 @@ async def test_full_user_flow_multiple_installations(
assert len(mock_setup_entry.mock_calls) == 1
async def test_full_user_flow_single_installation_with_mfa(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verisure_config_flow: MagicMock,
) -> None:
"""Test a full user initiated flow with a single installation and mfa."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("step_id") == "user"
assert result.get("type") == FlowResultType.FORM
assert result.get("errors") == {}
assert "flow_id" in result
mock_verisure_config_flow.login.side_effect = VerisureLoginError(
"Multifactor authentication enabled, disable or create MFA cookie"
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "SuperS3cr3t!",
},
)
await hass.async_block_till_done()
assert result2.get("type") == FlowResultType.FORM
assert result2.get("step_id") == "mfa"
assert "flow_id" in result2
mock_verisure_config_flow.login.side_effect = None
mock_verisure_config_flow.installations = [
mock_verisure_config_flow.installations[0]
]
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"code": "123456",
},
)
await hass.async_block_till_done()
assert result3.get("type") == FlowResultType.CREATE_ENTRY
assert result3.get("title") == "ascending (12345th street)"
assert result3.get("data") == {
CONF_GIID: "12345",
CONF_EMAIL: "verisure_my_pages@example.com",
CONF_PASSWORD: "SuperS3cr3t!",
}
assert len(mock_verisure_config_flow.login.mock_calls) == 2
assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1
assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_full_user_flow_multiple_installations_with_mfa(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verisure_config_flow: MagicMock,
) -> None:
"""Test a full user initiated configuration flow with a single installation."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("step_id") == "user"
assert result.get("type") == FlowResultType.FORM
assert result.get("errors") == {}
assert "flow_id" in result
mock_verisure_config_flow.login.side_effect = VerisureLoginError(
"Multifactor authentication enabled, disable or create MFA cookie"
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "SuperS3cr3t!",
},
)
await hass.async_block_till_done()
assert result2.get("type") == FlowResultType.FORM
assert result2.get("step_id") == "mfa"
assert "flow_id" in result2
mock_verisure_config_flow.login.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"code": "123456",
},
)
await hass.async_block_till_done()
assert result3.get("step_id") == "installation"
assert result3.get("type") == FlowResultType.FORM
assert result3.get("errors") is None
assert "flow_id" in result2
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"], {"giid": "54321"}
)
await hass.async_block_till_done()
assert result4.get("type") == FlowResultType.CREATE_ENTRY
assert result4.get("title") == "descending (54321th street)"
assert result4.get("data") == {
CONF_GIID: "54321",
CONF_EMAIL: "verisure_my_pages@example.com",
CONF_PASSWORD: "SuperS3cr3t!",
}
assert len(mock_verisure_config_flow.login.mock_calls) == 2
assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1
assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
"side_effect,error",
[
@ -142,10 +265,10 @@ async def test_verisure_errors(
assert result2.get("errors") == {"base": error}
assert "flow_id" in result2
mock_verisure_config_flow.login.side_effect = None
mock_verisure_config_flow.installations = [
mock_verisure_config_flow.installations[0]
]
mock_verisure_config_flow.login.side_effect = VerisureLoginError(
"Multifactor authentication enabled, disable or create MFA cookie"
)
mock_verisure_config_flow.login_mfa.side_effect = side_effect
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
@ -156,15 +279,65 @@ async def test_verisure_errors(
)
await hass.async_block_till_done()
assert result3.get("type") == FlowResultType.CREATE_ENTRY
assert result3.get("title") == "ascending (12345th street)"
assert result3.get("data") == {
mock_verisure_config_flow.login_mfa.side_effect = None
assert result3.get("type") == FlowResultType.FORM
assert result3.get("step_id") == "user"
assert result3.get("errors") == {"base": "unknown_mfa"}
assert "flow_id" in result3
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "SuperS3cr3t!",
},
)
await hass.async_block_till_done()
assert result4.get("type") == FlowResultType.FORM
assert result4.get("step_id") == "mfa"
assert "flow_id" in result4
mock_verisure_config_flow.mfa_validate.side_effect = side_effect
result5 = await hass.config_entries.flow.async_configure(
result4["flow_id"],
{
"code": "123456",
},
)
assert result5.get("type") == FlowResultType.FORM
assert result5.get("step_id") == "mfa"
assert result5.get("errors") == {"base": error}
assert "flow_id" in result5
mock_verisure_config_flow.installations = [
mock_verisure_config_flow.installations[0]
]
mock_verisure_config_flow.mfa_validate.side_effect = None
mock_verisure_config_flow.login.side_effect = None
result6 = await hass.config_entries.flow.async_configure(
result5["flow_id"],
{
"code": "654321",
},
)
await hass.async_block_till_done()
assert result6.get("type") == FlowResultType.CREATE_ENTRY
assert result6.get("title") == "ascending (12345th street)"
assert result6.get("data") == {
CONF_GIID: "12345",
CONF_EMAIL: "verisure_my_pages@example.com",
CONF_PASSWORD: "SuperS3cr3t!",
}
assert len(mock_verisure_config_flow.login.mock_calls) == 2
assert len(mock_verisure_config_flow.login.mock_calls) == 4
assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2
assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2
assert len(mock_setup_entry.mock_calls) == 1
@ -226,6 +399,70 @@ async def test_reauth_flow(
assert len(mock_setup_entry.mock_calls) == 1
async def test_reauth_flow_with_mfa(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verisure_config_flow: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a reauthentication flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"unique_id": mock_config_entry.unique_id,
"entry_id": mock_config_entry.entry_id,
},
data=mock_config_entry.data,
)
assert result.get("step_id") == "reauth_confirm"
assert result.get("type") == FlowResultType.FORM
assert result.get("errors") == {}
assert "flow_id" in result
mock_verisure_config_flow.login.side_effect = VerisureLoginError(
"Multifactor authentication enabled, disable or create MFA cookie"
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "correct horse battery staple!",
},
)
await hass.async_block_till_done()
assert result2.get("type") == FlowResultType.FORM
assert result2.get("step_id") == "reauth_mfa"
assert "flow_id" in result2
mock_verisure_config_flow.login.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
"code": "123456",
},
)
await hass.async_block_till_done()
assert result3.get("type") == FlowResultType.ABORT
assert result3.get("reason") == "reauth_successful"
assert mock_config_entry.data == {
CONF_GIID: "12345",
CONF_EMAIL: "verisure_my_pages@example.com",
CONF_PASSWORD: "correct horse battery staple!",
}
assert len(mock_verisure_config_flow.login.mock_calls) == 2
assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1
assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
"side_effect,error",
[
@ -271,16 +508,63 @@ async def test_reauth_flow_errors(
assert result2.get("errors") == {"base": error}
assert "flow_id" in result2
mock_verisure_config_flow.login.side_effect = VerisureLoginError(
"Multifactor authentication enabled, disable or create MFA cookie"
)
mock_verisure_config_flow.login_mfa.side_effect = side_effect
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "SuperS3cr3t!",
},
)
await hass.async_block_till_done()
assert result3.get("type") == FlowResultType.FORM
assert result3.get("step_id") == "reauth_confirm"
assert result3.get("errors") == {"base": "unknown_mfa"}
assert "flow_id" in result3
mock_verisure_config_flow.login_mfa.side_effect = None
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "SuperS3cr3t!",
},
)
await hass.async_block_till_done()
assert result4.get("type") == FlowResultType.FORM
assert result4.get("step_id") == "reauth_mfa"
assert "flow_id" in result4
mock_verisure_config_flow.mfa_validate.side_effect = side_effect
result5 = await hass.config_entries.flow.async_configure(
result4["flow_id"],
{
"code": "123456",
},
)
assert result5.get("type") == FlowResultType.FORM
assert result5.get("step_id") == "reauth_mfa"
assert result5.get("errors") == {"base": error}
assert "flow_id" in result5
mock_verisure_config_flow.mfa_validate.side_effect = None
mock_verisure_config_flow.login.side_effect = None
mock_verisure_config_flow.installations = [
mock_verisure_config_flow.installations[0]
]
await hass.config_entries.flow.async_configure(
result2["flow_id"],
result5["flow_id"],
{
"email": "verisure_my_pages@example.com",
"password": "correct horse battery staple",
"code": "654321",
},
)
await hass.async_block_till_done()
@ -288,10 +572,12 @@ async def test_reauth_flow_errors(
assert mock_config_entry.data == {
CONF_GIID: "12345",
CONF_EMAIL: "verisure_my_pages@example.com",
CONF_PASSWORD: "correct horse battery staple",
CONF_PASSWORD: "SuperS3cr3t!",
}
assert len(mock_verisure_config_flow.login.mock_calls) == 2
assert len(mock_verisure_config_flow.login.mock_calls) == 4
assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2
assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2
assert len(mock_setup_entry.mock_calls) == 1