From 6fc1c909cf9f71c4d1d8c0f7ff60a5916bc302ad Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Wed, 3 Dec 2025 20:45:03 +0100 Subject: [PATCH] Make authentication case insensitive (#2742) --- music_assistant/controllers/webserver/auth.py | 20 ++- .../webserver/helpers/auth_providers.py | 15 +++ tests/test_webserver_auth.py | 116 ++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/music_assistant/controllers/webserver/auth.py b/music_assistant/controllers/webserver/auth.py index f43f1014..6820e424 100644 --- a/music_assistant/controllers/webserver/auth.py +++ b/music_assistant/controllers/webserver/auth.py @@ -40,6 +40,7 @@ from music_assistant.controllers.webserver.helpers.auth_providers import ( HomeAssistantProviderConfig, LoginProvider, LoginProviderConfig, + normalize_username, ) from music_assistant.helpers.api import api_command from music_assistant.helpers.database import DatabaseConnection @@ -52,7 +53,7 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth") # Database schema version -DB_SCHEMA_VERSION = 3 +DB_SCHEMA_VERSION = 4 # Token expiration constants (in days) TOKEN_SHORT_LIVED_EXPIRATION = 30 # Short-lived tokens (auto-renewing on use) @@ -259,6 +260,12 @@ class AuthenticationManager: ) await self.database.commit() + # Migration to version 4: Make usernames case-insensitive by converting to lowercase + if from_version < 4: + self.logger.info("Converting all usernames to lowercase for case-insensitive auth") + await self.database.execute("UPDATE users SET username = LOWER(username)") + await self.database.commit() + async def _setup_login_providers(self, allow_self_registration: bool) -> None: """ Set up available login providers based on configuration. @@ -444,6 +451,8 @@ class AuthenticationManager: :param username: The username. :return: User object or None if not found. """ + username = normalize_username(username) + user_row = await self.database.get_row("users", {"username": username}) if not user_row: return None @@ -492,6 +501,8 @@ class AuthenticationManager: :param player_filter: Optional list of player IDs user has access to. :param provider_filter: Optional list of provider instance IDs user has access to. """ + username = normalize_username(username) + user_id = secrets.token_urlsafe(32) created_at = utc() if preferences is None: @@ -542,8 +553,10 @@ class AuthenticationManager: display_name = "Home Assistant Integration" role = UserRole.USER + normalized_username = normalize_username(username) + # Try to find existing user by username - user_row = await self.database.get_row("users", {"username": username}) + user_row = await self.database.get_row("users", {"username": normalized_username}) if user_row: # Use get_user to ensure preferences are parsed correctly user = await self.get_user(user_row["user_id"]) @@ -640,7 +653,8 @@ class AuthenticationManager: """ updates = {} if username is not None: - updates["username"] = username + # Normalize username for case-insensitive authentication + updates["username"] = normalize_username(username) if display_name is not None: updates["display_name"] = display_name if avatar_url is not None: diff --git a/music_assistant/controllers/webserver/helpers/auth_providers.py b/music_assistant/controllers/webserver/helpers/auth_providers.py index 8df9579d..8ce51671 100644 --- a/music_assistant/controllers/webserver/helpers/auth_providers.py +++ b/music_assistant/controllers/webserver/helpers/auth_providers.py @@ -27,6 +27,17 @@ if TYPE_CHECKING: from music_assistant.controllers.webserver.auth import AuthenticationManager from music_assistant.providers.hass import HomeAssistantProvider + +def normalize_username(username: str) -> str: + """ + Normalize username to lowercase for case-insensitive comparison. + + :param username: The username to normalize. + :return: Normalized username (lowercase, stripped). + """ + return username.strip().lower() + + LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth") @@ -280,6 +291,8 @@ class BuiltinLoginProvider(LoginProvider): if not username or not password: return AuthResult(success=False, error="Username and password required") + username = normalize_username(username) + # Check rate limit before attempting authentication allowed, remaining_delay = await self._rate_limiter.check_rate_limit(username) if not allowed: @@ -644,6 +657,8 @@ class HomeAssistantOAuthProvider(LoginProvider): if user: return user + username = normalize_username(username) + # Check if a user with this username already exists (from built-in provider) user_row = await self.auth_manager.database.get_row("users", {"username": username}) if user_row: diff --git a/tests/test_webserver_auth.py b/tests/test_webserver_auth.py index c90fee8f..47a58ca4 100644 --- a/tests/test_webserver_auth.py +++ b/tests/test_webserver_auth.py @@ -6,6 +6,7 @@ import logging import pathlib from collections.abc import AsyncGenerator from datetime import timedelta +from sqlite3 import IntegrityError import pytest from music_assistant_models.auth import AuthProviderType, UserRole @@ -706,3 +707,118 @@ async def test_long_lived_token_no_auto_renewal(auth_manager: AuthenticationMana # Expiration should remain the same for long-lived tokens assert updated_expires_at == initial_expires_at + + +async def test_username_case_insensitive_creation(auth_manager: AuthenticationManager) -> None: + """Test that usernames are normalized to lowercase on creation. + + :param auth_manager: AuthenticationManager instance. + """ + # Create user with mixed case username + user = await auth_manager.create_user( + username="TestUser", + role=UserRole.USER, + display_name="Test User", + ) + + # Username should be stored in lowercase + assert user.username == "testuser" + + +async def test_username_case_insensitive_duplicate_prevention( + auth_manager: AuthenticationManager, +) -> None: + """Test that duplicate usernames with different cases are prevented. + + :param auth_manager: AuthenticationManager instance. + """ + # Create user with lowercase username + await auth_manager.create_user(username="admin", role=UserRole.USER) + + # Try to create user with same username but different case should fail + # (SQLite UNIQUE constraint violation) + with pytest.raises(IntegrityError, match="UNIQUE constraint failed"): + await auth_manager.create_user(username="Admin", role=UserRole.USER) + + +async def test_username_case_insensitive_login(auth_manager: AuthenticationManager) -> None: + """Test that login works with any case variation of username. + + :param auth_manager: AuthenticationManager instance. + """ + builtin_provider = auth_manager.login_providers.get("builtin") + assert builtin_provider is not None + assert isinstance(builtin_provider, BuiltinLoginProvider) + + # Create user with lowercase username + await builtin_provider.create_user_with_password( + username="testadmin", + password="SecurePassword123", + role=UserRole.ADMIN, + ) + + # Test login with lowercase + result = await auth_manager.authenticate_with_credentials( + "builtin", + {"username": "testadmin", "password": "SecurePassword123"}, + ) + assert result.success is True + assert result.user is not None + assert result.user.username == "testadmin" + + # Test login with uppercase + result = await auth_manager.authenticate_with_credentials( + "builtin", + {"username": "TESTADMIN", "password": "SecurePassword123"}, + ) + assert result.success is True + assert result.user is not None + assert result.user.username == "testadmin" + + # Test login with mixed case + result = await auth_manager.authenticate_with_credentials( + "builtin", + {"username": "TestAdmin", "password": "SecurePassword123"}, + ) + assert result.success is True + assert result.user is not None + assert result.user.username == "testadmin" + + +async def test_username_case_insensitive_lookup(auth_manager: AuthenticationManager) -> None: + """Test that user lookup by username is case-insensitive. + + :param auth_manager: AuthenticationManager instance. + """ + # Create user with lowercase username + created_user = await auth_manager.create_user(username="lookupuser", role=UserRole.USER) + + # Lookup with lowercase + user1 = await auth_manager.get_user_by_username("lookupuser") + assert user1 is not None + assert user1.user_id == created_user.user_id + + # Lookup with uppercase + user2 = await auth_manager.get_user_by_username("LOOKUPUSER") + assert user2 is not None + assert user2.user_id == created_user.user_id + + # Lookup with mixed case + user3 = await auth_manager.get_user_by_username("LookUpUser") + assert user3 is not None + assert user3.user_id == created_user.user_id + + +async def test_username_update_normalizes(auth_manager: AuthenticationManager) -> None: + """Test that updating username normalizes it to lowercase. + + :param auth_manager: AuthenticationManager instance. + """ + user = await auth_manager.create_user(username="originaluser", role=UserRole.USER) + + # Update username with mixed case + updated_user = await auth_manager.update_user(user, username="UpdatedUser") + + # Username should be normalized to lowercase + assert updated_user is not None + assert updated_user.username == "updateduser" -- 2.34.1