From cb62f4242eb3cb15e0c8dfd14ee36565dea391d4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 24 May 2024 15:50:22 +0200 Subject: [PATCH] Remove strict connection (#117933) --- homeassistant/auth/__init__.py | 3 - homeassistant/auth/session.py | 205 ----------- homeassistant/components/auth/__init__.py | 18 - homeassistant/components/cloud/__init__.py | 70 +--- homeassistant/components/cloud/client.py | 1 - homeassistant/components/cloud/const.py | 1 - homeassistant/components/cloud/http_api.py | 6 +- homeassistant/components/cloud/icons.json | 1 - homeassistant/components/cloud/prefs.py | 21 +- homeassistant/components/cloud/strings.json | 12 - homeassistant/components/cloud/util.py | 15 - homeassistant/components/http/__init__.py | 96 +---- homeassistant/components/http/auth.py | 122 +------ homeassistant/components/http/const.py | 9 - homeassistant/components/http/icons.json | 5 - homeassistant/components/http/services.yaml | 1 - homeassistant/components/http/session.py | 160 --------- .../http/strict_connection_guard_page.html | 140 -------- homeassistant/components/http/strings.json | 16 - homeassistant/package_constraints.txt | 1 - pyproject.toml | 1 - requirements.txt | 1 - tests/components/cloud/test_client.py | 2 - tests/components/cloud/test_http_api.py | 5 - tests/components/cloud/test_init.py | 84 +---- tests/components/cloud/test_prefs.py | 43 +-- .../cloud/test_strict_connection.py | 294 ---------------- tests/components/http/test_auth.py | 329 ++---------------- tests/components/http/test_init.py | 79 ----- tests/components/http/test_session.py | 107 ------ tests/helpers/test_service.py | 5 +- tests/scripts/test_check_config.py | 2 - 32 files changed, 39 insertions(+), 1816 deletions(-) delete mode 100644 homeassistant/auth/session.py delete mode 100644 homeassistant/components/cloud/util.py delete mode 100644 homeassistant/components/http/icons.json delete mode 100644 homeassistant/components/http/services.yaml delete mode 100644 homeassistant/components/http/session.py delete mode 100644 homeassistant/components/http/strict_connection_guard_page.html delete mode 100644 homeassistant/components/http/strings.json delete mode 100644 tests/components/cloud/test_strict_connection.py delete mode 100644 tests/components/http/test_session.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2d0c98cdd14..24e34a2d555 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -28,7 +28,6 @@ from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRA from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .models import AuthFlowResult from .providers import AuthProvider, LoginFlow, auth_provider_from_config -from .session import SessionManager EVENT_USER_ADDED = "user_added" EVENT_USER_UPDATED = "user_updated" @@ -181,7 +180,6 @@ class AuthManager: self._remove_expired_job = HassJob( self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback ) - self.session = SessionManager(hass, self) async def async_setup(self) -> None: """Set up the auth manager.""" @@ -192,7 +190,6 @@ class AuthManager: ) ) self._async_track_next_refresh_token_expiration() - await self.session.async_setup() @property def auth_providers(self) -> list[AuthProvider]: diff --git a/homeassistant/auth/session.py b/homeassistant/auth/session.py deleted file mode 100644 index 88297b50d90..00000000000 --- a/homeassistant/auth/session.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Session auth module.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -import secrets -from typing import TYPE_CHECKING, Final, TypedDict - -from aiohttp.web import Request -from aiohttp_session import Session, get_session, new_session -from cryptography.fernet import Fernet - -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util - -from .models import RefreshToken - -if TYPE_CHECKING: - from . import AuthManager - - -TEMP_TIMEOUT = timedelta(minutes=5) -TEMP_TIMEOUT_SECONDS = TEMP_TIMEOUT.total_seconds() - -SESSION_ID = "id" -STORAGE_VERSION = 1 -STORAGE_KEY = "auth.session" - - -class StrictConnectionTempSessionData: - """Data for accessing unauthorized resources for a short period of time.""" - - __slots__ = ("cancel_remove", "absolute_expiry") - - def __init__(self, cancel_remove: CALLBACK_TYPE) -> None: - """Initialize the temp session data.""" - self.cancel_remove: Final[CALLBACK_TYPE] = cancel_remove - self.absolute_expiry: Final[datetime] = dt_util.utcnow() + TEMP_TIMEOUT - - -class StoreData(TypedDict): - """Data to store.""" - - unauthorized_sessions: dict[str, str] - key: str - - -class SessionManager: - """Session manager.""" - - def __init__(self, hass: HomeAssistant, auth: AuthManager) -> None: - """Initialize the strict connection manager.""" - self._auth = auth - self._hass = hass - self._temp_sessions: dict[str, StrictConnectionTempSessionData] = {} - self._strict_connection_sessions: dict[str, str] = {} - self._store = Store[StoreData]( - hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True - ) - self._key: str | None = None - self._refresh_token_revoke_callbacks: dict[str, CALLBACK_TYPE] = {} - - @property - def key(self) -> str: - """Return the encryption key.""" - if self._key is None: - self._key = Fernet.generate_key().decode() - self._async_schedule_save() - return self._key - - async def async_validate_request_for_strict_connection_session( - self, - request: Request, - ) -> bool: - """Check if a request has a valid strict connection session.""" - session = await get_session(request) - if session.new or session.empty: - return False - result = self.async_validate_strict_connection_session(session) - if result is False: - session.invalidate() - return result - - @callback - def async_validate_strict_connection_session( - self, - session: Session, - ) -> bool: - """Validate a strict connection session.""" - if not (session_id := session.get(SESSION_ID)): - return False - - if token_id := self._strict_connection_sessions.get(session_id): - if self._auth.async_get_refresh_token(token_id): - return True - # refresh token is invalid, delete entry - self._strict_connection_sessions.pop(session_id) - self._async_schedule_save() - - if data := self._temp_sessions.get(session_id): - if dt_util.utcnow() <= data.absolute_expiry: - return True - # session expired, delete entry - self._temp_sessions.pop(session_id).cancel_remove() - - return False - - @callback - def _async_register_revoke_token_callback(self, refresh_token_id: str) -> None: - """Register a callback to revoke all sessions for a refresh token.""" - if refresh_token_id in self._refresh_token_revoke_callbacks: - return - - @callback - def async_invalidate_auth_sessions() -> None: - """Invalidate all sessions for a refresh token.""" - self._strict_connection_sessions = { - session_id: token_id - for session_id, token_id in self._strict_connection_sessions.items() - if token_id != refresh_token_id - } - self._async_schedule_save() - - self._refresh_token_revoke_callbacks[refresh_token_id] = ( - self._auth.async_register_revoke_token_callback( - refresh_token_id, async_invalidate_auth_sessions - ) - ) - - async def async_create_session( - self, - request: Request, - refresh_token: RefreshToken, - ) -> None: - """Create new session for given refresh token. - - Caller needs to make sure that the refresh token is valid. - By creating a session, we are implicitly revoking all other - sessions for the given refresh token as there is one refresh - token per device/user case. - """ - self._strict_connection_sessions = { - session_id: token_id - for session_id, token_id in self._strict_connection_sessions.items() - if token_id != refresh_token.id - } - - self._async_register_revoke_token_callback(refresh_token.id) - session_id = await self._async_create_new_session(request) - self._strict_connection_sessions[session_id] = refresh_token.id - self._async_schedule_save() - - async def async_create_temp_unauthorized_session(self, request: Request) -> None: - """Create a temporary unauthorized session.""" - session_id = await self._async_create_new_session( - request, max_age=int(TEMP_TIMEOUT_SECONDS) - ) - - @callback - def remove(_: datetime) -> None: - self._temp_sessions.pop(session_id, None) - - self._temp_sessions[session_id] = StrictConnectionTempSessionData( - async_call_later(self._hass, TEMP_TIMEOUT_SECONDS, remove) - ) - - async def _async_create_new_session( - self, - request: Request, - *, - max_age: int | None = None, - ) -> str: - session_id = secrets.token_hex(64) - - session = await new_session(request) - session[SESSION_ID] = session_id - if max_age is not None: - session.max_age = max_age - return session_id - - @callback - def _async_schedule_save(self, delay: float = 1) -> None: - """Save sessions.""" - self._store.async_delay_save(self._data_to_save, delay) - - @callback - def _data_to_save(self) -> StoreData: - """Return the data to store.""" - return StoreData( - unauthorized_sessions=self._strict_connection_sessions, - key=self.key, - ) - - async def async_setup(self) -> None: - """Set up session manager.""" - data = await self._store.async_load() - if data is None: - return - - self._key = data["key"] - self._strict_connection_sessions = data["unauthorized_sessions"] - for token_id in self._strict_connection_sessions.values(): - self._async_register_revoke_token_callback(token_id) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 026935474f2..24c9cd249ce 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -162,7 +162,6 @@ from homeassistant.util import dt as dt_util from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" -STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token" type StoreResultType = Callable[[str, Credentials], str] type RetrieveResultType = Callable[[str, str], Credentials | None] @@ -188,7 +187,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(RevokeTokenView()) hass.http.register_view(LinkUserView(retrieve_result)) hass.http.register_view(OAuth2AuthorizeCallbackView()) - hass.http.register_view(StrictConnectionTempTokenView()) websocket_api.async_register_command(hass, websocket_current_user) websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) @@ -323,7 +321,6 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.FORBIDDEN, ) - await hass.auth.session.async_create_session(request, refresh_token) return self.json( { "access_token": access_token, @@ -392,7 +389,6 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.FORBIDDEN, ) - await hass.auth.session.async_create_session(request, refresh_token) return self.json( { "access_token": access_token, @@ -441,20 +437,6 @@ class LinkUserView(HomeAssistantView): return self.json_message("User linked") -class StrictConnectionTempTokenView(HomeAssistantView): - """View to get temporary strict connection token.""" - - url = STRICT_CONNECTION_URL - name = "api:auth:strict_connection:temp_token" - requires_auth = False - - async def get(self, request: web.Request) -> web.Response: - """Get a temporary token and redirect to main page.""" - hass = request.app[KEY_HASS] - await hass.auth.session.async_create_temp_unauthorized_session(request) - raise web.HTTPSeeOther(location="/") - - @callback def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]: """Create an in memory store.""" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2552fe4bf5c..cd8e5101e73 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -7,14 +7,11 @@ from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from enum import Enum from typing import cast -from urllib.parse import quote_plus, urljoin from hass_nabucasa import Cloud import voluptuous as vol -from homeassistant.components import alexa, google_assistant, http -from homeassistant.components.auth import STRICT_CONNECTION_URL -from homeassistant.components.http.auth import async_sign_path +from homeassistant.components import alexa, google_assistant from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( CONF_DESCRIPTION, @@ -24,21 +21,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import ( - Event, - HassJob, - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) -from homeassistant.exceptions import ( - HomeAssistantError, - ServiceValidationError, - Unauthorized, - UnknownUser, -) +from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform @@ -47,7 +31,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -418,50 +401,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: async_register_admin_service( hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - - async def create_temporary_strict_connection_url( - call: ServiceCall, - ) -> ServiceResponse: - """Create a strict connection url and return it.""" - # Copied form homeassistant/helpers/service.py#_async_admin_handler - # as the helper supports no responses yet - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - if not user.is_admin: - raise Unauthorized(context=call.context) - - if prefs.strict_connection is http.const.StrictConnectionMode.DISABLED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="strict_connection_not_enabled", - ) - - try: - url = get_url(hass, require_cloud=True) - except NoURLAvailableError as ex: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_url_available", - ) from ex - - path = async_sign_path( - hass, - STRICT_CONNECTION_URL, - timedelta(hours=1), - use_content_user=True, - ) - url = urljoin(url, path) - - return { - "url": f"https://login.home-assistant.io?u={quote_plus(url)}", - "direct_url": url, - } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c4d1c1dec60..01c8de77156 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -250,7 +250,6 @@ class CloudClient(Interface): "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, "alias": self.cloud.remote.alias, - "strict_connection": self._prefs.strict_connection, }, "version": HA_VERSION, "instance_id": self.prefs.instance_id, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 8b68eefc443..2c58dd57340 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -33,7 +33,6 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" -PREF_STRICT_CONNECTION = "strict_connection" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e14ee7da7c2..757bd27e212 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -19,7 +19,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import TTS_VOICES import voluptuous as vol -from homeassistant.components import http, websocket_api +from homeassistant.components import websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, @@ -46,7 +46,6 @@ from .const import ( PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_REMOTE_ALLOW_REMOTE_ENABLE, - PREF_STRICT_CONNECTION, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) @@ -449,9 +448,6 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: vol.Coerce(tuple), validate_language_voice ), vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, - vol.Optional(PREF_STRICT_CONNECTION): vol.Coerce( - http.const.StrictConnectionMode - ), } ) @websocket_api.async_response diff --git a/homeassistant/components/cloud/icons.json b/homeassistant/components/cloud/icons.json index 1a8593388b4..06ee7eb2f19 100644 --- a/homeassistant/components/cloud/icons.json +++ b/homeassistant/components/cloud/icons.json @@ -1,6 +1,5 @@ { "services": { - "create_temporary_strict_connection_url": "mdi:login-variant", "remote_connect": "mdi:cloud", "remote_disconnect": "mdi:cloud-off" } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 72207513ca9..af4e68194d6 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -10,7 +10,7 @@ from hass_nabucasa.voice import MAP_VOICE from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User -from homeassistant.components import http, webhook +from homeassistant.components import webhook from homeassistant.components.google_assistant.http import ( async_get_users as async_get_google_assistant_users, ) @@ -44,7 +44,6 @@ from .const import ( PREF_INSTANCE_ID, PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, - PREF_STRICT_CONNECTION, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) @@ -177,7 +176,6 @@ class CloudPreferences: google_settings_version: int | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, - strict_connection: http.const.StrictConnectionMode | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -197,7 +195,6 @@ class CloudPreferences: (PREF_REMOTE_DOMAIN, remote_domain), (PREF_GOOGLE_CONNECTED, google_connected), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), - (PREF_STRICT_CONNECTION, strict_connection), ): if value is not UNDEFINED: prefs[key] = value @@ -245,7 +242,6 @@ class CloudPreferences: PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, - PREF_STRICT_CONNECTION: self.strict_connection, } @property @@ -362,20 +358,6 @@ class CloudPreferences: """ return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] - @property - def strict_connection(self) -> http.const.StrictConnectionMode: - """Return the strict connection mode.""" - mode = self._prefs.get(PREF_STRICT_CONNECTION) - - if mode is None: - # Set to default value - # We store None in the store as the default value to detect if the user has changed the - # value or not. - mode = http.const.StrictConnectionMode.DISABLED - elif not isinstance(mode, http.const.StrictConnectionMode): - mode = http.const.StrictConnectionMode(mode) - return mode - async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" user = await self._load_cloud_user() @@ -433,5 +415,4 @@ class CloudPreferences: PREF_REMOTE_DOMAIN: None, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, - PREF_STRICT_CONNECTION: None, } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 1fec87235da..16a82a27c1a 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -5,14 +5,6 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, - "exceptions": { - "strict_connection_not_enabled": { - "message": "Strict connection is not enabled for cloud requests" - }, - "no_url_available": { - "message": "No cloud URL available.\nPlease mark sure you have a working Remote UI." - } - }, "system_health": { "info": { "can_reach_cert_server": "Reach Certificate Server", @@ -81,10 +73,6 @@ } }, "services": { - "create_temporary_strict_connection_url": { - "name": "Create a temporary strict connection URL", - "description": "Create a temporary strict connection URL, which can be used to login on another device." - }, "remote_connect": { "name": "Remote connect", "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud." diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py deleted file mode 100644 index 3e055851fff..00000000000 --- a/homeassistant/components/cloud/util.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Cloud util functions.""" - -from hass_nabucasa import Cloud - -from homeassistant.components import http -from homeassistant.core import HomeAssistant - -from .client import CloudClient -from .const import DOMAIN - - -def get_strict_connection_mode(hass: HomeAssistant) -> http.const.StrictConnectionMode: - """Get the strict connection mode.""" - cloud: Cloud[CloudClient] = hass.data[DOMAIN] - return cloud.client.prefs.strict_connection diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 0a41848b27e..b48e9f9615c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -10,8 +10,7 @@ import os import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, Required, TypedDict, cast -from urllib.parse import quote_plus, urljoin +from typing import Any, Final, TypedDict, cast from aiohttp import web from aiohttp.abc import AbstractStreamWriter @@ -30,20 +29,8 @@ from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT -from homeassistant.core import ( - Event, - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) -from homeassistant.exceptions import ( - HomeAssistantError, - ServiceValidationError, - Unauthorized, - UnknownUser, -) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv from homeassistant.helpers.http import ( @@ -66,14 +53,9 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.async_ import create_eager_task from homeassistant.util.json import json_loads -from .auth import async_setup_auth, async_sign_path +from .auth import async_setup_auth from .ban import setup_bans -from .const import ( # noqa: F401 - DOMAIN, - KEY_HASS_REFRESH_TOKEN_ID, - KEY_HASS_USER, - StrictConnectionMode, -) +from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded @@ -96,7 +78,6 @@ CONF_TRUSTED_PROXIES: Final = "trusted_proxies" CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" CONF_SSL_PROFILE: Final = "ssl_profile" -CONF_STRICT_CONNECTION: Final = "strict_connection" SSL_MODERN: Final = "modern" SSL_INTERMEDIATE: Final = "intermediate" @@ -146,9 +127,6 @@ HTTP_SCHEMA: Final = vol.All( [SSL_INTERMEDIATE, SSL_MODERN] ), vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, - vol.Optional( - CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED - ): vol.Coerce(StrictConnectionMode), } ), ) @@ -172,7 +150,6 @@ class ConfData(TypedDict, total=False): login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str - strict_connection: Required[StrictConnectionMode] @bind_hass @@ -241,7 +218,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, use_x_frame_options=use_x_frame_options, - strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION], ) async def stop_server(event: Event) -> None: @@ -271,7 +247,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: local_ip, host, server_port, ssl_certificate is not None ) - _setup_services(hass, conf) return True @@ -356,7 +331,6 @@ class HomeAssistantHTTP: login_threshold: int, is_ban_enabled: bool, use_x_frame_options: bool, - strict_connection_non_cloud: StrictConnectionMode, ) -> None: """Initialize the server.""" self.app[KEY_HASS] = self.hass @@ -373,7 +347,7 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(self.hass, self.app, login_threshold) - await async_setup_auth(self.hass, self.app, strict_connection_non_cloud) + await async_setup_auth(self.hass, self.app) setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) @@ -602,61 +576,3 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) - - -@callback -def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: - """Set up services for HTTP component.""" - - async def create_temporary_strict_connection_url( - call: ServiceCall, - ) -> ServiceResponse: - """Create a strict connection url and return it.""" - # Copied form homeassistant/helpers/service.py#_async_admin_handler - # as the helper supports no responses yet - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - if not user.is_admin: - raise Unauthorized(context=call.context) - - if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="strict_connection_not_enabled_non_cloud", - ) - - try: - url = get_url( - hass, prefer_external=True, allow_internal=False, allow_cloud=False - ) - except NoURLAvailableError as ex: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_external_url_available", - ) from ex - - # to avoid circular import - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.auth import STRICT_CONNECTION_URL - - path = async_sign_path( - hass, - STRICT_CONNECTION_URL, - datetime.timedelta(hours=1), - use_content_user=True, - ) - url = urljoin(url, path) - - return { - "url": f"https://login.home-assistant.io?u={quote_plus(url)}", - "direct_url": url, - } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 58dae21d2a6..0f43aac0115 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -4,18 +4,14 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from datetime import timedelta -from http import HTTPStatus from ipaddress import ip_address import logging -import os import secrets import time from typing import Any, Final from aiohttp import hdrs -from aiohttp.web import Application, Request, Response, StreamResponse, middleware -from aiohttp.web_exceptions import HTTPBadRequest -from aiohttp_session import session_middleware +from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from jwt import api_jws from yarl import URL @@ -25,21 +21,13 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import singleton from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local -from .const import ( - DOMAIN, - KEY_AUTHENTICATED, - KEY_HASS_REFRESH_TOKEN_ID, - KEY_HASS_USER, - StrictConnectionMode, -) -from .session import HomeAssistantCookieStorage +from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER _LOGGER = logging.getLogger(__name__) @@ -51,11 +39,6 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"] STORAGE_VERSION = 1 STORAGE_KEY = "http.auth" CONTENT_USER_NAME = "Home Assistant Content" -STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/" -STRICT_CONNECTION_GUARD_PAGE_NAME = "strict_connection_guard_page.html" -STRICT_CONNECTION_GUARD_PAGE = os.path.join( - os.path.dirname(__file__), STRICT_CONNECTION_GUARD_PAGE_NAME -) @callback @@ -137,7 +120,6 @@ def async_user_not_allowed_do_auth( async def async_setup_auth( hass: HomeAssistant, app: Application, - strict_connection_mode_non_cloud: StrictConnectionMode, ) -> None: """Create auth middleware for the app.""" store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) @@ -160,10 +142,6 @@ async def async_setup_auth( hass.data[STORAGE_KEY] = refresh_token.id - if strict_connection_mode_non_cloud is StrictConnectionMode.GUARD_PAGE: - # Load the guard page content on setup - await _read_strict_connection_guard_page(hass) - @callback def async_validate_auth_header(request: Request) -> bool: """Test authorization header against access token. @@ -252,37 +230,6 @@ async def async_setup_auth( authenticated = True auth_type = "signed request" - if not authenticated and not request.path.startswith( - STRICT_CONNECTION_EXCLUDED_PATH - ): - strict_connection_mode = strict_connection_mode_non_cloud - strict_connection_func = ( - _async_perform_strict_connection_action_on_non_local - ) - if is_cloud_connection(hass): - from homeassistant.components.cloud.util import ( # pylint: disable=import-outside-toplevel - get_strict_connection_mode, - ) - - strict_connection_mode = get_strict_connection_mode(hass) - strict_connection_func = _async_perform_strict_connection_action - - if ( - strict_connection_mode is not StrictConnectionMode.DISABLED - and not await hass.auth.session.async_validate_request_for_strict_connection_session( - request - ) - and ( - resp := await strict_connection_func( - hass, - request, - strict_connection_mode is StrictConnectionMode.GUARD_PAGE, - ) - ) - is not None - ): - return resp - if authenticated and _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Authenticated %s for %s using %s", @@ -294,69 +241,4 @@ async def async_setup_auth( request[KEY_AUTHENTICATED] = authenticated return await handler(request) - app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass))) app.middlewares.append(auth_middleware) - - -async def _async_perform_strict_connection_action_on_non_local( - hass: HomeAssistant, - request: Request, - guard_page: bool, -) -> StreamResponse | None: - """Perform strict connection mode action if the request is not local. - - The function does the following: - - Try to get the IP address of the request. If it fails, assume it's not local - - If the request is local, return None (allow the request to continue) - - If guard_page is True, return a response with the content - - Otherwise close the connection and raise an exception - """ - try: - ip_address_ = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - _LOGGER.debug("Invalid IP address: %s", request.remote) - ip_address_ = None - - if ip_address_ and is_local(ip_address_): - return None - - return await _async_perform_strict_connection_action(hass, request, guard_page) - - -async def _async_perform_strict_connection_action( - hass: HomeAssistant, - request: Request, - guard_page: bool, -) -> StreamResponse | None: - """Perform strict connection mode action. - - The function does the following: - - If guard_page is True, return a response with the content - - Otherwise close the connection and raise an exception - """ - - _LOGGER.debug("Perform strict connection action for %s", request.remote) - if guard_page: - return Response( - text=await _read_strict_connection_guard_page(hass), - content_type="text/html", - status=HTTPStatus.IM_A_TEAPOT, - ) - - if transport := request.transport: - # it should never happen that we don't have a transport - transport.close() - - # We need to raise an exception to stop processing the request - raise HTTPBadRequest - - -@singleton.singleton(f"{DOMAIN}_{STRICT_CONNECTION_GUARD_PAGE_NAME}") -async def _read_strict_connection_guard_page(hass: HomeAssistant) -> str: - """Read the strict connection guard page from disk via executor.""" - - def read_guard_page() -> str: - with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file: - return file.read() - - return await hass.async_add_executor_job(read_guard_page) diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 4a15e310b11..1a5d7a603d7 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,6 +1,5 @@ """HTTP specific constants.""" -from enum import StrEnum from typing import Final from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 @@ -9,11 +8,3 @@ DOMAIN: Final = "http" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" - - -class StrictConnectionMode(StrEnum): - """Enum for strict connection mode.""" - - DISABLED = "disabled" - GUARD_PAGE = "guard_page" - DROP_CONNECTION = "drop_connection" diff --git a/homeassistant/components/http/icons.json b/homeassistant/components/http/icons.json deleted file mode 100644 index 8e8b6285db7..00000000000 --- a/homeassistant/components/http/icons.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "services": { - "create_temporary_strict_connection_url": "mdi:login-variant" - } -} diff --git a/homeassistant/components/http/services.yaml b/homeassistant/components/http/services.yaml deleted file mode 100644 index 16b0debb144..00000000000 --- a/homeassistant/components/http/services.yaml +++ /dev/null @@ -1 +0,0 @@ -create_temporary_strict_connection_url: ~ diff --git a/homeassistant/components/http/session.py b/homeassistant/components/http/session.py deleted file mode 100644 index 81668ec2ccc..00000000000 --- a/homeassistant/components/http/session.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Session http module.""" - -from functools import lru_cache -import logging - -from aiohttp.web import Request, StreamResponse -from aiohttp_session import Session, SessionData -from aiohttp_session.cookie_storage import EncryptedCookieStorage -from cryptography.fernet import InvalidToken - -from homeassistant.auth.const import REFRESH_TOKEN_EXPIRATION -from homeassistant.core import HomeAssistant -from homeassistant.helpers.json import json_dumps -from homeassistant.helpers.network import is_cloud_connection -from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads - -from .ban import process_wrong_login - -_LOGGER = logging.getLogger(__name__) - -COOKIE_NAME = "SC" -PREFIXED_COOKIE_NAME = f"__Host-{COOKIE_NAME}" -SESSION_CACHE_SIZE = 16 - - -def _get_cookie_name(is_secure: bool) -> str: - """Return the cookie name.""" - return PREFIXED_COOKIE_NAME if is_secure else COOKIE_NAME - - -class HomeAssistantCookieStorage(EncryptedCookieStorage): - """Home Assistant cookie storage. - - Own class is required: - - to set the secure flag based on the connection type - - to use a LRU cache for session decryption - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the cookie storage.""" - super().__init__( - hass.auth.session.key, - cookie_name=PREFIXED_COOKIE_NAME, - max_age=int(REFRESH_TOKEN_EXPIRATION), - httponly=True, - samesite="Lax", - secure=True, - encoder=json_dumps, - decoder=json_loads, - ) - self._hass = hass - - def _secure_connection(self, request: Request) -> bool: - """Return if the connection is secure (https).""" - return is_cloud_connection(self._hass) or request.secure - - def load_cookie(self, request: Request) -> str | None: - """Load cookie.""" - is_secure = self._secure_connection(request) - cookie_name = _get_cookie_name(is_secure) - return request.cookies.get(cookie_name) - - @lru_cache(maxsize=SESSION_CACHE_SIZE) - def _decrypt_cookie(self, cookie: str) -> Session | None: - """Decrypt and validate cookie.""" - try: - data = SessionData( # type: ignore[misc] - self._decoder( - self._fernet.decrypt( - cookie.encode("utf-8"), ttl=self.max_age - ).decode("utf-8") - ) - ) - except (InvalidToken, TypeError, ValueError, *JSON_DECODE_EXCEPTIONS): - _LOGGER.warning("Cannot decrypt/parse cookie value") - return None - - session = Session(None, data=data, new=data is None, max_age=self.max_age) - - # Validate session if not empty - if ( - not session.empty - and not self._hass.auth.session.async_validate_strict_connection_session( - session - ) - ): - # Invalidate session as it is not valid - session.invalidate() - - return session - - async def new_session(self) -> Session: - """Create a new session and mark it as changed.""" - session = Session(None, data=None, new=True, max_age=self.max_age) - session.changed() - return session - - async def load_session(self, request: Request) -> Session: - """Load session.""" - # Split parent function to use lru_cache - if (cookie := self.load_cookie(request)) is None: - return await self.new_session() - - if (session := self._decrypt_cookie(cookie)) is None: - # Decrypting/parsing failed, log wrong login and create a new session - await process_wrong_login(request) - session = await self.new_session() - - return session - - async def save_session( - self, request: Request, response: StreamResponse, session: Session - ) -> None: - """Save session.""" - - is_secure = self._secure_connection(request) - cookie_name = _get_cookie_name(is_secure) - - if session.empty: - response.del_cookie(cookie_name) - else: - params = self.cookie_params.copy() - params["secure"] = is_secure - params["max_age"] = session.max_age - - cookie_data = self._encoder(self._get_session_data(session)).encode("utf-8") - response.set_cookie( - cookie_name, - self._fernet.encrypt(cookie_data).decode("utf-8"), - **params, - ) - # Add Cache-Control header to not cache the cookie as it - # is used for session management - self._add_cache_control_header(response) - - @staticmethod - def _add_cache_control_header(response: StreamResponse) -> None: - """Add/set cache control header to no-cache="Set-Cookie".""" - # Structure of the Cache-Control header defined in - # https://datatracker.ietf.org/doc/html/rfc2068#section-14.9 - if header := response.headers.get("Cache-Control"): - directives = [] - for directive in header.split(","): - directive = directive.strip() - directive_lowered = directive.lower() - if directive_lowered.startswith("no-cache"): - if "set-cookie" in directive_lowered or directive.find("=") == -1: - # Set-Cookie is already in the no-cache directive or - # the whole request should not be cached -> Nothing to do - return - - # Add Set-Cookie to the no-cache - # [:-1] to remove the " at the end of the directive - directive = f"{directive[:-1]}, Set-Cookie" - - directives.append(directive) - header = ", ".join(directives) - else: - header = 'no-cache="Set-Cookie"' - response.headers["Cache-Control"] = header diff --git a/homeassistant/components/http/strict_connection_guard_page.html b/homeassistant/components/http/strict_connection_guard_page.html deleted file mode 100644 index 8567e500c9d..00000000000 --- a/homeassistant/components/http/strict_connection_guard_page.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - Home Assistant - Access denied - - - - -
- - - - -
-
-

You need access

-

- This device is not known to - Home Assistant. -

- - - Learn how to get access - -
- - diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json deleted file mode 100644 index 7cd64f5f297..00000000000 --- a/homeassistant/components/http/strings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "exceptions": { - "strict_connection_not_enabled_non_cloud": { - "message": "Strict connection is not enabled for non-cloud requests" - }, - "no_external_url_available": { - "message": "No external URL available" - } - }, - "services": { - "create_temporary_strict_connection_url": { - "name": "Create a temporary strict connection URL", - "description": "Create a temporary strict connection URL, which can be used to login on another device." - } - } -} diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a2c687e7da5..113a4b551b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,7 +7,6 @@ aiohttp-fast-url-dispatcher==0.3.0 aiohttp-fast-zlib==0.1.0 aiohttp==3.9.5 aiohttp_cors==0.7.0 -aiohttp_session==2.12.0 aiozoneinfo==0.1.0 astral==2.2 async-interrupt==1.1.1 diff --git a/pyproject.toml b/pyproject.toml index e2ea752cc83..d52b605393b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "aiodns==3.2.0", "aiohttp==3.9.5", "aiohttp_cors==0.7.0", - "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-fast-zlib==0.1.0", "aiozoneinfo==0.1.0", diff --git a/requirements.txt b/requirements.txt index d34f022526c..d77962d64d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,6 @@ aiodns==3.2.0 aiohttp==3.9.5 aiohttp_cors==0.7.0 -aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-fast-zlib==0.1.0 aiozoneinfo==0.1.0 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index bcddc32f107..5e15aa32b6f 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -24,7 +24,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( ExposedEntities, async_expose_entity, ) -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.const import CONTENT_TYPE_JSON, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er @@ -388,7 +387,6 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "connected": False, "enabled": False, "instance_domain": None, - "strict_connection": StrictConnectionMode.DISABLED, }, "version": HA_VERSION, } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d9d2b5c6742..5ee9af88681 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -19,7 +19,6 @@ from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er @@ -783,7 +782,6 @@ async def test_websocket_status( "google_report_state": True, "remote_allow_remote_enable": True, "remote_enabled": False, - "strict_connection": "disabled", "tts_default_voice": ["en-US", "JennyNeural"], }, "alexa_entities": { @@ -903,7 +901,6 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin is None assert cloud.client.prefs.remote_allow_remote_enable is True - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DISABLED client = await hass_ws_client(hass) @@ -915,7 +912,6 @@ async def test_websocket_update_preferences( "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, - "strict_connection": StrictConnectionMode.DROP_CONNECTION, } ) response = await client.receive_json() @@ -926,7 +922,6 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DROP_CONNECTION @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index bc4526975da..9cc1324ebc1 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import MagicMock, patch -from urllib.parse import quote_plus from hass_nabucasa import Cloud import pytest @@ -14,16 +13,11 @@ from homeassistant.components.cloud import ( CloudNotConnected, async_get_or_create_cloudhook, ) -from homeassistant.components.cloud.const import ( - DOMAIN, - PREF_CLOUDHOOKS, - PREF_STRICT_CONNECTION, -) +from homeassistant.components.cloud.const import DOMAIN, PREF_CLOUDHOOKS from homeassistant.components.cloud.prefs import STORAGE_KEY -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import ServiceValidationError, Unauthorized +from homeassistant.exceptions import Unauthorized from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockUser @@ -301,77 +295,3 @@ async def test_cloud_logout( await hass.async_block_till_done() assert cloud.is_logged_in is False - - -async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( - hass: HomeAssistant, -) -> None: - """Test service create_temporary_strict_connection_url with strict_connection not enabled.""" - mock_config_entry = MockConfigEntry(domain=DOMAIN) - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) - await hass.async_block_till_done() - with pytest.raises( - ServiceValidationError, - match="Strict connection is not enabled for cloud requests", - ): - await hass.services.async_call( - cloud.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - -@pytest.mark.parametrize( - ("mode"), - [ - StrictConnectionMode.DROP_CONNECTION, - StrictConnectionMode.GUARD_PAGE, - ], -) -async def test_service_create_temporary_strict_connection( - hass: HomeAssistant, - set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - mode: StrictConnectionMode, -) -> None: - """Test service create_temporary_strict_connection_url.""" - mock_config_entry = MockConfigEntry(domain=DOMAIN) - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) - await hass.async_block_till_done() - - await set_cloud_prefs( - { - PREF_STRICT_CONNECTION: mode, - } - ) - - # No cloud url set - with pytest.raises(ServiceValidationError, match="No cloud URL available"): - await hass.services.async_call( - cloud.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - # Patch cloud url - url = "https://example.com" - with patch( - "homeassistant.helpers.network._get_cloud_url", - return_value=url, - ): - response = await hass.services.async_call( - cloud.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - assert isinstance(response, dict) - direct_url_prefix = f"{url}/auth/strict_connection/temp_token?authSig=" - assert response.pop("direct_url").startswith(direct_url_prefix) - assert response.pop("url").startswith( - f"https://login.home-assistant.io?u={quote_plus(direct_url_prefix)}" - ) - assert response == {} # No more keys in response diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 57715fe2bdf..9b0fa4c01d7 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -6,13 +6,8 @@ from unittest.mock import ANY, MagicMock, patch import pytest from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components.cloud.const import ( - DOMAIN, - PREF_STRICT_CONNECTION, - PREF_TTS_DEFAULT_VOICE, -) +from homeassistant.components.cloud.const import DOMAIN, PREF_TTS_DEFAULT_VOICE from homeassistant.components.cloud.prefs import STORAGE_KEY, CloudPreferences -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -179,39 +174,3 @@ async def test_tts_default_voice_legacy_gender( await hass.async_block_till_done() assert cloud.client.prefs.tts_default_voice == (expected_language, voice) - - -@pytest.mark.parametrize("mode", list(StrictConnectionMode)) -async def test_strict_connection_convertion( - hass: HomeAssistant, - cloud: MagicMock, - hass_storage: dict[str, Any], - mode: StrictConnectionMode, -) -> None: - """Test strict connection string value will be converted to the enum.""" - hass_storage[STORAGE_KEY] = { - "version": 1, - "data": {PREF_STRICT_CONNECTION: mode.value}, - } - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - - assert cloud.client.prefs.strict_connection is mode - - -@pytest.mark.parametrize("storage_data", [{}, {PREF_STRICT_CONNECTION: None}]) -async def test_strict_connection_default( - hass: HomeAssistant, - cloud: MagicMock, - hass_storage: dict[str, Any], - storage_data: dict[str, Any], -) -> None: - """Test strict connection default values.""" - hass_storage[STORAGE_KEY] = { - "version": 1, - "data": storage_data, - } - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DISABLED diff --git a/tests/components/cloud/test_strict_connection.py b/tests/components/cloud/test_strict_connection.py deleted file mode 100644 index 2205e785a7a..00000000000 --- a/tests/components/cloud/test_strict_connection.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Test strict connection mode for cloud.""" - -from collections.abc import Awaitable, Callable, Coroutine, Generator -from contextlib import contextmanager -from datetime import timedelta -from http import HTTPStatus -from typing import Any -from unittest.mock import MagicMock, Mock, patch - -from aiohttp import ServerDisconnectedError, web -from aiohttp.test_utils import TestClient -from aiohttp_session import get_session -import pytest -from yarl import URL - -from homeassistant.auth.models import RefreshToken -from homeassistant.auth.session import SESSION_ID, TEMP_TIMEOUT -from homeassistant.components.cloud.const import PREF_STRICT_CONNECTION -from homeassistant.components.http import KEY_HASS -from homeassistant.components.http.auth import ( - STRICT_CONNECTION_GUARD_PAGE, - async_setup_auth, - async_sign_path, -) -from homeassistant.components.http.const import KEY_AUTHENTICATED, StrictConnectionMode -from homeassistant.components.http.session import COOKIE_NAME, PREFIXED_COOKIE_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.network import is_cloud_connection -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow - -from tests.common import async_fire_time_changed -from tests.typing import ClientSessionGenerator - - -@pytest.fixture -async def refresh_token(hass: HomeAssistant, hass_access_token: str) -> RefreshToken: - """Return a refresh token.""" - refresh_token = hass.auth.async_validate_access_token(hass_access_token) - assert refresh_token - session = hass.auth.session - assert session._strict_connection_sessions == {} - assert session._temp_sessions == {} - return refresh_token - - -@contextmanager -def simulate_cloud_request() -> Generator[None, None, None]: - """Simulate a cloud request.""" - with patch( - "hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True)) - ): - yield - - -@pytest.fixture -def app_strict_connection( - hass: HomeAssistant, refresh_token: RefreshToken -) -> web.Application: - """Fixture to set up a web.Application.""" - - async def handler(request): - """Return if request was authenticated.""" - return web.json_response(data={"authenticated": request[KEY_AUTHENTICATED]}) - - app = web.Application() - app[KEY_HASS] = hass - app.router.add_get("/", handler) - - async def set_cookie(request: web.Request) -> web.Response: - hass = request.app[KEY_HASS] - # Clear all sessions - hass.auth.session._temp_sessions.clear() - hass.auth.session._strict_connection_sessions.clear() - - if request.query["token"] == "refresh": - await hass.auth.session.async_create_session(request, refresh_token) - else: - await hass.auth.session.async_create_temp_unauthorized_session(request) - session = await get_session(request) - return web.Response(text=session[SESSION_ID]) - - app.router.add_get("/test/cookie", set_cookie) - return app - - -@pytest.fixture(name="client") -async def set_up_fixture( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - app_strict_connection: web.Application, - cloud: MagicMock, - socket_enabled: None, -) -> TestClient: - """Set up the fixture.""" - - await async_setup_auth(hass, app_strict_connection, StrictConnectionMode.DISABLED) - assert await async_setup_component(hass, "cloud", {"cloud": {}}) - await hass.async_block_till_done() - return await aiohttp_client(app_strict_connection) - - -@pytest.mark.parametrize( - "strict_connection_mode", [e.value for e in StrictConnectionMode] -) -async def test_strict_connection_cloud_authenticated_requests( - hass: HomeAssistant, - client: TestClient, - hass_access_token: str, - set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - refresh_token: RefreshToken, - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test authenticated requests with strict connection.""" - assert hass.auth.session._strict_connection_sessions == {} - - signed_path = async_sign_path( - hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id - ) - - await set_cloud_prefs( - { - PREF_STRICT_CONNECTION: strict_connection_mode, - } - ) - - with simulate_cloud_request(): - assert is_cloud_connection(hass) - req = await client.get( - "/", headers={"Authorization": f"Bearer {hass_access_token}"} - ) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - - -async def _test_strict_connection_cloud_enabled_external_unauthenticated_requests( - hass: HomeAssistant, - client: TestClient, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - _: RefreshToken, -) -> None: - """Test external unauthenticated requests with strict connection cloud enabled.""" - with simulate_cloud_request(): - assert is_cloud_connection(hass) - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_cloud_enabled_external_unauthenticated_requests_refresh_token( - hass: HomeAssistant, - client: TestClient, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - refresh_token: RefreshToken, -) -> None: - """Test external unauthenticated requests with strict connection cloud enabled and refresh token cookie.""" - session = hass.auth.session - - # set strict connection cookie with refresh token - session_id = await _modify_cookie_for_cloud(client, "refresh") - assert session._strict_connection_sessions == {session_id: refresh_token.id} - with simulate_cloud_request(): - assert is_cloud_connection(hass) - req = await client.get("/") - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": False} - - # Invalidate refresh token, which should also invalidate session - hass.auth.async_remove_refresh_token(refresh_token) - assert session._strict_connection_sessions == {} - - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_cloud_enabled_external_unauthenticated_requests_temp_session( - hass: HomeAssistant, - client: TestClient, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - _: RefreshToken, -) -> None: - """Test external unauthenticated requests with strict connection cloud enabled and temp cookie.""" - session = hass.auth.session - - # set strict connection cookie with temp session - assert session._temp_sessions == {} - session_id = await _modify_cookie_for_cloud(client, "temp") - assert session_id in session._temp_sessions - with simulate_cloud_request(): - assert is_cloud_connection(hass) - resp = await client.get("/") - assert resp.status == HTTPStatus.OK - assert await resp.json() == {"authenticated": False} - - async_fire_time_changed(hass, utcnow() + TEMP_TIMEOUT + timedelta(minutes=1)) - await hass.async_block_till_done(wait_background_tasks=True) - assert session._temp_sessions == {} - - await perform_unauthenticated_request(hass, client) - - -async def _drop_connection_unauthorized_request( - _: HomeAssistant, client: TestClient -) -> None: - with pytest.raises(ServerDisconnectedError): - # unauthorized requests should raise ServerDisconnectedError - await client.get("/") - - -async def _guard_page_unauthorized_request( - hass: HomeAssistant, client: TestClient -) -> None: - req = await client.get("/") - assert req.status == HTTPStatus.IM_A_TEAPOT - - def read_guard_page() -> str: - with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file: - return file.read() - - assert await req.text() == await hass.async_add_executor_job(read_guard_page) - - -@pytest.mark.parametrize( - "test_func", - [ - _test_strict_connection_cloud_enabled_external_unauthenticated_requests, - _test_strict_connection_cloud_enabled_external_unauthenticated_requests_refresh_token, - _test_strict_connection_cloud_enabled_external_unauthenticated_requests_temp_session, - ], - ids=[ - "no cookie", - "refresh token cookie", - "temp session cookie", - ], -) -@pytest.mark.parametrize( - ("strict_connection_mode", "request_func"), - [ - (StrictConnectionMode.DROP_CONNECTION, _drop_connection_unauthorized_request), - (StrictConnectionMode.GUARD_PAGE, _guard_page_unauthorized_request), - ], - ids=["drop connection", "static page"], -) -async def test_strict_connection_cloud_external_unauthenticated_requests( - hass: HomeAssistant, - client: TestClient, - refresh_token: RefreshToken, - set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - test_func: Callable[ - [ - HomeAssistant, - TestClient, - Callable[[HomeAssistant, TestClient], Awaitable[None]], - RefreshToken, - ], - Awaitable[None], - ], - strict_connection_mode: StrictConnectionMode, - request_func: Callable[[HomeAssistant, TestClient], Awaitable[None]], -) -> None: - """Test external unauthenticated requests with strict connection cloud.""" - await set_cloud_prefs( - { - PREF_STRICT_CONNECTION: strict_connection_mode, - } - ) - - await test_func( - hass, - client, - request_func, - refresh_token, - ) - - -async def _modify_cookie_for_cloud(client: TestClient, token_type: str) -> str: - """Modify cookie for cloud.""" - # Cloud cookie has set secure=true and will not set on insecure connection - # As we test with insecure connection, we need to set it manually - # We get the session via http and modify the cookie name to the secure one - session_id = await (await client.get(f"/test/cookie?token={token_type}")).text() - cookie_jar = client.session.cookie_jar - localhost = URL("http://127.0.0.1") - cookie = cookie_jar.filter_cookies(localhost)[COOKIE_NAME].value - assert cookie - cookie_jar.clear() - cookie_jar.update_cookies({PREFIXED_COOKIE_NAME: cookie}, localhost) - return session_id diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index afff8294f0c..aa6ed64ff57 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,28 +1,23 @@ """The tests for the Home Assistant HTTP component.""" -from collections.abc import Awaitable, Callable from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging from unittest.mock import Mock, patch -from aiohttp import BasicAuth, ServerDisconnectedError, web -from aiohttp.test_utils import TestClient +from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from aiohttp_session import get_session import jwt import pytest import yarl -from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY -from homeassistant.auth.models import RefreshToken, User +from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) -from homeassistant.auth.session import SESSION_ID, TEMP_TIMEOUT from homeassistant.components import websocket_api from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( @@ -30,12 +25,11 @@ from homeassistant.components.http.auth import ( DATA_SIGN_SECRET, SIGN_QUERY_PARAM, STORAGE_KEY, - STRICT_CONNECTION_GUARD_PAGE, async_setup_auth, async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED, StrictConnectionMode +from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, @@ -43,11 +37,10 @@ from homeassistant.components.http.request_context import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from . import HTTP_HEADER_HA_AUTH -from tests.common import MockUser, async_fire_time_changed +from tests.common import MockUser from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -137,7 +130,7 @@ async def test_cant_access_with_password_in_header( hass: HomeAssistant, ) -> None: """Test access with password in header.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -154,7 +147,7 @@ async def test_cant_access_with_password_in_query( hass: HomeAssistant, ) -> None: """Test access with password in URL.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) resp = await client.get("/", params={"api_password": API_PASSWORD}) @@ -174,7 +167,7 @@ async def test_basic_auth_does_not_work( legacy_auth: LegacyApiPasswordAuthProvider, ) -> None: """Test access with basic authentication.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", auth=BasicAuth("homeassistant", API_PASSWORD)) @@ -198,7 +191,7 @@ async def test_cannot_access_with_trusted_ip( hass_owner_user: MockUser, ) -> None: """Test access with an untrusted ip address.""" - await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -226,7 +219,7 @@ async def test_auth_active_access_with_access_token_in_header( ) -> None: """Test access with access token in header.""" token = hass_access_token - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -262,7 +255,7 @@ async def test_auth_active_access_with_trusted_ip( hass_owner_user: MockUser, ) -> None: """Test access with an untrusted ip address.""" - await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -289,7 +282,7 @@ async def test_auth_legacy_support_api_password_cannot_access( hass: HomeAssistant, ) -> None: """Test access using api_password if auth.support_legacy.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -311,7 +304,7 @@ async def test_auth_access_signed_path_with_refresh_token( """Test access with signed url.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -356,7 +349,7 @@ async def test_auth_access_signed_path_with_query_param( """Test access with signed url and query params.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -386,7 +379,7 @@ async def test_auth_access_signed_path_with_query_param_order( """Test access with signed url and query params different order.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -427,7 +420,7 @@ async def test_auth_access_signed_path_with_query_param_safe_param( """Test access with signed url and changing a safe param.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -466,7 +459,7 @@ async def test_auth_access_signed_path_with_query_param_tamper( """Test access with signed url and query params that have been tampered with.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -535,7 +528,7 @@ async def test_auth_access_signed_path_with_http( ) app.router.add_get("/hello", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -559,7 +552,7 @@ async def test_auth_access_signed_path_with_content_user( hass: HomeAssistant, app, aiohttp_client: ClientSessionGenerator ) -> None: """Test access signed url uses content user.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) signed_path = async_sign_path(hass, "/", timedelta(seconds=5)) signature = yarl.URL(signed_path).query["authSig"] claims = jwt.decode( @@ -579,7 +572,7 @@ async def test_local_only_user_rejected( ) -> None: """Test access with access token in header.""" token = hass_access_token - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) set_mock_ip = mock_real_ip(app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -645,7 +638,7 @@ async def test_create_user_once(hass: HomeAssistant) -> None: """Test that we reuse the user.""" cur_users = len(await hass.auth.async_get_users()) app = web.Application() - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) users = await hass.auth.async_get_users() assert len(users) == cur_users + 1 @@ -657,287 +650,7 @@ async def test_create_user_once(hass: HomeAssistant) -> None: assert len(user.refresh_tokens) == 1 assert user.system_generated - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) # test it did not create a user assert len(await hass.auth.async_get_users()) == cur_users + 1 - - -@pytest.fixture -def app_strict_connection(hass): - """Fixture to set up a web.Application.""" - - async def handler(request): - """Return if request was authenticated.""" - return web.json_response(data={"authenticated": request[KEY_AUTHENTICATED]}) - - app = web.Application() - app[KEY_HASS] = hass - app.router.add_get("/", handler) - async_setup_forwarded(app, True, []) - return app - - -@pytest.mark.parametrize( - "strict_connection_mode", [e.value for e in StrictConnectionMode] -) -async def test_strict_connection_non_cloud_authenticated_requests( - hass: HomeAssistant, - app_strict_connection: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test authenticated requests with strict connection.""" - token = hass_access_token - await async_setup_auth(hass, app_strict_connection, strict_connection_mode) - set_mock_ip = mock_real_ip(app_strict_connection) - client = await aiohttp_client(app_strict_connection) - refresh_token = hass.auth.async_validate_access_token(hass_access_token) - assert refresh_token - assert hass.auth.session._strict_connection_sessions == {} - - signed_path = async_sign_path( - hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id - ) - - for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES, *EXTERNAL_ADDRESSES): - set_mock_ip(remote_addr) - - # authorized requests should work normally - req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - - -@pytest.mark.parametrize( - "strict_connection_mode", [e.value for e in StrictConnectionMode] -) -async def test_strict_connection_non_cloud_local_unauthenticated_requests( - hass: HomeAssistant, - app_strict_connection: web.Application, - aiohttp_client: ClientSessionGenerator, - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test local unauthenticated requests with strict connection.""" - await async_setup_auth(hass, app_strict_connection, strict_connection_mode) - set_mock_ip = mock_real_ip(app_strict_connection) - client = await aiohttp_client(app_strict_connection) - assert hass.auth.session._strict_connection_sessions == {} - - for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES): - set_mock_ip(remote_addr) - # local requests should work normally - req = await client.get("/") - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": False} - - -def _add_set_cookie_endpoint(app: web.Application, refresh_token: RefreshToken) -> None: - """Add an endpoint to set a cookie.""" - - async def set_cookie(request: web.Request) -> web.Response: - hass = request.app[KEY_HASS] - # Clear all sessions - hass.auth.session._temp_sessions.clear() - hass.auth.session._strict_connection_sessions.clear() - - if request.query["token"] == "refresh": - await hass.auth.session.async_create_session(request, refresh_token) - else: - await hass.auth.session.async_create_temp_unauthorized_session(request) - session = await get_session(request) - return web.Response(text=session[SESSION_ID]) - - app.router.add_get("/test/cookie", set_cookie) - - -async def _test_strict_connection_non_cloud_enabled_setup( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - strict_connection_mode: StrictConnectionMode, -) -> tuple[TestClient, Callable[[str], None], RefreshToken]: - """Test external unauthenticated requests with strict connection non cloud enabled.""" - refresh_token = hass.auth.async_validate_access_token(hass_access_token) - assert refresh_token - session = hass.auth.session - assert session._strict_connection_sessions == {} - assert session._temp_sessions == {} - - _add_set_cookie_endpoint(app, refresh_token) - await async_setup_auth(hass, app, strict_connection_mode) - set_mock_ip = mock_real_ip(app) - client = await aiohttp_client(app) - return (client, set_mock_ip, refresh_token) - - -async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test external unauthenticated requests with strict connection non cloud enabled.""" - client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup( - hass, app, aiohttp_client, hass_access_token, strict_connection_mode - ) - - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test external unauthenticated requests with strict connection non cloud enabled and refresh token cookie.""" - ( - client, - set_mock_ip, - refresh_token, - ) = await _test_strict_connection_non_cloud_enabled_setup( - hass, app, aiohttp_client, hass_access_token, strict_connection_mode - ) - session = hass.auth.session - - # set strict connection cookie with refresh token - set_mock_ip(LOCALHOST_ADDRESSES[0]) - session_id = await (await client.get("/test/cookie?token=refresh")).text() - assert session._strict_connection_sessions == {session_id: refresh_token.id} - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - req = await client.get("/") - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": False} - - # Invalidate refresh token, which should also invalidate session - hass.auth.async_remove_refresh_token(refresh_token) - assert session._strict_connection_sessions == {} - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test external unauthenticated requests with strict connection non cloud enabled and temp cookie.""" - client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup( - hass, app, aiohttp_client, hass_access_token, strict_connection_mode - ) - session = hass.auth.session - - # set strict connection cookie with temp session - assert session._temp_sessions == {} - set_mock_ip(LOCALHOST_ADDRESSES[0]) - session_id = await (await client.get("/test/cookie?token=temp")).text() - assert client.session.cookie_jar.filter_cookies(URL("http://127.0.0.1")) - assert session_id in session._temp_sessions - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - resp = await client.get("/") - assert resp.status == HTTPStatus.OK - assert await resp.json() == {"authenticated": False} - - async_fire_time_changed(hass, utcnow() + TEMP_TIMEOUT + timedelta(minutes=1)) - await hass.async_block_till_done(wait_background_tasks=True) - - assert session._temp_sessions == {} - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - await perform_unauthenticated_request(hass, client) - - -async def _drop_connection_unauthorized_request( - _: HomeAssistant, client: TestClient -) -> None: - with pytest.raises(ServerDisconnectedError): - # unauthorized requests should raise ServerDisconnectedError - await client.get("/") - - -async def _guard_page_unauthorized_request( - hass: HomeAssistant, client: TestClient -) -> None: - req = await client.get("/") - assert req.status == HTTPStatus.IM_A_TEAPOT - - def read_guard_page() -> str: - with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file: - return file.read() - - assert await req.text() == await hass.async_add_executor_job(read_guard_page) - - -@pytest.mark.parametrize( - "test_func", - [ - _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests, - _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token, - _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session, - ], - ids=[ - "no cookie", - "refresh token cookie", - "temp session cookie", - ], -) -@pytest.mark.parametrize( - ("strict_connection_mode", "request_func"), - [ - (StrictConnectionMode.DROP_CONNECTION, _drop_connection_unauthorized_request), - (StrictConnectionMode.GUARD_PAGE, _guard_page_unauthorized_request), - ], - ids=["drop connection", "static page"], -) -async def test_strict_connection_non_cloud_external_unauthenticated_requests( - hass: HomeAssistant, - app_strict_connection: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - test_func: Callable[ - [ - HomeAssistant, - web.Application, - ClientSessionGenerator, - str, - Callable[[HomeAssistant, TestClient], Awaitable[None]], - StrictConnectionMode, - ], - Awaitable[None], - ], - strict_connection_mode: StrictConnectionMode, - request_func: Callable[[HomeAssistant, TestClient], Awaitable[None]], -) -> None: - """Test external unauthenticated requests with strict connection non cloud.""" - await test_func( - hass, - app_strict_connection, - aiohttp_client, - hass_access_token, - request_func, - strict_connection_mode, - ) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b554737e7b3..9e892e2ee43 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -7,7 +7,6 @@ from ipaddress import ip_network import logging from pathlib import Path from unittest.mock import Mock, patch -from urllib.parse import quote_plus import pytest @@ -15,10 +14,7 @@ from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) from homeassistant.components import http -from homeassistant.components.http.const import StrictConnectionMode -from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component @@ -525,78 +521,3 @@ async def test_logging( response = await client.get("/api/states/logging.entity") assert response.status == HTTPStatus.OK assert "GET /api/states/logging.entity" not in caplog.text - - -async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( - hass: HomeAssistant, -) -> None: - """Test service create_temporary_strict_connection_url with strict_connection not enabled.""" - assert await async_setup_component(hass, http.DOMAIN, {"http": {}}) - with pytest.raises( - ServiceValidationError, - match="Strict connection is not enabled for non-cloud requests", - ): - await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - -@pytest.mark.parametrize( - ("mode"), - [ - StrictConnectionMode.DROP_CONNECTION, - StrictConnectionMode.GUARD_PAGE, - ], -) -async def test_service_create_temporary_strict_connection( - hass: HomeAssistant, mode: StrictConnectionMode -) -> None: - """Test service create_temporary_strict_connection_url.""" - assert await async_setup_component( - hass, http.DOMAIN, {"http": {"strict_connection": mode}} - ) - - # No external url set - assert hass.config.external_url is None - assert hass.config.internal_url is None - with pytest.raises(ServiceValidationError, match="No external URL available"): - await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - # Raise if only internal url is available - hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") - with pytest.raises(ServiceValidationError, match="No external URL available"): - await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - # Set external url too - external_url = "https://example.com" - await async_process_ha_core_config( - hass, - {"external_url": external_url}, - ) - assert hass.config.external_url == external_url - response = await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - assert isinstance(response, dict) - direct_url_prefix = f"{external_url}/auth/strict_connection/temp_token?authSig=" - assert response.pop("direct_url").startswith(direct_url_prefix) - assert response.pop("url").startswith( - f"https://login.home-assistant.io?u={quote_plus(direct_url_prefix)}" - ) - assert response == {} # No more keys in response diff --git a/tests/components/http/test_session.py b/tests/components/http/test_session.py deleted file mode 100644 index ae62365749a..00000000000 --- a/tests/components/http/test_session.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Tests for HTTP session.""" - -from collections.abc import Callable -import logging -from typing import Any -from unittest.mock import patch - -from aiohttp import web -from aiohttp.test_utils import make_mocked_request -import pytest - -from homeassistant.auth.session import SESSION_ID -from homeassistant.components.http.session import ( - COOKIE_NAME, - HomeAssistantCookieStorage, -) -from homeassistant.core import HomeAssistant - - -def fake_request_with_strict_connection_cookie(cookie_value: str) -> web.Request: - """Return a fake request with a strict connection cookie.""" - request = make_mocked_request( - "GET", "/", headers={"Cookie": f"{COOKIE_NAME}={cookie_value}"} - ) - assert COOKIE_NAME in request.cookies - return request - - -@pytest.fixture -def cookie_storage(hass: HomeAssistant) -> HomeAssistantCookieStorage: - """Fixture for the cookie storage.""" - return HomeAssistantCookieStorage(hass) - - -def _encrypt_cookie_data(cookie_storage: HomeAssistantCookieStorage, data: Any) -> str: - """Encrypt cookie data.""" - cookie_data = cookie_storage._encoder(data).encode("utf-8") - return cookie_storage._fernet.encrypt(cookie_data).decode("utf-8") - - -@pytest.mark.parametrize( - "func", - [ - lambda _: "invalid", - lambda storage: _encrypt_cookie_data(storage, "bla"), - lambda storage: _encrypt_cookie_data(storage, None), - ], -) -async def test_load_session_modified_cookies( - cookie_storage: HomeAssistantCookieStorage, - caplog: pytest.LogCaptureFixture, - func: Callable[[HomeAssistantCookieStorage], str], -) -> None: - """Test that on modified cookies the session is empty and the request will be logged for ban.""" - request = fake_request_with_strict_connection_cookie(func(cookie_storage)) - with patch( - "homeassistant.components.http.session.process_wrong_login", - ) as mock_process_wrong_login: - session = await cookie_storage.load_session(request) - assert session.empty - assert ( - "homeassistant.components.http.session", - logging.WARNING, - "Cannot decrypt/parse cookie value", - ) in caplog.record_tuples - mock_process_wrong_login.assert_called() - - -async def test_load_session_validate_session( - hass: HomeAssistant, - cookie_storage: HomeAssistantCookieStorage, -) -> None: - """Test load session validates the session.""" - session = await cookie_storage.new_session() - session[SESSION_ID] = "bla" - request = fake_request_with_strict_connection_cookie( - _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session)) - ) - - with patch.object( - hass.auth.session, "async_validate_strict_connection_session", return_value=True - ) as mock_validate: - session = await cookie_storage.load_session(request) - assert not session.empty - assert session[SESSION_ID] == "bla" - mock_validate.assert_called_with(session) - - # verify lru_cache is working - mock_validate.reset_mock() - await cookie_storage.load_session(request) - mock_validate.assert_not_called() - - session = await cookie_storage.new_session() - session[SESSION_ID] = "something" - request = fake_request_with_strict_connection_cookie( - _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session)) - ) - - with patch.object( - hass.auth.session, - "async_validate_strict_connection_session", - return_value=False, - ): - session = await cookie_storage.load_session(request) - assert session.empty - assert SESSION_ID not in session - assert session._changed diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e32768ee33e..c9d92c2f25a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -800,11 +800,10 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert proxy_load_services_files.mock_calls[0][1][1] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), - await async_get_integration(hass, "http"), # system_health requires http ] ) - assert len(descriptions) == 2 + assert len(descriptions) == 1 assert DOMAIN_GROUP in descriptions assert "description" in descriptions[DOMAIN_GROUP]["reload"] assert "fields" in descriptions[DOMAIN_GROUP]["reload"] @@ -838,7 +837,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN_LOGGER, logger_config) descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 3 + assert len(descriptions) == 2 assert DOMAIN_LOGGER in descriptions assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name" assert ( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 76acb2ff678..79c64259f8b 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.config import YAML_CONFIG_FILE from homeassistant.scripts import check_config @@ -135,7 +134,6 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", - "strict_connection": StrictConnectionMode.DISABLED, "use_x_frame_options": True, "server_host": ["0.0.0.0", "::"], }