Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/password_endpoints.py: 47%
59 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Self-service password management.
4/user/password/change
6Deliberately NOT wrapped in `management_endpoint_wrapper`: the wrapper emits
7request kwargs to OTEL spans, which would log plaintext passwords. The audit
8signal is emitted by hand below, with field names only, never values.
9"""
11from typing import TYPE_CHECKING, Annotated, Final
13from fastapi import APIRouter, Depends, HTTPException
14from pydantic import TypeAdapter
16from litellm._logging import verbose_proxy_logger
17from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
18from litellm.proxy._types import (
19 UI_TEAM_ID,
20 ChangePasswordRequest,
21 ChangePasswordResponse,
22 CommonProxyErrors,
23 HTTPExceptionErrorDetail,
24 LitellmTableNames,
25 UserAPIKeyAuth,
26)
27from litellm.proxy.auth.login_utils import PASSWORD_SESSION_METADATA
28from litellm.proxy.auth.password_policy import (
29 get_hibp_client,
30 validate_password_not_breached,
31 validate_password_policy,
32)
33from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
34from litellm.proxy.management_endpoints.session_endpoints import revoke_ui_session_keys
35from litellm.proxy.management_helpers.audit_logs import create_object_audit_log
36from litellm.proxy.utils import hash_password, verify_password
37from litellm.repositories.prisma_protocols import TableActions
38from litellm.repositories.user_repository import UserRepository
40if TYPE_CHECKING: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 from prisma import models as prisma_models
42 from prisma import types as prisma_types
44 from litellm.proxy.utils import PrismaClient
46router: Final = APIRouter()
48_PASSWORD_CHANGED_AUDIT_VALUES: Final = '{"fields_changed": ["password"]}'
49_KEY_METADATA: Final = TypeAdapter(dict[str, object])
52def _error_detail(message: str) -> HTTPExceptionErrorDetail:
53 detail: Final[HTTPExceptionErrorDetail] = {"error": message}
54 return detail
57def _is_password_login_session(user_api_key_dict: UserAPIKeyAuth) -> bool:
58 if user_api_key_dict.team_id != UI_TEAM_ID: 58 ↛ 60line 58 didn't jump to line 60 because the condition on line 58 was always true
59 return False
60 key_metadata: Final = _KEY_METADATA.validate_python(user_api_key_dict.metadata)
61 return all(key_metadata.get(k) == v for k, v in PASSWORD_SESSION_METADATA.items())
64def _user_table(
65 prisma_client: "PrismaClient | None",
66) -> "TableActions[prisma_models.LiteLLM_UserTable]":
67 user_table: Final[TableActions[prisma_models.LiteLLM_UserTable]] = UserRepository(prisma_client).table
68 return user_table
71@router.post(
72 "/user/password/change",
73 tags=("Internal User management",),
74 dependencies=(Depends(user_api_key_auth),),
75)
76async def change_password(
77 data: ChangePasswordRequest,
78 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
79 hibp_client: Annotated[AsyncHTTPHandler, Depends(get_hibp_client)],
80) -> ChangePasswordResponse:
81 """
82 Change the calling user's own password.
84 Only callable with the dashboard session issued by a username/password
85 login; SSO sessions and virtual keys are rejected with 403. Requires the
86 current password. The new password must differ from the
87 current one and satisfy the configured password policy
88 (`general_settings.password_policy_*`: minimum length, character classes,
89 and, when enabled, breached-password screening via haveibeenpwned.com).
90 A successful change lifts any pending forced password reset
91 (`password_reset_required`) on the account.
93 Parameters:
94 - current_password: str - The user's current password.
95 - new_password: str - The password to change to.
96 """
97 from litellm.proxy.proxy_server import general_settings, litellm_proxy_admin_name, prisma_client
99 if prisma_client is None: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 raise HTTPException(
101 status_code=500,
102 detail=_error_detail(CommonProxyErrors.db_not_connected_error.value),
103 )
105 if not _is_password_login_session(user_api_key_dict): 105 ↛ 113line 105 didn't jump to line 113 because the condition on line 105 was always true
106 raise HTTPException(
107 status_code=403,
108 detail=_error_detail(
109 "Passwords can only be changed from a dashboard session created by logging in with a password."
110 ),
111 )
113 user_id: Final = user_api_key_dict.user_id
114 if user_id is None:
115 raise HTTPException(
116 status_code=400,
117 detail=_error_detail("No user is associated with this session, so there is no password to change."),
118 )
120 find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id}
121 user_row: Final = await _user_table(prisma_client).find_first(where=find_user)
122 stored_password: Final = user_row.password if user_row is not None else None
123 if stored_password is None:
124 raise HTTPException(
125 status_code=400,
126 detail=_error_detail(
127 "This account has no password set, so there is no password to change. "
128 "Passwords are set through an invitation link (POST /invitation/new)."
129 ),
130 )
132 if not verify_password(data.current_password, stored_password):
133 raise HTTPException(status_code=400, detail=_error_detail("Current password is incorrect."))
135 if data.new_password == data.current_password:
136 raise HTTPException(
137 status_code=400,
138 detail=_error_detail("New password must be different from the current password."),
139 )
141 validate_password_policy(data.new_password, general_settings)
142 await validate_password_not_breached(data.new_password, general_settings, hibp_client)
144 password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {
145 "password": hash_password(data.new_password),
146 "password_reset_required": False,
147 "last_breach_check_at": None,
148 }
149 await _user_table(prisma_client).update(where=find_user, data=password_update)
151 # The old password may have been compromised; revoke every other UI session
152 # so a holder of a stolen session token is cut off. The caller's own session
153 # is kept — they just proved they hold the current password.
154 await revoke_ui_session_keys(
155 user_id=user_id,
156 user_api_key_dict=user_api_key_dict,
157 keep_hashed_token=user_api_key_dict.token,
158 )
160 verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
161 await create_object_audit_log(
162 object_id=user_id,
163 action="updated",
164 litellm_changed_by=None,
165 user_api_key_dict=user_api_key_dict,
166 litellm_proxy_admin_name=litellm_proxy_admin_name,
167 table_name=LitellmTableNames.USER_TABLE_NAME,
168 after_value=_PASSWORD_CHANGED_AUDIT_VALUES,
169 )
170 return ChangePasswordResponse(user_id=user_id, message="Password updated successfully.")