diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 8c991d3f227..665bc308d49 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -28,6 +28,7 @@ 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 .providers.homeassistant import HassAuthProvider EVENT_USER_ADDED = "user_added" EVENT_USER_UPDATED = "user_updated" @@ -73,6 +74,13 @@ async def auth_manager_from_config( key = (provider.type, provider.id) provider_hash[key] = provider + if isinstance(provider, HassAuthProvider): + # Can be removed in 2026.7 with the legacy mode of homeassistant auth provider + # We need to initialize the provider to create the repair if needed as otherwise + # the provider will be initialized on first use, which could be rare as users + # don't frequently change auth settings + await provider.async_initialize() + if module_configs: modules = await asyncio.gather( *(auth_mfa_module_from_config(hass, config) for config in module_configs) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 1ed2f1dd3f7..4e38260dd2f 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.storage import Store from ..models import AuthFlowResult, Credentials, UserMeta @@ -88,7 +89,7 @@ class Data: self._data: dict[str, list[dict[str, str]]] | None = None # Legacy mode will allow usernames to start/end with whitespace # and will compare usernames case-insensitive. - # Remove in 2020 or when we launch 1.0. + # Deprecated in June 2019 and will be removed in 2026.7 self.is_legacy = False @callback @@ -106,44 +107,49 @@ class Data: if (data := await self._store.async_load()) is None: data = cast(dict[str, list[dict[str, str]]], {"users": []}) - seen: set[str] = set() + self._async_check_for_not_normalized_usernames(data) + self._data = data + + @callback + def _async_check_for_not_normalized_usernames( + self, data: dict[str, list[dict[str, str]]] + ) -> None: + not_normalized_usernames: set[str] = set() for user in data["users"]: username = user["username"] - # check if we have duplicates - if (folded := username.casefold()) in seen: - self.is_legacy = True - + if self.normalize_username(username, force_normalize=True) != username: logging.getLogger(__name__).warning( ( "Home Assistant auth provider is running in legacy mode " - "because we detected usernames that are case-insensitive" - "equivalent. Please change the username: '%s'." + "because we detected usernames that are normalized (lowercase and without spaces)." + " Please change the username: '%s'." ), username, ) + not_normalized_usernames.add(username) - break - - seen.add(folded) - - # check if we have unstripped usernames - if username != username.strip(): - self.is_legacy = True - - logging.getLogger(__name__).warning( - ( - "Home Assistant auth provider is running in legacy mode " - "because we detected usernames that start or end in a " - "space. Please change the username: '%s'." - ), - username, - ) - - break - - self._data = data + if not_normalized_usernames: + self.is_legacy = True + ir.async_create_issue( + self.hass, + "auth", + "homeassistant_provider_not_normalized_usernames", + breaks_in_ha_version="2026.7.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="homeassistant_provider_not_normalized_usernames", + translation_placeholders={ + "usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"' + }, + learn_more_url="homeassistant://config/users", + ) + else: + self.is_legacy = False + ir.async_delete_issue( + self.hass, "auth", "homeassistant_provider_not_normalized_usernames" + ) @property def users(self) -> list[dict[str, str]]: @@ -228,6 +234,7 @@ class Data: else: raise InvalidUser + @callback def _validate_new_username(self, new_username: str) -> None: """Validate that username is normalized and unique. @@ -251,6 +258,7 @@ class Data: translation_placeholders={"username": new_username}, ) + @callback def change_username(self, username: str, new_username: str) -> None: """Update the username. @@ -263,6 +271,8 @@ class Data: for user in self.users: if self.normalize_username(user["username"]) == username: user["username"] = new_username + assert self._data is not None + self._async_check_for_not_normalized_usernames(self._data) break else: raise InvalidUser @@ -346,9 +356,7 @@ class HassAuthProvider(AuthProvider): await self.async_initialize() assert self.data is not None - await self.hass.async_add_executor_job( - self.data.change_username, credential.data["username"], new_username - ) + self.data.change_username(credential.data["username"], new_username) self.hass.auth.async_update_user_credentials_data( credential, {**credential.data, "username": new_username} ) diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index 2b96b84c1cf..0e4cede78a3 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -39,5 +39,11 @@ "username_not_normalized": { "message": "Username \"{new_username}\" is not normalized" } + }, + "issues": { + "homeassistant_provider_not_normalized_usernames": { + "title": "Not normalized usernames detected", + "description": "The Home Assistant auth provider is running in legacy mode because we detected not normalized usernames. The legacy mode is deprecated and will be removed. Please change the following usernames:\n\n{usernames}\n\nNormalized usernames are case folded (lower case) and stripped of whitespaces." + } } } diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 3224bf6b4f7..dd2ce65b480 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,6 +1,7 @@ """Test the Home Assistant local auth provider.""" import asyncio +from typing import Any from unittest.mock import Mock, patch import pytest @@ -13,6 +14,7 @@ from homeassistant.auth.providers import ( homeassistant as hass_auth, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -389,3 +391,91 @@ async def test_change_username_not_normalized( hass_auth.InvalidUsername, match='Username "TEST-user " is not normalized' ): data.change_username("test-user", "TEST-user ") + + +@pytest.mark.parametrize( + ("usernames_in_storage", "usernames_in_repair"), + [ + (["Uppercase"], '- "Uppercase"'), + ([" leading"], '- " leading"'), + (["trailing "], '- "trailing "'), + (["Test", "test", "Fritz "], '- "Fritz "\n- "Test"'), + ], +) +async def test_create_repair_on_legacy_usernames( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, + usernames_in_storage: list[str], + usernames_in_repair: str, +) -> None: + """Test that we create a repair issue for legacy usernames.""" + assert not issue_registry.issues.get( + ("auth", "homeassistant_provider_not_normalized_usernames") + ), "Repair issue already exists" + + hass_storage[hass_auth.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "auth_provider.homeassistant", + "data": { + "users": [ + { + "username": username, + "password": "onlyherebecauseweneedapasswordstring", + } + for username in usernames_in_storage + ] + }, + } + data = hass_auth.Data(hass) + await data.async_load() + issue = issue_registry.issues.get( + ("auth", "homeassistant_provider_not_normalized_usernames") + ) + assert issue, "Repair issue not created" + assert issue.translation_placeholders == {"usernames": usernames_in_repair} + + +async def test_delete_repair_after_fixing_usernames( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Test that the repair is deleted after fixing the usernames.""" + hass_storage[hass_auth.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "auth_provider.homeassistant", + "data": { + "users": [ + { + "username": "Test", + "password": "onlyherebecauseweneedapasswordstring", + }, + { + "username": "bla ", + "password": "onlyherebecauseweneedapasswordstring", + }, + ] + }, + } + data = hass_auth.Data(hass) + await data.async_load() + issue = issue_registry.issues.get( + ("auth", "homeassistant_provider_not_normalized_usernames") + ) + assert issue, "Repair issue not created" + assert issue.translation_placeholders == {"usernames": '- "Test"\n- "bla "'} + + data.change_username("Test", "test") + issue = issue_registry.issues.get( + ("auth", "homeassistant_provider_not_normalized_usernames") + ) + assert issue + assert issue.translation_placeholders == {"usernames": '- "bla "'} + + data.change_username("bla ", "bla") + assert not issue_registry.issues.get( + ("auth", "homeassistant_provider_not_normalized_usernames") + ), "Repair issue should be deleted"