Make authentication case insensitive (#2742)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Wed, 3 Dec 2025 19:45:03 +0000 (20:45 +0100)
committerGitHub <noreply@github.com>
Wed, 3 Dec 2025 19:45:03 +0000 (20:45 +0100)
music_assistant/controllers/webserver/auth.py
music_assistant/controllers/webserver/helpers/auth_providers.py
tests/test_webserver_auth.py

index f43f1014659dda122852a58609e4bf8435d11cbf..6820e4242f1f90bfc2defd513c8f146033073aaa 100644 (file)
@@ -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:
index 8df9579d7404eea69906b5d229e077e567314855..8ce516715ddf286fe5d490f018aea75d39a70799 100644 (file)
@@ -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:
index c90fee8fe2697464c6a4cbf95f27668511d4431c..47a58ca4db867994492b25038ef5df606c13cbda 100644 (file)
@@ -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"