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 .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)

View File

@ -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}
)

View File

@ -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."
}
}
}

View File

@ -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"