HomeAssistantProviderConfig,
LoginProvider,
LoginProviderConfig,
+ normalize_username,
)
from music_assistant.helpers.api import api_command
from music_assistant.helpers.database import DatabaseConnection
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)
)
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.
: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
: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:
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"])
"""
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:
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")
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:
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:
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
# 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"