diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index bf2c45461e7..96fb267f5e4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1583,6 +1583,16 @@ def check_user_info(cls, values): return values +class ChangePasswordRequest(LiteLLMPydanticObjectBase): + current_password: str + new_password: str + + +class ChangePasswordResponse(LiteLLMPydanticObjectBase): + message: str + password_expiry: Optional[datetime] = None + + class DeleteUserRequest(LiteLLMPydanticObjectBase): user_ids: List[str] # required @@ -2591,6 +2601,7 @@ class UserInfoV2Response(LiteLLMPydanticObjectBase): metadata: Optional[dict] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + password_expiry: Optional[datetime] = None sso_user_id: Optional[str] = None teams: List[str] = [] # Just team IDs, not full team objects diff --git a/litellm/proxy/common_utils/user_credentials_rotation_manager.py b/litellm/proxy/common_utils/user_credentials_rotation_manager.py index 6c35736aa8a..8802ed36fa3 100644 --- a/litellm/proxy/common_utils/user_credentials_rotation_manager.py +++ b/litellm/proxy/common_utils/user_credentials_rotation_manager.py @@ -4,7 +4,7 @@ from litellm._logging import verbose_proxy_logger from litellm.constants import LITELLM_USER_CREDENTIALS_ROTATION_LOCK_TTL_SECONDS from litellm.models.user import LiteLLM_UserTable -from litellm.proxy.utils import PrismaClient, send_email +from litellm.proxy.utils import PrismaClient, get_custom_url, send_email from litellm.repositories.user_repository import UserRepository @@ -98,11 +98,13 @@ async def _notify_user(self, user: UserCredentialsReminderRow) -> None: if not isinstance(user_email, str) or not user_email.strip() or password_expiry is None: return + change_password_url = get_custom_url("", "ui/change-password") subject = "LiteLLM password rotation reminder" html = ( "
Your LiteLLM password will expire soon.
" f"Password expiry: {password_expiry}
" - "Please rotate your password before the expiry date to keep access.
" + "Please change your password before the expiry date to keep access.
" + f'Change password in the Console
' ) await send_email( receiver_email=user_email, diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index a10d3a01c66..7a07df461d1 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -47,7 +47,11 @@ prepare_metadata_fields, ) from litellm.proxy.management_helpers.utils import management_endpoint_wrapper -from litellm.proxy.utils import handle_exception_on_proxy, hash_password +from litellm.proxy.utils import ( + handle_exception_on_proxy, + hash_password, + verify_password, +) from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.table_repositories import ( InvitationLinkRepository, @@ -1044,6 +1048,7 @@ async def user_info_v2( metadata=_redact_scim_enterprise_metadata(user_data.get("metadata")), created_at=user_data.get("created_at"), updated_at=user_data.get("updated_at"), + password_expiry=user_data.get("password_expiry"), sso_user_id=user_data.get("sso_user_id"), teams=user_data.get("teams") or [], ) @@ -1288,7 +1293,6 @@ def _check_user_update_authz( }, ) elif user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: - # Silent-create guard: only PROXY_ADMIN may create via /user/update. raise HTTPException( status_code=404, detail={ @@ -1297,6 +1301,49 @@ def _check_user_update_authz( ) +def _resolve_password_expiry_for_new_password() -> Optional[datetime]: + rotation_interval = _get_user_credentials_rotation_interval() + if rotation_interval is None: + return None + return _calculate_password_expiry(rotation_interval) + + +async def _get_user_row_by_id_or_throw( + prisma_client: "PrismaClient", + user_id: str, +) -> BaseModel: + user_row = await UserRepository(prisma_client).table.find_first( + where={"user_id": user_id} + ) + if user_row is None: + raise HTTPException( + status_code=404, + detail={"error": f"User not found: {user_id}"}, + ) + return user_row + + +async def _change_password_for_user( + prisma_client: "PrismaClient", + user_id: str, + new_password: str, +) -> ChangePasswordResponse: + password_expiry = _resolve_password_expiry_for_new_password() + response = await prisma_client.update_data( + user_id=user_id, + data={ + "password": hash_password(new_password), + "password_expiry": password_expiry, + }, + table_name="user", + ) + _strip_password_from_response(response) + return ChangePasswordResponse( + message="Password updated successfully", + password_expiry=password_expiry, + ) + + async def _invalidate_user_spend_counter_if_changed( non_default_values: dict[str, Any], ) -> None: @@ -1477,6 +1524,70 @@ def can_user_call_user_update( return False +@router.post( + "/user/change_password", + tags=["Internal User management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ChangePasswordResponse, +) +@management_endpoint_wrapper +async def user_change_password( + data: ChangePasswordRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + try: + from litellm.proxy.proxy_server import prisma_client + + caller_user_id = require_caller_user_id_for_non_admin(user_api_key_dict) + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + user_row = await _get_user_row_by_id_or_throw(prisma_client, caller_user_id) + typed_user = LiteLLM_UserTable(**user_row.model_dump(exclude_none=True)) + stored_password = typed_user.password + if stored_password is None: + raise HTTPException( + status_code=400, + detail={"error": "User does not have a password set."}, + ) + if not verify_password(data.current_password, stored_password): + raise HTTPException( + status_code=401, + detail={"error": "Current password is incorrect."}, + ) + + return await _change_password_for_user( + prisma_client=prisma_client, + user_id=caller_user_id, + new_password=data.new_password, + ) + except Exception as e: + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.user_change_password(): Exception occured - {}".format( + str(e) + ) + ) + verbose_proxy_logger.debug(traceback.format_exc()) + if isinstance(e, HTTPException): + raise ProxyException( + message=getattr(e, "detail", f"Authentication Error({str(e)})"), + type=ProxyErrorTypes.auth_error, + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), + ) + elif isinstance(e, ProxyException): + raise e + raise ProxyException( + message="Authentication Error, " + str(e), + type=ProxyErrorTypes.auth_error, + param=getattr(e, "param", "None"), + code=status.HTTP_400_BAD_REQUEST, + ) + + @router.post( "/user/update", tags=["Internal User management"], diff --git a/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py b/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py index 02cb4d2eabc..2840644df2a 100644 --- a/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py +++ b/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py @@ -47,6 +47,7 @@ async def test_notify_user_sends_email_for_expiring_password(): assert call["receiver_email"] == "user@example.com" assert call["subject"] == "LiteLLM password rotation reminder" assert "2030-01-01" in call["html"] + assert "/ui/change-password" in call["html"] @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index dfca62d5dd1..b938891e0d3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -13,6 +13,7 @@ ) # Adds the parent directory to the system path from litellm.proxy._types import ( + ChangePasswordRequest, LiteLLM_UserTableFiltered, LitellmUserRoles, NewUserRequest, @@ -28,6 +29,8 @@ get_users, new_user, ui_view_users, + user_change_password, + user_info_v2, ) from litellm.proxy.proxy_server import app @@ -1036,9 +1039,7 @@ async def mock_check(*_args, **_kwargs): user_role=LitellmUserRoles.INTERNAL_USER, permissions={"get_spend_routes": True}, ) - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(ProxyException) as exc_info: await new_user(data=data, user_api_key_dict=caller) @@ -1082,9 +1083,7 @@ async def mock_check(*_args, **_kwargs): permissions={}, ) assert "permissions" in data.model_fields_set - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(ProxyException) as exc_info: await new_user(data=data, user_api_key_dict=caller) @@ -1137,9 +1136,7 @@ async def stub_helper(**_kwargs): user_role=LitellmUserRoles.INTERNAL_USER, ) assert "permissions" not in data.model_fields_set - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) result = await new_user(data=data, user_api_key_dict=caller) assert result is not None @@ -1213,14 +1210,10 @@ async def test_update_single_user_non_admin_permissions_rejected(mocker): user_id="alice", permissions={"get_spend_routes": True}, ) - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(HTTPException) as exc_info: - await _update_single_user_helper( - user_request=data, user_api_key_dict=caller - ) + await _update_single_user_helper(user_request=data, user_api_key_dict=caller) assert exc_info.value.status_code == 403 assert "permissions" in str(exc_info.value.detail) @@ -1242,14 +1235,10 @@ async def test_update_single_user_non_admin_permissions_explicit_empty_rejected( data = UpdateUserRequest(user_id="alice", permissions={}) assert "permissions" in data.model_fields_set - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(HTTPException) as exc_info: - await _update_single_user_helper( - user_request=data, user_api_key_dict=caller - ) + await _update_single_user_helper(user_request=data, user_api_key_dict=caller) assert exc_info.value.status_code == 403 assert "permissions" in str(exc_info.value.detail) @@ -1563,7 +1552,9 @@ async def mock_count(*args, **kwargs): user_role="internal_user", key_alias="TestKey Name", ), - user_api_key_dict=UserAPIKeyAuth(user_id="test_admin", user_email="caller@example.com"), + user_api_key_dict=UserAPIKeyAuth( + user_id="test_admin", user_email="caller@example.com" + ), ) call_kwargs = mock_generate_key_helper_fn.call_args.kwargs @@ -1595,12 +1586,18 @@ async def mock_count(*args, **kwargs): mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check) mocker.patch( "litellm.proxy.management_endpoints.internal_user_endpoints._enforce_email_prefix_on_key_alias", - mocker.AsyncMock(side_effect=HTTPException(status_code=400, detail={"error": "missing email"})), + mocker.AsyncMock( + side_effect=HTTPException( + status_code=400, detail={"error": "missing email"} + ) + ), ) with pytest.raises(ProxyException) as exc_info: await new_user( - data=NewUserRequest(user_email=None, user_role="internal_user", key_alias="Needs Alias"), + data=NewUserRequest( + user_email=None, user_role="internal_user", key_alias="Needs Alias" + ), user_api_key_dict=UserAPIKeyAuth(user_id="test_admin", user_email=None), ) @@ -1654,8 +1651,12 @@ async def mock_count(*args, **kwargs): ) await new_user( - data=NewUserRequest(user_email="owner.user@example.com", user_role="internal_user"), - user_api_key_dict=UserAPIKeyAuth(user_id="test_admin", user_email="caller@example.com"), + data=NewUserRequest( + user_email="owner.user@example.com", user_role="internal_user" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="test_admin", user_email="caller@example.com" + ), ) call_kwargs = mock_generate_key_helper_fn.call_args.kwargs @@ -2994,6 +2995,7 @@ async def test_user_info_v2_response_shape(mocker): "user_email": "shape@example.com", "user_alias": "Shape Test", "user_role": "internal_user", + "password_expiry": datetime(2024, 4, 1, tzinfo=timezone.utc), "spend": 5.0, "max_budget": 50.0, "models": ["gpt-3.5-turbo"], @@ -3036,6 +3038,7 @@ async def mock_find_unique(*args, **kwargs): "user_email", "user_alias", "user_role", + "password_expiry", "spend", "max_budget", "models", @@ -3756,3 +3759,177 @@ async def test_resolve_user_email_metadata_skips_db_when_no_user_ids(mocker): assert result == {} find_many.assert_not_called() + + +@pytest.mark.asyncio +async def test_user_change_password_updates_password_and_expiry(mocker): + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.update_data = mocker.AsyncMock( + return_value={"user_id": "user-1"} + ) + mock_user = mocker.MagicMock() + mock_user.model_dump.return_value = { + "user_id": "user-1", + "password": "scrypt:stored", + "user_email": "test@example.com", + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_row_by_id_or_throw", + new=mocker.AsyncMock(return_value=mock_user), + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.verify_password", + return_value=True, + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.hash_password", + return_value="hashed-new-pass", + ) + mocker.patch( + "litellm.proxy.proxy_server.general_settings", + {"user_credentials_rotation_interval": "30d"}, + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.duration_in_seconds", + return_value=30 * 24 * 60 * 60, + ) + + response = await user_change_password( + data=ChangePasswordRequest( + current_password="old-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + update_call = mock_prisma_client.update_data.await_args + assert update_call.kwargs["user_id"] == "user-1" + assert update_call.kwargs["data"]["password"] == "hashed-new-pass" + assert update_call.kwargs["data"]["password_expiry"] is not None + assert response.message == "Password updated successfully" + assert response.password_expiry is not None + + +@pytest.mark.asyncio +async def test_user_change_password_rejects_wrong_current_password(mocker): + mock_prisma_client = mocker.MagicMock() + mock_user = mocker.MagicMock() + mock_user.model_dump.return_value = { + "user_id": "user-1", + "password": "scrypt:stored", + "user_email": "test@example.com", + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_row_by_id_or_throw", + new=mocker.AsyncMock(return_value=mock_user), + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.verify_password", + return_value=False, + ) + + with pytest.raises(ProxyException) as exc_info: + await user_change_password( + data=ChangePasswordRequest( + current_password="wrong-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert exc_info.value.code == "401" + mock_prisma_client.update_data.assert_not_called() + + +@pytest.mark.asyncio +async def test_user_change_password_requires_user_bound_key(): + with pytest.raises(ProxyException) as exc_info: + await user_change_password( + data=ChangePasswordRequest( + current_password="old-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id=None, + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert exc_info.value.code == "403" + assert "Service-account keys" in str(exc_info.value.message) + + +@pytest.mark.asyncio +async def test_user_change_password_rejects_users_without_stored_password(mocker): + mock_prisma_client = mocker.MagicMock() + mock_user = mocker.MagicMock() + mock_user.model_dump.return_value = { + "user_id": "user-1", + "password": None, + "user_email": "test@example.com", + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_row_by_id_or_throw", + new=mocker.AsyncMock(return_value=mock_user), + ) + + with pytest.raises(ProxyException) as exc_info: + await user_change_password( + data=ChangePasswordRequest( + current_password="old-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert exc_info.value.code == "400" + mock_prisma_client.update_data.assert_not_called() + + +@pytest.mark.asyncio +async def test_user_info_v2_returns_password_expiry(mocker): + password_expiry = datetime.now(timezone.utc) + user_row = mocker.MagicMock() + user_row.model_dump.return_value = { + "user_id": "user-1", + "user_email": "test@example.com", + "user_alias": "Test User", + "user_role": LitellmUserRoles.INTERNAL_USER, + "spend": 0.0, + "max_budget": None, + "models": [], + "budget_duration": None, + "budget_reset_at": None, + "metadata": None, + "created_at": password_expiry, + "updated_at": password_expiry, + "password_expiry": password_expiry, + "sso_user_id": None, + "teams": [], + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.MagicMock()) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._check_user_info_v2_access", + new=mocker.AsyncMock(return_value=user_row), + ) + + response = await user_info_v2( + request=mocker.MagicMock(), + user_id=None, + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert response.password_expiry == password_expiry + assert response.user_id == "user-1" + assert response.user_email == "test@example.com" diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 8b92683db67..2c985d58c5d 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -8593,7 +8593,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.test.tsx new file mode 100644 index 00000000000..987a8e79710 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.test.tsx @@ -0,0 +1,99 @@ +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ChangePasswordPage from "./ChangePasswordPage"; + +const mockUseAuthorized = vi.fn(); +const mockUseCurrentUser = vi.fn(); +const mockInvalidateQueries = vi.fn(); +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockChangePasswordCall = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ + useCurrentUser: () => mockUseCurrentUser(), +})); + +vi.mock("@/components/molecules/message_manager", () => ({ + default: { + success: (...args: unknown[]) => mockSuccess(...args), + error: (...args: unknown[]) => mockError(...args), + }, +})); + +vi.mock("@/components/networking", () => ({ + changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual