Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 = (
"<p>Your LiteLLM password will expire soon.</p>"
f"<p>Password expiry: <b>{password_expiry}</b></p>"
"<p>Please rotate your password before the expiry date to keep access.</p>"
"<p>Please change your password before the expiry date to keep access.</p>"
f'<p><a href="{change_password_url}">Change password in the Console</a></p>'
)
await send_email(
receiver_email=user_email,
Expand Down
115 changes: 113 additions & 2 deletions litellm/proxy/management_endpoints/internal_user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 [],
)
Expand Down Expand Up @@ -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={
Expand All @@ -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:
Expand Down Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading