From 571c7565242a165862c626455c3600ca08cf9a44 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Sep 2026 17:47:13 +0200 Subject: [PATCH] Validate username during onboarding before creating a new user (#182320) --- homeassistant/components/onboarding/views.py | 12 ++++- tests/components/onboarding/test_views.py | 51 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 2801f4266df1..d3144de56bc8 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -10,7 +10,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.auth.providers.homeassistant import HassAuthProvider +from homeassistant.auth.providers.homeassistant import HassAuthProvider, InvalidUsername from homeassistant.components import person from homeassistant.components.auth import indieauth from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID @@ -199,10 +199,18 @@ class UserOnboardingView(_BaseOnboardingStepView): provider = _async_get_hass_provider(hass) await provider.async_initialize() + # Add the auth before creating the user, as it validates the + # username, to avoid leaving an orphaned user behind on failure. + try: + await provider.async_add_auth(data["username"], data["password"]) + except InvalidUsername as err: + return self.json_message( + str(err), HTTPStatus.BAD_REQUEST, err.translation_key + ) + user = await hass.auth.async_create_user( data["name"], group_ids=[GROUP_ID_ADMIN] ) - await provider.async_add_auth(data["username"], data["password"]) credentials = await provider.async_get_or_create_credentials( {"username": data["username"]} ) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 274e96cf84cd..9745c6a97be2 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -256,6 +256,57 @@ async def test_onboarding_user_invalid_name( assert resp.status == 400 +@pytest.mark.parametrize( + ("username", "expected_code"), + [ + pytest.param("Test-User", "username_not_normalized", id="uppercase"), + pytest.param("test-user ", "username_not_normalized", id="whitespace"), + pytest.param("existing-user", "username_already_exists", id="duplicate"), + ], +) +async def test_onboarding_user_invalid_username( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client_no_auth: ClientSessionGenerator, + username: str, + expected_code: str, +) -> None: + """Test a rejected username does not leave an orphaned user behind.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + provider = views._async_get_hass_provider(hass) + await provider.async_initialize() + await provider.async_add_auth("existing-user", "test-pass") + + cur_users = len(await hass.auth.async_get_users()) + client = await hass_client_no_auth() + + resp = await client.post( + "/api/onboarding/users", + json={ + "client_id": CLIENT_ID, + "name": "Test Name", + "username": username, + "password": "test-pass", + "language": "en", + }, + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + body = await resp.json() + assert body["code"] == expected_code + # The rejected username is echoed back so the frontend can explain the failure + assert username in body["message"] + + # The step stays open so onboarding can be retried with another username, + # and no user may be left behind from the rejected attempt. + assert const.STEP_USER not in hass_storage[const.DOMAIN]["data"]["done"] + assert len(await hass.auth.async_get_users()) == cur_users + + async def test_onboarding_user_race( hass: HomeAssistant, hass_storage: dict[str, Any],