Implement trusted_networks support (#283)
* Implement bypass for trusted_networks * Trusted Network tests * Test cleanup * Improve integration tests * Defensive programming * Fix wrong import issue
This commit is contained in:
committed by
GitHub
parent
04abb0fdb3
commit
c7370ed266
@@ -90,24 +90,59 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry):
|
||||
return False
|
||||
|
||||
|
||||
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str):
|
||||
"""Set up the OIDC provider with the given configuration."""
|
||||
providers = OrderedDict()
|
||||
|
||||
async def _register_oidc_provider(hass: HomeAssistant, my_config: dict):
|
||||
"""Register the OIDC provider in Home Assistant's auth system."""
|
||||
# Use private APIs until there is a real auth platform
|
||||
|
||||
# pylint: disable=protected-access
|
||||
providers = OrderedDict()
|
||||
provider = OpenIDAuthProvider(hass, hass.auth._store, my_config)
|
||||
|
||||
existing_auth_providers = hass.auth._providers.copy()
|
||||
_LOGGER.debug("Current auth providers: %s", list(existing_auth_providers.keys()))
|
||||
has_other_auth_providers = len(existing_auth_providers) > 0
|
||||
has_trusted_networks_provider_first = False
|
||||
|
||||
if has_other_auth_providers:
|
||||
# Pop the first provider from the existing providers to check if it's trusted_networks
|
||||
first_provider_key, first_provider_obj = next(
|
||||
iter(existing_auth_providers.items())
|
||||
)
|
||||
existing_auth_providers.pop(first_provider_key)
|
||||
|
||||
if first_provider_key[0] == "trusted_networks":
|
||||
_LOGGER.info(
|
||||
"Trusted Networks provider detected as the first auth provider. "
|
||||
+ "Keeping registration order intact."
|
||||
)
|
||||
providers[first_provider_key] = first_provider_obj
|
||||
has_trusted_networks_provider_first = True
|
||||
else:
|
||||
# Reset back to what we had before
|
||||
existing_auth_providers = hass.auth._providers.copy()
|
||||
|
||||
# Register OIDC at the start of the array
|
||||
# OIDC needs to be first because it needs to process the login cookie after sign-in
|
||||
providers[(provider.type, provider.id)] = provider
|
||||
|
||||
# Get current provider count
|
||||
has_other_auth_providers = len(hass.auth._providers) > 0
|
||||
# Add back any other providers that were already registered
|
||||
providers.update(existing_auth_providers)
|
||||
|
||||
providers.update(hass.auth._providers)
|
||||
_LOGGER.debug("Final auth providers: %s", list(providers.values()))
|
||||
hass.auth._providers = providers
|
||||
# pylint: enable=protected-access
|
||||
|
||||
_LOGGER.info("Registered OIDC provider")
|
||||
return provider, has_other_auth_providers, has_trusted_networks_provider_first
|
||||
|
||||
|
||||
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str):
|
||||
"""Set up the OIDC provider with the given configuration."""
|
||||
(
|
||||
provider,
|
||||
has_other_auth_providers,
|
||||
has_trusted_networks_provider_first,
|
||||
) = await _register_oidc_provider(hass, my_config)
|
||||
|
||||
# Set the correct scopes
|
||||
# Always use 'openid' & 'profile' as they are specified in the OIDC spec
|
||||
@@ -179,6 +214,8 @@ async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_nam
|
||||
_LOGGER.info("Registered OIDC views")
|
||||
|
||||
# Inject OIDC code into the frontend for /auth/authorize for automatic redirect
|
||||
await OIDCInjectedAuthPage.inject(hass, force_https)
|
||||
await OIDCInjectedAuthPage.inject(
|
||||
hass, provider, force_https, has_trusted_networks_provider_first
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.http import HomeAssistantView, StaticPathConfig
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .welcome import PATH as WELCOME_PATH
|
||||
from ..provider import OpenIDAuthProvider
|
||||
from ..tools.helpers import get_url
|
||||
|
||||
PATH = "/auth/authorize"
|
||||
@@ -24,7 +25,12 @@ async def read_file(path: str) -> str:
|
||||
return await f.read()
|
||||
|
||||
|
||||
async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
|
||||
async def frontend_injection(
|
||||
hass: HomeAssistant,
|
||||
provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
has_trusted_networks_provider_first: bool,
|
||||
) -> None:
|
||||
"""Inject new frontend code into /auth/authorize."""
|
||||
router = hass.http.app.router
|
||||
frontend_path = None
|
||||
@@ -81,7 +87,11 @@ async def frontend_injection(hass: HomeAssistant, force_https: bool) -> None:
|
||||
)
|
||||
|
||||
# If everything is succesful, register a fake view that just returns the modified HTML
|
||||
hass.http.register_view(OIDCInjectedAuthPage(frontend_code, force_https))
|
||||
hass.http.register_view(
|
||||
OIDCInjectedAuthPage(
|
||||
frontend_code, provider, force_https, has_trusted_networks_provider_first
|
||||
)
|
||||
)
|
||||
_LOGGER.info("Performed OIDC frontend injection")
|
||||
|
||||
|
||||
@@ -92,21 +102,36 @@ class OIDCInjectedAuthPage(HomeAssistantView):
|
||||
url = PATH
|
||||
name = "auth:oidc:authorize_page"
|
||||
|
||||
def __init__(self, html: str, force_https: bool) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
html: str,
|
||||
provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
has_trusted_networks_provider_first: bool,
|
||||
) -> None:
|
||||
"""Initialize the injected auth page."""
|
||||
self.html = html
|
||||
self.provider = provider
|
||||
self.force_https = force_https
|
||||
self.has_trusted_networks_provider_first = has_trusted_networks_provider_first
|
||||
|
||||
@staticmethod
|
||||
async def inject(hass: HomeAssistant, force_https: bool) -> None:
|
||||
async def inject(
|
||||
hass: HomeAssistant,
|
||||
provider: OpenIDAuthProvider,
|
||||
force_https: bool,
|
||||
has_trusted_networks_provider_first: bool,
|
||||
) -> None:
|
||||
"""Inject the OIDC auth page into the frontend."""
|
||||
|
||||
try:
|
||||
await frontend_injection(hass, force_https)
|
||||
await frontend_injection(
|
||||
hass, provider, force_https, has_trusted_networks_provider_first
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error("Failed to inject OIDC auth page: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _should_do_oidc_redirect(req: web.Request) -> bool:
|
||||
def _should_do_oidc_redirect(self, req: web.Request) -> bool:
|
||||
"""Check if we should redirect to the OIDC flow."""
|
||||
# Set when we return from finish
|
||||
if req.query.get("skip_oidc_redirect") == "true":
|
||||
@@ -118,6 +143,13 @@ class OIDCInjectedAuthPage(HomeAssistantView):
|
||||
if not redirect_uri:
|
||||
return False
|
||||
|
||||
# Check if we are on a trusted network if we have trusted networks registered first
|
||||
if (
|
||||
self.has_trusted_networks_provider_first
|
||||
and self.provider.is_trusted_network_host()
|
||||
):
|
||||
return False
|
||||
|
||||
# Handle both encoded and plain redirect_uri values.
|
||||
decoded_redirect_uri = unquote(redirect_uri)
|
||||
return "skip_oidc_redirect=true" not in decoded_redirect_uri
|
||||
|
||||
@@ -6,7 +6,12 @@ import logging
|
||||
|
||||
from typing import Dict, Optional
|
||||
import asyncio
|
||||
from homeassistant.auth import EVENT_USER_ADDED
|
||||
from ipaddress import (
|
||||
ip_address,
|
||||
IPv4Address,
|
||||
IPv6Address,
|
||||
)
|
||||
from homeassistant.auth import EVENT_USER_ADDED, InvalidAuthError as HAInvalidAuthError
|
||||
from homeassistant.auth.providers import (
|
||||
AUTH_PROVIDERS,
|
||||
AuthProvider,
|
||||
@@ -20,7 +25,6 @@ from homeassistant.auth.providers import (
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.components import http, person
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .config.const import (
|
||||
FEATURES,
|
||||
@@ -31,6 +35,8 @@ from .config.const import (
|
||||
from .stores.state_store import StateStore
|
||||
from .tools.types import UserDetails
|
||||
|
||||
type IPAddress = IPv4Address | IPv6Address
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_TYPE = "auth_oidc"
|
||||
@@ -38,7 +44,7 @@ HASS_PROVIDER_TYPE = "homeassistant"
|
||||
COOKIE_NAME = "auth_oidc_state"
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
class InvalidAuthError(HAInvalidAuthError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
@@ -114,6 +120,41 @@ class OpenIDAuthProvider(AuthProvider):
|
||||
|
||||
return None
|
||||
|
||||
def is_trusted_network_host(self) -> bool:
|
||||
"""Check if the current request is coming from a trusted network host."""
|
||||
ip = self._resolve_ip()
|
||||
if ip is None:
|
||||
return False
|
||||
|
||||
# Check if trusted networks auth provider is present
|
||||
trusted_network_provider = self.hass.auth.get_auth_provider(
|
||||
"trusted_networks", None
|
||||
)
|
||||
if not trusted_network_provider:
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Trusted networks present and checking if we should OIDC redirect"
|
||||
)
|
||||
|
||||
try:
|
||||
trusted_network_provider.async_validate_access(ip_address(ip))
|
||||
_LOGGER.info("IP %s is in a trusted network, skipping OIDC flow", ip)
|
||||
return True
|
||||
except HAInvalidAuthError:
|
||||
# Log the error
|
||||
_LOGGER.info(
|
||||
"IP %s is not in a trusted network, proceeding with OIDC flow", ip
|
||||
)
|
||||
return False
|
||||
# Catch every other error, HA might have changed the API.
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
_LOGGER.warning(
|
||||
"Error while validating trusted network for IP %s: %s", ip, e
|
||||
)
|
||||
return False
|
||||
|
||||
async def async_create_state(self, redirect_uri: str, ip: str | None = None) -> str:
|
||||
"""Create a new OIDC state and return the state id."""
|
||||
if self._state_store is None:
|
||||
|
||||
Reference in New Issue
Block a user