diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql
new file mode 100644
index 00000000000..960b0d4d7eb
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "LiteLLM_UserTable" ADD COLUMN IF NOT EXISTS "password_reset_required" BOOLEAN;
+
+ALTER TABLE "LiteLLM_UserTable" ADD COLUMN IF NOT EXISTS "last_breach_check_at" TIMESTAMP(3);
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 7d521d54791..a2b32dd8e73 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -241,6 +241,8 @@ model LiteLLM_UserTable {
organization_id String?
object_permission_id String?
password String?
+ password_reset_required Boolean?
+ last_breach_check_at DateTime?
teams String[] @default([])
user_role String?
max_budget Float?
diff --git a/litellm/models/user.py b/litellm/models/user.py
index 82f78c28078..92aca87d303 100644
--- a/litellm/models/user.py
+++ b/litellm/models/user.py
@@ -24,6 +24,8 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase):
organization_id: str | None = None
object_permission_id: str | None = None
password: str | None = Field(default=None, exclude=True)
+ password_reset_required: bool | None = None
+ last_breach_check_at: datetime | None = None
teams: list[str] = []
user_role: str | None = None
max_budget: float | None = None
diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py
index c0a76a4fc20..16b4ef7380c 100644
--- a/litellm/proxy/auth/login_utils.py
+++ b/litellm/proxy/auth/login_utils.py
@@ -10,14 +10,16 @@ import secrets
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
-from typing import Final, Literal, cast
+from typing import TYPE_CHECKING, Final, Literal, cast
import jwt
from fastapi import HTTPException
import litellm
+from litellm._logging import verbose_proxy_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME, LITELLM_UI_SESSION_DURATION
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
@@ -27,6 +29,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
+from litellm.proxy.auth.password_policy import is_breach_check_enabled, is_password_breached
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
@@ -44,6 +47,56 @@ from litellm.repositories.user_repository import UserRepository
from litellm.secret_managers.main import get_secret_bool
from litellm.types.proxy.ui_sso import ReturnedUITokenObject
+if TYPE_CHECKING:
+ from prisma import types as prisma_types
+
+BREACH_RECHECK_INTERVAL: Final = timedelta(hours=24)
+PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change",)
+
+
+def _breach_recheck_due(last_breach_check_at: datetime | None) -> bool:
+ if last_breach_check_at is None:
+ return True
+ last_checked_utc: Final = (
+ last_breach_check_at
+ if last_breach_check_at.tzinfo is not None
+ else last_breach_check_at.replace(tzinfo=timezone.utc)
+ )
+ return datetime.now(timezone.utc) - last_checked_utc >= BREACH_RECHECK_INTERVAL
+
+
+async def screen_login_password_for_breach(
+ user_id: str,
+ password: str,
+ last_breach_check_at: datetime | None,
+ general_settings: Mapping[str, object],
+ prisma_client: PrismaClient,
+ client: AsyncHTTPHandler | None = None,
+) -> bool:
+ """Screens a successfully verified login password against HIBP, stamps
+ ``password_reset_required`` when breached, and returns whether a breach was
+ found so the login it runs in can restrict the session it is about to mint.
+ Fails open (HIBP or DB trouble never fails the login) and rechecks a given
+ user at most once per ``BREACH_RECHECK_INTERVAL``."""
+ if not is_breach_check_enabled(general_settings):
+ return False
+ if not _breach_recheck_due(last_breach_check_at):
+ return False
+ breached: Final = await is_password_breached(password, general_settings, client)
+ checked_at: Final = datetime.now(timezone.utc)
+ breached_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {
+ "last_breach_check_at": checked_at,
+ "password_reset_required": True,
+ }
+ recheck_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"last_breach_check_at": checked_at}
+ update_data: Final = breached_update if breached else recheck_update
+ find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id}
+ try:
+ await UserRepository(prisma_client).table.update(where=find_user, data=update_data)
+ except Exception as e: # noqa: BLE001 # a failed stamp must never surface into the login
+ verbose_proxy_logger.warning("Login-time breach screening could not update user %s: %s", user_id, e)
+ return breached
+
async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None:
"""Rehash legacy password (SHA256) to scrypt on successful login."""
@@ -116,6 +169,7 @@ class LoginResult:
user_email: str | None
user_role: str
login_method: Literal["sso", "username_password"]
+ password_reset_required: bool
def __init__(
self,
@@ -124,12 +178,14 @@ class LoginResult:
user_email: str | None,
user_role: str,
login_method: Literal["sso", "username_password"] = "username_password",
+ password_reset_required: bool = False,
):
self.user_id = user_id
self.key = key
self.user_email = user_email
self.user_role = user_role
self.login_method = login_method
+ self.password_reset_required = password_reset_required
async def authenticate_user(
@@ -322,20 +378,25 @@ async def authenticate_user(
if verify_password(password, _password):
await _rehash_password_if_needed(_user_row.user_id, password, _password)
+ breached_now: Final = prisma_client is not None and await screen_login_password_for_breach(
+ user_id=_user_row.user_id,
+ password=password,
+ last_breach_check_at=getattr(_user_row, "last_breach_check_at", None),
+ general_settings=general_settings,
+ prisma_client=prisma_client,
+ )
+ password_reset_required: Final = breached_now or getattr(_user_row, "password_reset_required", None) is True
if os.getenv("DATABASE_URL") is not None:
response = await generate_key_helper_fn(
request_type="key",
- **{
- "user_role": user_role,
- "duration": LITELLM_UI_SESSION_DURATION,
- "key_max_budget": litellm.max_ui_session_budget,
- "models": [],
- "aliases": {},
- "config": {},
- "spend": 0,
- "user_id": user_id,
- "team_id": "litellm-dashboard",
- },
+ user_role=user_role,
+ duration=LITELLM_UI_SESSION_DURATION,
+ key_max_budget=litellm.max_ui_session_budget,
+ spend=0,
+ user_id=user_id,
+ team_id="litellm-dashboard",
+ allowed_routes=list(PASSWORD_RESET_ALLOWED_ROUTES) if password_reset_required else None,
+ metadata={"password_reset_required": True} if password_reset_required else {},
)
else:
raise ProxyException(
@@ -353,6 +414,7 @@ async def authenticate_user(
user_email=user_email,
user_role=cast(str, user_role),
login_method="username_password",
+ password_reset_required=password_reset_required,
)
else:
raise ProxyException(
@@ -426,4 +488,5 @@ def create_ui_token_object(
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=login_result.password_reset_required,
)
diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py
index e4a36e73373..c4109bdc2f1 100644
--- a/litellm/proxy/auth/route_checks.py
+++ b/litellm/proxy/auth/route_checks.py
@@ -187,6 +187,16 @@ class RouteChecks:
if denied_auth_enforced_pass_through_route:
raise RouteChecks._auth_pass_through_denied_exception(route=route)
+ if valid_token.metadata.get("password_reset_required") is True:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=(
+ "This account's password must be changed before the session can be used: "
+ "it was either found in a known data breach or set by an admin. "
+ "Change it via POST /user/password/change (UI: /ui/change-password), then log in again."
+ ),
+ )
+
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Virtual key is not allowed to call this route. Only allowed to call routes: {valid_token.allowed_routes}. Tried to call route: {route}",
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index c716f6067e1..2b7de116d06 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -173,12 +173,17 @@ async def _hash_password_in_dict(
"""Validate and hash password field in-place if present.
``password_prevalidated`` skips the policy checks for callers that already
- validated the password (the bulk path screens its whole batch upfront)."""
+ validated the password (the bulk path screens its whole batch upfront).
+
+ An admin-set password is known to whoever set it, so the user is also
+ flagged for a forced password change at next login."""
if "password" in data and data["password"] is not None:
if not password_prevalidated:
validate_password_policy(data["password"], general_settings)
await validate_password_not_breached(data["password"], general_settings)
data["password"] = hash_password(data["password"])
+ data["password_reset_required"] = True
+ data["last_breach_check_at"] = None
def _strip_password_from_response(response) -> None:
@@ -1643,7 +1648,7 @@ async def user_update(
Parameters:
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
- user_email: Optional[str] - Specify a user email.
- - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
+ - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. The user is required to change it at their next login. Users change their own password with POST /user/password/change.
- user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
- teams: Optional[list] - specify a list of team id's a user belongs to.
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.
diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py
index c1d409cc08d..bd3c9722d4d 100644
--- a/litellm/proxy/management_endpoints/password_endpoints.py
+++ b/litellm/proxy/management_endpoints/password_endpoints.py
@@ -66,7 +66,8 @@ async def change_password(
Requires the current password. The new password must satisfy the
configured password policy (`general_settings.password_policy_*`: minimum
length, character classes, and, when enabled, breached-password screening
- via haveibeenpwned.com).
+ via haveibeenpwned.com). A successful change lifts any pending forced
+ password reset (`password_reset_required`) on the account.
Parameters:
- current_password: str - The user's current password.
@@ -105,7 +106,11 @@ async def change_password(
validate_password_policy(data.new_password, general_settings)
await validate_password_not_breached(data.new_password, general_settings)
- password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"password": hash_password(data.new_password)}
+ password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {
+ "password": hash_password(data.new_password),
+ "password_reset_required": False,
+ "last_breach_check_at": None,
+ }
await _user_table(prisma_client).update(where=find_user, data=password_update)
verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py
index 1ba90725eff..5279ebcfcc8 100644
--- a/litellm/proxy/management_endpoints/ui_sso.py
+++ b/litellm/proxy/management_endpoints/ui_sso.py
@@ -3666,6 +3666,7 @@ class SSOAuthenticationHandler:
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=False,
)
from litellm.proxy.auth.login_utils import encode_ui_session_jwt
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index efe3d32e6e5..d859e6c19c8 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -16137,6 +16137,7 @@ async def onboarding(invite_link: str, request: Request):
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=False,
)
jwt_token: Final = jwt.encode(
cast(dict, returned_ui_token_object),
@@ -16246,6 +16247,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=False,
)
assert master_key is not None
return jwt.encode(
@@ -16336,7 +16338,12 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
### UPDATE USER OBJECT ###
user_obj: Final[_UserTableRow | None] = await tx.litellm_usertable.update(
- where={"user_id": invite_obj.user_id}, data={"password": hashed_pw}
+ where={"user_id": invite_obj.user_id},
+ data={
+ "password": hashed_pw,
+ "password_reset_required": False,
+ "last_breach_check_at": None,
+ },
)
if user_obj is None:
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index 7d521d54791..a2b32dd8e73 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -241,6 +241,8 @@ model LiteLLM_UserTable {
organization_id String?
object_permission_id String?
password String?
+ password_reset_required Boolean?
+ last_breach_check_at DateTime?
teams String[] @default([])
user_role String?
max_budget Float?
diff --git a/litellm/types/proxy/ui_sso.py b/litellm/types/proxy/ui_sso.py
index 0d7e0b99cf0..03b0b92a4d1 100644
--- a/litellm/types/proxy/ui_sso.py
+++ b/litellm/types/proxy/ui_sso.py
@@ -1,6 +1,6 @@
from typing import Literal
-from typing_extensions import TypedDict
+from typing_extensions import ReadOnly, TypedDict
class ReturnedUITokenObject(TypedDict):
@@ -17,6 +17,7 @@ class ReturnedUITokenObject(TypedDict):
auth_header_name: str
disabled_non_admin_personal_key_creation: bool
server_root_path: str # e.g. `/litellm`
+ password_reset_required: ReadOnly[bool]
class ParsedOpenIDResult(TypedDict, total=False):
diff --git a/schema.prisma b/schema.prisma
index 7d521d54791..a2b32dd8e73 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -241,6 +241,8 @@ model LiteLLM_UserTable {
organization_id String?
object_permission_id String?
password String?
+ password_reset_required Boolean?
+ last_breach_check_at DateTime?
teams String[] @default([])
user_role String?
max_budget Float?
diff --git a/tests/test_litellm/models/test_models.py b/tests/test_litellm/models/test_models.py
index aa6449c98dd..02d3a519586 100644
--- a/tests/test_litellm/models/test_models.py
+++ b/tests/test_litellm/models/test_models.py
@@ -93,9 +93,7 @@ class TestCredentials:
assert item.credential_values is None
def test_create_credential_item_requires_values_or_model_id(self):
- with pytest.raises(
- ValueError, match="Either credential_values or model_id must be set"
- ):
+ with pytest.raises(ValueError, match="Either credential_values or model_id must be set"):
CreateCredentialItem(credential_name="bad", credential_info={})
@@ -113,12 +111,8 @@ class TestModel:
assert model.team_public_model_name == "my-gpt4"
def test_is_blocked(self):
- model_blocked = LiteLLM_ProxyModelTable(
- model_id="m1", model_name="test", litellm_params={}, blocked=True
- )
- model_unblocked = LiteLLM_ProxyModelTable(
- model_id="m2", model_name="test", litellm_params={}, blocked=False
- )
+ model_blocked = LiteLLM_ProxyModelTable(model_id="m1", model_name="test", litellm_params={}, blocked=True)
+ model_unblocked = LiteLLM_ProxyModelTable(model_id="m2", model_name="test", litellm_params={}, blocked=False)
assert model_blocked.is_blocked
assert not model_unblocked.is_blocked
@@ -160,9 +154,7 @@ class TestModel:
assert model.blocked is True
def test_team_helpers_none_when_no_model_info(self):
- model = LiteLLM_ProxyModelTable(
- model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None
- )
+ model = LiteLLM_ProxyModelTable(model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None)
assert model.team_id is None
assert model.team_public_model_name is None
@@ -264,9 +256,7 @@ class TestTeam:
assert team.model_max_budget == {"gpt-4": 5.0}
def test_cached_team(self):
- cached = LiteLLM_TeamTableCachedObj(
- team_id="t1", last_refreshed_at=1234567890.0
- )
+ cached = LiteLLM_TeamTableCachedObj(team_id="t1", last_refreshed_at=1234567890.0)
assert cached.last_refreshed_at == 1234567890.0
def test_deleted_team(self):
@@ -308,6 +298,8 @@ class TestUser:
assert user_no_models.has_model_access("any-model")
def test_password_hash_excluded_from_serialization(self):
+ import json
+
from litellm.proxy._types import LiteLLM_UserTableWithKeyCount
secret = "$2b$12$abcdefghijklmnopqrstuv"
@@ -315,14 +307,12 @@ class TestUser:
assert user.password == secret
assert "password" not in user.model_dump()
- assert "password" not in user.model_dump_json()
+ assert "password" not in json.loads(user.model_dump_json())
- with_keys = LiteLLM_UserTableWithKeyCount(
- user_id="u1", user_email="a@b.c", password=secret, key_count=2
- )
+ with_keys = LiteLLM_UserTableWithKeyCount(user_id="u1", user_email="a@b.c", password=secret, key_count=2)
assert with_keys.password == secret
assert "password" not in with_keys.model_dump()
- assert "password" not in with_keys.model_dump_json()
+ assert "password" not in json.loads(with_keys.model_dump_json())
class TestVerificationToken:
@@ -443,9 +433,7 @@ class TestEndUserTable:
class TestBudgetTableFull:
def test_full_adds_server_managed_fields(self):
now = datetime.now()
- budget = LiteLLM_BudgetTableFull(
- budget_id="b1", max_budget=10.0, created_at=now, budget_reset_at=now
- )
+ budget = LiteLLM_BudgetTableFull(budget_id="b1", max_budget=10.0, created_at=now, budget_reset_at=now)
assert budget.created_at == now
assert budget.budget_reset_at == now
assert budget.max_budget == 10.0
@@ -457,9 +445,7 @@ class TestBudgetTableFull:
class TestTeamMemberTable:
def test_tracks_user_within_team(self):
- member = LiteLLM_TeamMemberTable(
- user_id="u1", team_id="t1", spend=3.0, budget_id="b1", max_budget=5.0
- )
+ member = LiteLLM_TeamMemberTable(user_id="u1", team_id="t1", spend=3.0, budget_id="b1", max_budget=5.0)
assert member.user_id == "u1"
assert member.team_id == "t1"
assert member.spend == 3.0
@@ -549,9 +535,7 @@ class TestSpendLogs:
assert log.updated_at == updated_at
def test_error_logs_creation(self):
- log = LiteLLM_ErrorLogs(
- request_id="r1", startTime=None, endTime=None, status_code="500"
- )
+ log = LiteLLM_ErrorLogs(request_id="r1", startTime=None, endTime=None, status_code="500")
assert log.request_id == "r1"
assert log.status_code == "500"
@@ -569,9 +553,7 @@ class TestManagedTables:
def test_managed_object_table_requires_purpose(self):
with pytest.raises(ValidationError):
- LiteLLM_ManagedObjectTable(
- unified_object_id="o1", model_object_id="m1", file_object={}
- )
+ LiteLLM_ManagedObjectTable(unified_object_id="o1", model_object_id="m1", file_object={})
def test_managed_vector_stores_table(self):
table = LiteLLM_ManagedVectorStoresTable(
diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py
index e209a491b0a..3baaa258879 100644
--- a/tests/test_litellm/proxy/auth/test_login_utils.py
+++ b/tests/test_litellm/proxy/auth/test_login_utils.py
@@ -5,13 +5,17 @@ This module tests the refactored login logic that was moved from proxy_server.py
to login_utils.py for better reusability.
"""
+import hashlib
import os
from contextlib import ExitStack
+from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import pytest
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
@@ -24,8 +28,13 @@ from litellm.proxy.auth.login_utils import (
authenticate_user,
get_ui_credentials,
is_env_credential_login_enabled,
+ screen_login_password_for_breach,
)
+# Successful DB-user logins schedule the background HIBP screen; disable it so
+# no test ever does live network I/O to haveibeenpwned.com from CI.
+_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
+
def test_get_ui_credentials_prefers_explicit_password():
"""The configured UI password should be returned when available."""
@@ -298,12 +307,14 @@ async def test_authenticate_user_email_case_insensitive_login():
password=correct_password,
master_key=master_key,
prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
)
result_lower = await authenticate_user(
username=stored_email,
password=correct_password,
master_key=master_key,
prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
)
assert result_mixed.user_id == result_lower.user_id == "test-user-123"
@@ -541,6 +552,7 @@ async def test_authenticate_user_database_login_with_non_ascii_password():
password=password_with_special_char,
master_key=master_key,
prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
)
assert isinstance(result, LoginResult)
@@ -956,3 +968,263 @@ class TestIsEnvCredentialLoginEnabled:
with ExitStack() as stack:
_patch_sso_configured(stack, configured=False)
assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is True
+
+
+def _db_user_row(*, password: str, password_reset_required: bool | None = None, last_breach_check_at=None):
+ hashed = hash_token(token=password)
+ row = MagicMock()
+ row.user_id = "reset-user-1"
+ row.user_email = "reset@example.com"
+ row.password = hashed
+ row.user_role = LitellmUserRoles.INTERNAL_USER
+ row.password_reset_required = password_reset_required
+ row.last_breach_check_at = last_breach_check_at
+ return row
+
+
+def _prisma_with_user(row) -> MagicMock:
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=row)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=row)
+ return mock_prisma_client
+
+
+_DB_LOGIN_ENV = {
+ "DATABASE_URL": "postgresql://test:test@localhost/test",
+ "UI_USERNAME": "admin",
+ "UI_PASSWORD": "admin-password",
+}
+
+
+class TestPasswordResetRequiredSessionMinting:
+ """A user flagged `password_reset_required` must receive a UI session key
+ restricted to the change-password endpoint (server-side enforcement, so a
+ script driving the management API with the session key is blocked too);
+ an unflagged user must keep getting an unrestricted key."""
+
+ async def _login(self, mock_prisma_client) -> tuple[LoginResult, dict]:
+ with patch.dict(os.environ, _DB_LOGIN_ENV):
+ with patch( # test-quality-ok: asserting the minted key's restriction requires seeing its kwargs
+ "litellm.proxy.auth.login_utils.generate_key_helper_fn",
+ new_callable=AsyncMock,
+ return_value={"token": "session-token"},
+ ) as mock_generate_key:
+ result = await authenticate_user(
+ username="reset@example.com",
+ password="Str0ng!Passw0rd",
+ master_key="sk-1234",
+ prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
+ )
+ return result, mock_generate_key.call_args.kwargs
+
+ @pytest.mark.asyncio
+ async def test_flagged_user_gets_key_restricted_to_change_password(self):
+ row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=True)
+ result, key_kwargs = await self._login(_prisma_with_user(row))
+
+ assert key_kwargs["allowed_routes"] == ["/user/password/change"]
+ assert key_kwargs["metadata"] == {"password_reset_required": True}
+ assert result.password_reset_required is True
+
+ @pytest.mark.asyncio
+ async def test_unflagged_user_gets_unrestricted_key(self):
+ row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None)
+ result, key_kwargs = await self._login(_prisma_with_user(row))
+
+ assert key_kwargs["allowed_routes"] is None
+ assert not key_kwargs["metadata"]
+ assert result.password_reset_required is False
+
+ async def _login_with_screen_result(self, mock_prisma_client, breached: bool) -> tuple[LoginResult, dict, dict]:
+ with patch.dict(os.environ, _DB_LOGIN_ENV):
+ with patch( # test-quality-ok: asserting the minted key's restriction requires seeing its kwargs
+ "litellm.proxy.auth.login_utils.generate_key_helper_fn",
+ new_callable=AsyncMock,
+ return_value={"token": "session-token"},
+ ) as mock_generate_key:
+ with (
+ patch( # test-quality-ok: authenticate_user has no HIBP client seam; the screen itself is tested against MockTransport below
+ "litellm.proxy.auth.login_utils.screen_login_password_for_breach",
+ new_callable=AsyncMock,
+ return_value=breached,
+ ) as mock_screen
+ ):
+ result = await authenticate_user(
+ username="reset@example.com",
+ password="Str0ng!Passw0rd",
+ master_key="sk-1234",
+ prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
+ )
+ return result, mock_generate_key.call_args.kwargs, mock_screen.call_args.kwargs
+
+ @pytest.mark.asyncio
+ async def test_login_screens_with_row_state_before_minting(self):
+ """The login must hand the screen the row's recheck timestamp, or the
+ 24h throttle can never work."""
+ checked_at = datetime.now(timezone.utc) - timedelta(hours=1)
+ row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at)
+ mock_prisma_client = _prisma_with_user(row)
+
+ _, _, screen_kwargs = await self._login_with_screen_result(mock_prisma_client, breached=False)
+
+ assert screen_kwargs["user_id"] == "reset-user-1"
+ assert screen_kwargs["password"] == "Str0ng!Passw0rd"
+ assert screen_kwargs["last_breach_check_at"] == checked_at
+ assert screen_kwargs["prisma_client"] is mock_prisma_client
+
+ @pytest.mark.asyncio
+ async def test_fresh_breach_hit_restricts_the_current_session(self):
+ """A breach found during THIS login must restrict THIS session, not
+ just the next one."""
+ row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None)
+ mock_prisma_client = _prisma_with_user(row)
+
+ result, key_kwargs, _ = await self._login_with_screen_result(mock_prisma_client, breached=True)
+
+ assert key_kwargs["allowed_routes"] == ["/user/password/change"]
+ assert key_kwargs["metadata"] == {"password_reset_required": True}
+ assert result.password_reset_required is True
+
+
+def _sha1_upper(password: str) -> str:
+ return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+
+
+def _client_with_transport(handler) -> AsyncHTTPHandler:
+ http_handler = AsyncHTTPHandler()
+ http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ return http_handler
+
+
+def _client_returning_breach_hit(password: str) -> AsyncHTTPHandler:
+ body = f"{_sha1_upper(password)[5:]}:42"
+ return _client_with_transport(lambda request: httpx.Response(200, text=body))
+
+
+def _client_returning_no_hit() -> AsyncHTTPHandler:
+ return _client_with_transport(lambda request: httpx.Response(200, text="0000000000000000000000000000000000A:3"))
+
+
+def _client_never_called() -> AsyncHTTPHandler:
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise AssertionError(f"unexpected HTTP call to {request.url}")
+
+ return _client_with_transport(handler)
+
+
+class TestScreenLoginPasswordForBreach:
+ """The awaited login-time screen: flags a breached password for a forced
+ reset, stamps the recheck timestamp, rechecks at most every 24h, returns
+ the breach verdict so the login can restrict the session it is minting,
+ and never raises into the login."""
+
+ @pytest.mark.asyncio
+ async def test_breached_password_sets_reset_flag_and_timestamp(self):
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+
+ breached = await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password=password,
+ last_breach_check_at=None,
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_breach_hit(password),
+ )
+
+ assert breached is True
+ update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
+ assert update_kwargs["where"] == {"user_id": "reset-user-1"}
+ assert update_kwargs["data"]["password_reset_required"] is True
+ assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
+
+ @pytest.mark.asyncio
+ async def test_clean_password_stamps_timestamp_without_flag(self):
+ mock_prisma_client = _prisma_with_user(None)
+
+ breached = await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password="Str0ng!Passw0rd",
+ last_breach_check_at=None,
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_no_hit(),
+ )
+
+ assert breached is False
+ update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
+ assert "password_reset_required" not in update_kwargs["data"]
+ assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
+
+ @pytest.mark.asyncio
+ async def test_skips_hibp_when_checked_within_24_hours(self):
+ mock_prisma_client = _prisma_with_user(None)
+
+ breached = await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password="Password123!",
+ last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=23),
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_never_called(),
+ )
+
+ assert breached is False
+ mock_prisma_client.db.litellm_usertable.update.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_rechecks_when_last_check_is_older_than_24_hours(self):
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+
+ breached = await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password=password,
+ last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=25),
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_breach_hit(password),
+ )
+
+ assert breached is True
+ assert (
+ mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True
+ )
+
+ @pytest.mark.asyncio
+ async def test_skips_hibp_when_check_disabled(self):
+ mock_prisma_client = _prisma_with_user(None)
+
+ breached = await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password="Password123!",
+ last_breach_check_at=None,
+ general_settings=_POLICY_NO_BREACH_CHECK,
+ prisma_client=mock_prisma_client,
+ client=_client_never_called(),
+ )
+
+ assert breached is False
+ mock_prisma_client.db.litellm_usertable.update.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_db_failure_never_raises_but_still_reports_the_breach(self):
+ """A failed flag write must not fail the login, but the breach verdict
+ still has to restrict the session being minted right now."""
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(side_effect=RuntimeError("db down"))
+
+ assert (
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password=password,
+ last_breach_check_at=None,
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_breach_hit(password),
+ )
+ is True
+ )
diff --git a/tests/test_litellm/proxy/auth/test_onboarding.py b/tests/test_litellm/proxy/auth/test_onboarding.py
index 8939baedd50..0454aea1239 100644
--- a/tests/test_litellm/proxy/auth/test_onboarding.py
+++ b/tests/test_litellm/proxy/auth/test_onboarding.py
@@ -463,6 +463,10 @@ async def test_claim_token_sets_accepted_at_after_password_written():
call_kwargs = prisma.db.litellm_usertable.update.call_args
assert call_kwargs.kwargs["where"] == {"user_id": "user-123"}
assert "password" in call_kwargs.kwargs["data"]
+ # A freshly claimed, policy-screened password lifts any pending forced
+ # reset and re-arms the login-time breach screen.
+ assert call_kwargs.kwargs["data"]["password_reset_required"] is False
+ assert call_kwargs.kwargs["data"]["last_breach_check_at"] is None
# is_accepted was flipped to True on the invitation link
prisma.db.litellm_invitationlink.update.assert_called_once()
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index 4f58e3c86ff..83ebc3c9225 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -3717,6 +3717,67 @@ def test_non_admin_roles_can_change_own_password(user_role):
assert allowed is None
+def _password_reset_session_token() -> UserAPIKeyAuth:
+ """The UI session key `authenticate_user` mints for a user flagged
+ `password_reset_required`."""
+ return UserAPIKeyAuth(
+ user_id="flagged_user",
+ allowed_routes=["/user/password/change"],
+ metadata={"password_reset_required": True},
+ )
+
+
+def test_password_reset_session_can_reach_change_password():
+ result = RouteChecks.is_virtual_key_allowed_to_call_route(
+ route="/user/password/change",
+ valid_token=_password_reset_session_token(),
+ )
+
+ assert result is True
+
+
+@pytest.mark.parametrize(
+ "route",
+ [
+ "/user/info",
+ "/key/generate",
+ "/user/update",
+ "/chat/completions",
+ ],
+)
+def test_password_reset_session_is_blocked_everywhere_else_with_reset_message(route):
+ """Server-side enforcement of the forced reset: a script that logs in via
+ /v2/login and drives the management API with the session key must get a 403
+ naming the remediation endpoint, on every route but the change-password one."""
+ with pytest.raises(HTTPException) as exc_info:
+ RouteChecks.is_virtual_key_allowed_to_call_route(
+ route=route,
+ valid_token=_password_reset_session_token(),
+ )
+
+ assert exc_info.value.status_code == 403
+ assert "password must be changed" in str(exc_info.value.detail)
+ assert "/user/password/change" in str(exc_info.value.detail)
+
+
+def test_restricted_key_without_reset_marker_keeps_generic_message():
+ """The reset-specific 403 must not leak onto ordinary allowed_routes keys."""
+ valid_token = UserAPIKeyAuth(
+ user_id="test_user",
+ allowed_routes=["/chat/completions"],
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ RouteChecks.is_virtual_key_allowed_to_call_route(
+ route="/user/info",
+ valid_token=valid_token,
+ )
+
+ assert exc_info.value.status_code == 403
+ assert "password must be changed" not in str(exc_info.value.detail)
+ assert "not allowed to call this route" in str(exc_info.value.detail)
+
+
TEAM_CALLBACK_ROUTES = (
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback",
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback/langfuse",
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 f919e092e27..c1f60b02405 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
@@ -4269,6 +4269,10 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo
written_data = mock_prisma_client.update_data.call_args.kwargs["data"]
assert written_data.get("password") is not None
assert written_data["password"] != strong_password
+ # An admin-set password is known to the admin, so the user must be forced
+ # to change it at next login and the breach screen re-armed.
+ assert written_data["password_reset_required"] is True
+ assert written_data["last_breach_check_at"] is None
@pytest.mark.asyncio
diff --git a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
index 4c6de608003..bd154ebab41 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
@@ -74,6 +74,10 @@ async def test_change_password_success_writes_new_scrypt_hash():
stored = update_kwargs["data"]["password"]
assert stored != NEW_PASSWORD
assert verify_password(NEW_PASSWORD, stored)
+ # A successful change lifts any pending forced reset and re-arms the
+ # login-time breach screen for the new password.
+ assert update_kwargs["data"]["password_reset_required"] is False
+ assert update_kwargs["data"]["last_breach_check_at"] is None
@pytest.mark.asyncio
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
index e78f170cf0c..c4cf8ebcf9d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
@@ -1,16 +1,19 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChangePasswordForm from "./ChangePasswordForm";
const mockChangePasswordCall = vi.fn();
const mockToastSuccess = vi.fn();
+const mockClearTokenCookies = vi.fn();
+let mockPasswordResetRequired = false;
vi.mock("@/components/networking", () => ({
changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args),
+ getProxyBaseUrl: () => "",
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
- default: () => ({ accessToken: "sk-session-token" }),
+ default: () => ({ accessToken: "sk-session-token", passwordResetRequired: mockPasswordResetRequired }),
}));
vi.mock("@/lib/toast", () => ({
@@ -20,6 +23,10 @@ vi.mock("@/lib/toast", () => ({
},
}));
+vi.mock("@/utils/cookieUtils", () => ({
+ clearTokenCookies: (...args: unknown[]) => mockClearTokenCookies(...args),
+}));
+
const fillForm = (values: { current: string; next: string; confirm: string }) => {
fireEvent.change(screen.getByLabelText("Current Password"), { target: { value: values.current } });
fireEvent.change(screen.getByLabelText("New Password"), { target: { value: values.next } });
@@ -31,6 +38,7 @@ const submit = () => fireEvent.click(screen.getByRole("button", { name: "Change
describe("ChangePasswordForm", () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPasswordResetRequired = false;
});
it("sends the current and new password to the change endpoint and resets on success", async () => {
@@ -65,4 +73,38 @@ describe("ChangePasswordForm", () => {
expect(await screen.findByText("Current password is incorrect.")).toBeInTheDocument();
expect(mockToastSuccess).not.toHaveBeenCalled();
});
+
+ describe("forced password reset", () => {
+ it("shows the forced-reset warning only when the session is flagged", () => {
+ mockPasswordResetRequired = true;
+ render(