Add config for trusted networks auth provider (#21111)

* Add config for trusted networks auth provider

* Lint

* Fix typing

* Fix pylint

* Fix lint

* Add some log information

* Add http.trusted_networks deprecated warning

* Remove log info

* Lint
This commit is contained in:
Jason Hu 2019-02-26 14:42:48 -08:00 committed by Paulus Schoutsen
parent 5c2f997394
commit 7bae76843c
6 changed files with 72 additions and 43 deletions

View File

@ -3,18 +3,23 @@
It shows list of users if access from trusted network. It shows list of users if access from trusted network.
Abort login flow if not access from trusted network. Abort login flow if not access from trusted network.
""" """
from typing import Any, Dict, Optional, cast from ipaddress import ip_network, IPv4Address, IPv6Address, IPv4Network,\
IPv6Network
from typing import Any, Dict, List, Optional, Union, cast
import voluptuous as vol import voluptuous as vol
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
from ..models import Credentials, UserMeta from ..models import Credentials, UserMeta
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
vol.Required('trusted_networks'): vol.All(cv.ensure_list, [ip_network])
}, extra=vol.PREVENT_EXTRA) }, extra=vol.PREVENT_EXTRA)
@ -35,6 +40,11 @@ class TrustedNetworksAuthProvider(AuthProvider):
DEFAULT_TITLE = 'Trusted Networks' DEFAULT_TITLE = 'Trusted Networks'
@property
def trusted_networks(self) -> List[IPNetwork]:
"""Return trusted networks."""
return cast(List[IPNetwork], self.config['trusted_networks'])
@property @property
def support_mfa(self) -> bool: def support_mfa(self) -> bool:
"""Trusted Networks auth provider does not support MFA.""" """Trusted Networks auth provider does not support MFA."""
@ -49,7 +59,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
if not user.system_generated and user.is_active} if not user.system_generated and user.is_active}
return TrustedNetworksLoginFlow( return TrustedNetworksLoginFlow(
self, cast(str, context.get('ip_address')), available_users) self, cast(IPAddress, context.get('ip_address')), available_users)
async def async_get_or_create_credentials( async def async_get_or_create_credentials(
self, flow_result: Dict[str, str]) -> Credentials: self, flow_result: Dict[str, str]) -> Credentials:
@ -80,19 +90,17 @@ class TrustedNetworksAuthProvider(AuthProvider):
raise NotImplementedError raise NotImplementedError
@callback @callback
def async_validate_access(self, ip_address: str) -> None: def async_validate_access(self, ip_addr: IPAddress) -> None:
"""Make sure the access from trusted networks. """Make sure the access from trusted networks.
Raise InvalidAuthError if not. Raise InvalidAuthError if not.
Raise InvalidAuthError if trusted_networks is not configured. Raise InvalidAuthError if trusted_networks is not configured.
""" """
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP if not self.trusted_networks:
if not hass_http or not hass_http.trusted_networks:
raise InvalidAuthError('trusted_networks is not configured') raise InvalidAuthError('trusted_networks is not configured')
if not any(ip_address in trusted_network for trusted_network if not any(ip_addr in trusted_network for trusted_network
in hass_http.trusted_networks): in self.trusted_networks):
raise InvalidAuthError('Not in trusted_networks') raise InvalidAuthError('Not in trusted_networks')
@ -100,12 +108,12 @@ class TrustedNetworksLoginFlow(LoginFlow):
"""Handler for the login flow.""" """Handler for the login flow."""
def __init__(self, auth_provider: TrustedNetworksAuthProvider, def __init__(self, auth_provider: TrustedNetworksAuthProvider,
ip_address: str, available_users: Dict[str, Optional[str]]) \ ip_addr: IPAddress,
-> None: available_users: Dict[str, Optional[str]]) -> None:
"""Initialize the login flow.""" """Initialize the login flow."""
super().__init__(auth_provider) super().__init__(auth_provider)
self._available_users = available_users self._available_users = available_users
self._ip_address = ip_address self._ip_address = ip_addr
async def async_step_init( async def async_step_init(
self, user_input: Optional[Dict[str, str]] = None) \ self, user_input: Optional[Dict[str, str]] = None) \

