Create repair when HA auth provider is running in legacy mode (#119975)

This commit is contained in:
Robert Resch 2024-06-26 09:00:33 +02:00 committed by GitHub
parent 005c71a4a5
commit 9f4bf6f11a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 143 additions and 31 deletions

View File

@ -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 .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
from .models import AuthFlowResult from .models import AuthFlowResult
from .providers import AuthProvider, LoginFlow, auth_provider_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config
from .providers.homeassistant import HassAuthProvider
EVENT_USER_ADDED = "user_added" EVENT_USER_ADDED = "user_added"
EVENT_USER_UPDATED = "user_updated" EVENT_USER_UPDATED = "user_updated"
@ -73,6 +74,13 @@ async def auth_manager_from_config(
key = (provider.type, provider.id) key = (provider.type, provider.id)
provider_hash[key] = provider 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: if module_configs:
modules = await asyncio.gather( modules = await asyncio.gather(
*(auth_mfa_module_from_config(hass, config) for config in module_configs) *(auth_mfa_module_from_config(hass, config) for config in module_configs)

View File

@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.const import CONF_ID from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from ..models import AuthFlowResult, Credentials, UserMeta from ..models import AuthFlowResult, Credentials, UserMeta
@ -88,7 +89,7 @@ class Data:
self._data: dict[str, list[dict[str, str]]] | None = None self._data: dict[str, list[dict[str, str]]] | None = None
# Legacy mode will allow usernames to start/end with whitespace # Legacy mode will allow usernames to start/end with whitespace
# and will compare usernames case-insensitive. # 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 self.is_legacy = False
@callback @callback
@ -106,44 +107,49 @@ class Data:
if (data := await self._store.async_load()) is None: if (data := await self._store.async_load()) is None:
data = cast(dict[str, list[dict[str, str]]], {"users": []}) 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"]: for user in data["users"]:
username = user["username"] username = user["username"]
# check if we have duplicates if self.normalize_username(username, force_normalize=True) != username:
if (folded := username.casefold()) in seen:
self.is_legacy = True
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
( (
"Home Assistant auth provider is running in legacy mode " "Home Assistant auth provider is running in legacy mode "
"because we detected usernames that are case-insensitive" "because we detected usernames that are normalized (lowercase and without spaces)."
"equivalent. Please change the username: '%s'." " Please change the username: '%s'."
), ),
username, username,
) )
not_normalized_usernames.add(username)
break if not_normalized_usernames:
self.is_legacy = True
seen.add(folded) ir.async_create_issue(
self.hass,
# check if we have unstripped usernames "auth",
if username != username.strip(): "homeassistant_provider_not_normalized_usernames",
self.is_legacy = True breaks_in_ha_version="2026.7.0",
is_fixable=False,
logging.getLogger(__name__).warning( severity=ir.IssueSeverity.WARNING,
( translation_key="homeassistant_provider_not_normalized_usernames",
"Home Assistant auth provider is running in legacy mode " translation_placeholders={
"because we detected usernames that start or end in a " "usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
"space. Please change the username: '%s'." },
), learn_more_url="homeassistant://config/users",
username, )
) else:
self.is_legacy = False
break ir.async_delete_issue(
self.hass, "auth", "homeassistant_provider_not_normalized_usernames"
self._data = data )
@property @property
def users(self) -> list[dict[str, str]]: def users(self) -> list[dict[str, str]]:
@ -228,6 +234,7 @@ class Data:
else: else:
raise InvalidUser raise InvalidUser
@callback
def _validate_new_username(self, new_username: str) -> None: def _validate_new_username(self, new_username: str) -> None:
"""Validate that username is normalized and unique. """Validate that username is normalized and unique.
@ -251,6 +258,7 @@ class Data:
translation_placeholders={"username": new_username}, translation_placeholders={"username": new_username},
) )
@callback
def change_username(self, username: str, new_username: str) -> None: def change_username(self, username: str, new_username: str) -> None:
"""Update the username. """Update the username.
@ -263,6 +271,8 @@ class Data:
for user in self.users: for user in self.users:
if self.normalize_username(user["username"]) == username: if self.normalize_username(user["username"]) == username:
user["username"] = new_username user["username"] = new_username
assert self._data is not None
self._async_check_for_not_normalized_usernames(self._data)
break break
else: else:
raise InvalidUser raise InvalidUser
@ -346,9 +356,7 @@ class HassAuthProvider(AuthProvider):
await self.async_initialize() await self.async_initialize()
assert self.data is not None 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( self.hass.auth.async_update_user_credentials_data(
credential, {**credential.data, "username": new_username} credential, {**credential.data, "username": new_username}
) )

View File

@ -39,5 +39,11 @@
"username_not_normalized": { "username_not_normalized": {
"message": "Username \"{new_username}\" is 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."
}
} }
} }

View File

@ -1,6 +1,7 @@
"""Test the Home Assistant local auth provider.""" """Test the Home Assistant local auth provider."""
import asyncio import asyncio
from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
@ -13,6 +14,7 @@ from homeassistant.auth.providers import (
homeassistant as hass_auth, homeassistant as hass_auth,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component 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' hass_auth.InvalidUsername, match='Username "TEST-user " is not normalized'
): ):
data.change_username("test-user", "TEST-user ") 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"