mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 19:57:07 +00:00
Create repair when HA auth provider is running in legacy mode (#119975)
This commit is contained in:
parent
005c71a4a5
commit
9f4bf6f11a
@ -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)
|
||||||
|
@ -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}
|
||||||
)
|
)
|
||||||
|
@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user