View File

@ -86,13 +86,12 @@ async def async_from_config_dict(config: Dict[str, Any],
log_no_color) log_no_color)
core_config = config.get(core.DOMAIN, {}) core_config = config.get(core.DOMAIN, {})
has_api_password = bool((config.get('http') or {}).get('api_password')) has_api_password = bool(config.get('http', {}).get('api_password'))
has_trusted_networks = bool((config.get('http') or {}) trusted_networks = config.get('http', {}).get('trusted_networks')
.get('trusted_networks'))
try: try:
await conf_util.async_process_ha_core_config( await conf_util.async_process_ha_core_config(
hass, core_config, has_api_password, has_trusted_networks) hass, core_config, has_api_password, trusted_networks)
except vol.Invalid as config_err: except vol.Invalid as config_err:
conf_util.async_log_exception( conf_util.async_log_exception(
config_err, 'homeassistant', core_config, hass) config_err, 'homeassistant', core_config, hass)

View File

@ -52,6 +52,17 @@ DEFAULT_SERVER_HOST = '0.0.0.0'
DEFAULT_DEVELOPMENT = '0' DEFAULT_DEVELOPMENT = '0'
NO_LOGIN_ATTEMPT_THRESHOLD = -1 NO_LOGIN_ATTEMPT_THRESHOLD = -1
def trusted_networks_deprecated(value):
"""Warn user trusted_networks config is deprecated."""
_LOGGER.warning(
"Configuring trusted_networks via the http component has been"
" deprecated. Use the trusted networks auth provider instead."
" For instructions, see https://www.home-assistant.io/docs/"
"authentication/providers/#trusted-networks")
return value
HTTP_SCHEMA = vol.Schema({ HTTP_SCHEMA = vol.Schema({
vol.Optional(CONF_API_PASSWORD): cv.string, vol.Optional(CONF_API_PASSWORD): cv.string,
vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string,
@ -66,7 +77,7 @@ HTTP_SCHEMA = vol.Schema({
vol.Inclusive(CONF_TRUSTED_PROXIES, 'proxy'): vol.Inclusive(CONF_TRUSTED_PROXIES, 'proxy'):
vol.All(cv.ensure_list, [ip_network]), vol.All(cv.ensure_list, [ip_network]),
vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.Optional(CONF_TRUSTED_NETWORKS, default=[]):
vol.All(cv.ensure_list, [ip_network]), vol.All(cv.ensure_list, [ip_network], trusted_networks_deprecated),
vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD,
default=NO_LOGIN_ATTEMPT_THRESHOLD): default=NO_LOGIN_ATTEMPT_THRESHOLD):
vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD),

View File

@ -429,7 +429,7 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str:
async def async_process_ha_core_config( async def async_process_ha_core_config(
hass: HomeAssistant, config: Dict, hass: HomeAssistant, config: Dict,
has_api_password: bool = False, has_api_password: bool = False,
has_trusted_networks: bool = False) -> None: trusted_networks: Optional[Any] = None) -> None:
"""Process the [homeassistant] section from the configuration. """Process the [homeassistant] section from the configuration.
This method is a coroutine. This method is a coroutine.
@ -446,8 +446,11 @@ async def async_process_ha_core_config(
] ]
if has_api_password: if has_api_password:
auth_conf.append({'type': 'legacy_api_password'}) auth_conf.append({'type': 'legacy_api_password'})
if has_trusted_networks: if trusted_networks:
auth_conf.append({'type': 'trusted_networks'}) auth_conf.append({
'type': 'trusted_networks',
'trusted_networks': trusted_networks,
})
mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [ mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [
{'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'}, {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'},

View File

@ -1,5 +1,5 @@
"""Test the Trusted Networks auth provider.""" """Test the Trusted Networks auth provider."""
from unittest.mock import Mock from ipaddress import ip_address
import pytest import pytest
import voluptuous as vol import voluptuous as vol
@ -18,9 +18,17 @@ def store(hass):
@pytest.fixture @pytest.fixture
def provider(hass, store): def provider(hass, store):
"""Mock provider.""" """Mock provider."""
return tn_auth.TrustedNetworksAuthProvider(hass, store, { return tn_auth.TrustedNetworksAuthProvider(
'type': 'trusted_networks' hass, store, tn_auth.CONFIG_SCHEMA({
}) 'type': 'trusted_networks',
'trusted_networks': [
'192.168.0.1',
'192.168.128.0/24',
'::1',
'fd00::/8'
]
})
)
@pytest.fixture @pytest.fixture
@ -56,14 +64,17 @@ async def test_trusted_networks_credentials(manager, provider):
async def test_validate_access(provider): async def test_validate_access(provider):
"""Test validate access from trusted networks.""" """Test validate access from trusted networks."""
with pytest.raises(tn_auth.InvalidAuthError): provider.async_validate_access(ip_address('192.168.0.1'))
provider.async_validate_access('192.168.0.1') provider.async_validate_access(ip_address('192.168.128.10'))
provider.async_validate_access(ip_address('::1'))
provider.hass.http = Mock(trusted_networks=['192.168.0.1']) provider.async_validate_access(ip_address('fd01:db8::ff00:42:8329'))
provider.async_validate_access('192.168.0.1')
with pytest.raises(tn_auth.InvalidAuthError): with pytest.raises(tn_auth.InvalidAuthError):
provider.async_validate_access('127.0.0.1') provider.async_validate_access(ip_address('192.168.0.2'))
with pytest.raises(tn_auth.InvalidAuthError):
provider.async_validate_access(ip_address('127.0.0.1'))
with pytest.raises(tn_auth.InvalidAuthError):
provider.async_validate_access(ip_address('2001:db8::ff00:42:8329'))
async def test_login_flow(manager, provider): async def test_login_flow(manager, provider):
@ -71,22 +82,16 @@ async def test_login_flow(manager, provider):
owner = await manager.async_create_user("test-owner") owner = await manager.async_create_user("test-owner")
user = await manager.async_create_user("test-user") user = await manager.async_create_user("test-user")
# trusted network didn't loaded
flow = await provider.async_login_flow({'ip_address': '127.0.0.1'})
step = await flow.async_step_init()
assert step['type'] == 'abort'
assert step['reason'] == 'not_whitelisted'
provider.hass.http = Mock(trusted_networks=['192.168.0.1'])
# not from trusted network # not from trusted network
flow = await provider.async_login_flow({'ip_address': '127.0.0.1'}) flow = await provider.async_login_flow(
{'ip_address': ip_address('127.0.0.1')})
step = await flow.async_step_init() step = await flow.async_step_init()
assert step['type'] == 'abort' assert step['type'] == 'abort'
assert step['reason'] == 'not_whitelisted' assert step['reason'] == 'not_whitelisted'
# from trusted network, list users # from trusted network, list users
flow = await provider.async_login_flow({'ip_address': '192.168.0.1'}) flow = await provider.async_login_flow(
{'ip_address': ip_address('192.168.0.1')})
step = await flow.async_step_init() step = await flow.async_step_init()
assert step['step_id'] == 'init' assert step['step_id'] == 'init'

View File

@ -5,6 +5,7 @@ import os
import unittest import unittest
import unittest.mock as mock import unittest.mock as mock
from collections import OrderedDict from collections import OrderedDict
from ipaddress import ip_network
import asynctest import asynctest
import pytest import pytest
@ -891,12 +892,14 @@ async def test_auth_provider_config_default_trusted_networks(hass):
} }
if hasattr(hass, 'auth'): if hasattr(hass, 'auth'):
del hass.auth del hass.auth
await config_util.async_process_ha_core_config(hass, core_config, await config_util.async_process_ha_core_config(
has_trusted_networks=True) hass, core_config, trusted_networks=['192.168.0.1'])
assert len(hass.auth.auth_providers) == 2 assert len(hass.auth.auth_providers) == 2
assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[0].type == 'homeassistant'
assert hass.auth.auth_providers[1].type == 'trusted_networks' assert hass.auth.auth_providers[1].type == 'trusted_networks'
assert hass.auth.auth_providers[1].trusted_networks[0] == ip_network(
'192.168.0.1')
async def test_disallowed_auth_provider_config(hass): async def test_disallowed_auth_provider_config(hass):