From fcbe23f6a8b9fe04b26fec6a17bfe538a6e09378 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Mon, 7 Sep 2026 12:47:39 +0200
Subject: [PATCH 1/6] feat(auth): force password reset for breached or
admin-set passwords
---
.../migration.sql | 3 +
.../litellm_proxy_extras/schema.prisma | 2 +
litellm/models/user.py | 2 +
litellm/proxy/auth/login_utils.py | 70 +++++
litellm/proxy/auth/route_checks.py | 10 +
.../internal_user_endpoints.py | 9 +-
.../password_endpoints.py | 9 +-
litellm/proxy/management_endpoints/ui_sso.py | 1 +
litellm/proxy/proxy_server.py | 9 +-
litellm/proxy/schema.prisma | 2 +
litellm/types/proxy/ui_sso.py | 3 +-
schema.prisma | 2 +
.../proxy/auth/test_login_utils.py | 243 ++++++++++++++++++
.../proxy/auth/test_onboarding.py | 4 +
.../proxy/auth/test_route_checks.py | 61 +++++
.../test_internal_user_endpoints.py | 4 +
.../test_password_endpoints.py | 4 +
.../ChangePasswordForm.integration.test.tsx | 46 +++-
.../change-password/ChangePasswordForm.tsx | 23 +-
.../app/(dashboard)/hooks/useAuthorized.ts | 1 +
.../src/app/(dashboard)/layout.test.tsx | 57 +++-
.../src/app/(dashboard)/layout.tsx | 11 +-
.../src/contexts/AuthContext.tsx | 4 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 13 +-
24 files changed, 579 insertions(+), 14 deletions(-)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql
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..a5809ce0899 100644
--- a/litellm/proxy/auth/login_utils.py
+++ b/litellm/proxy/auth/login_utils.py
@@ -5,6 +5,7 @@ This module contains the core login logic that can be reused across different
login endpoints (e.g., /login and /v2/login).
"""
+import asyncio
import os
import secrets
from collections.abc import Mapping
@@ -16,8 +17,10 @@ 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 +30,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 +48,48 @@ from litellm.repositories.user_repository import UserRepository
from litellm.secret_managers.main import get_secret_bool
from litellm.types.proxy.ui_sso import ReturnedUITokenObject
+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,
+) -> None:
+ """Background task behind a successful password login: screens the password
+ against HIBP and stamps ``password_reset_required`` when breached, so the
+ NEXT login is restricted to the change-password flow. Never blocks or fails
+ the login it runs behind, and rechecks a given user at most once per
+ ``BREACH_RECHECK_INTERVAL``."""
+ if not is_breach_check_enabled(general_settings):
+ return
+ if not _breach_recheck_due(last_breach_check_at):
+ return
+ breached: Final = await is_password_breached(password, general_settings, client)
+ update_data: Final = {
+ "last_breach_check_at": datetime.now(timezone.utc),
+ **({"password_reset_required": True} if breached else {}),
+ }
+ try:
+ await UserRepository(prisma_client).table.update(where={"user_id": user_id}, data=update_data)
+ except Exception as e: # noqa: BLE001 # fire-and-forget: 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)
+
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 +162,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 +171,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,6 +371,17 @@ async def authenticate_user(
if verify_password(password, _password):
await _rehash_password_if_needed(_user_row.user_id, password, _password)
+ if prisma_client is not None:
+ asyncio.create_task(
+ 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 = 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",
@@ -335,6 +395,14 @@ async def authenticate_user(
"spend": 0,
"user_id": user_id,
"team_id": "litellm-dashboard",
+ **(
+ {
+ "allowed_routes": list(PASSWORD_RESET_ALLOWED_ROUTES),
+ "metadata": {"password_reset_required": True},
+ }
+ if password_reset_required
+ else {}
+ ),
},
)
else:
@@ -353,6 +421,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 +495,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 b564e3b9f64..2d17aa69745 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -168,12 +168,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:
@@ -1625,7 +1630,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 f6798d51566..e2dbd237016 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -16134,6 +16134,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),
@@ -16243,6 +16244,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(
@@ -16333,7 +16335,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/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py
index e209a491b0a..16547db527c 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,234 @@ 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(
+ "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 "allowed_routes" not in key_kwargs
+ assert "metadata" not in key_kwargs
+ assert result.password_reset_required is False
+
+ @pytest.mark.asyncio
+ async def test_login_schedules_breach_screen_with_row_state(self):
+ """The login must hand the background 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)
+
+ with patch.dict(os.environ, _DB_LOGIN_ENV):
+ with patch(
+ "litellm.proxy.auth.login_utils.generate_key_helper_fn",
+ new_callable=AsyncMock,
+ return_value={"token": "session-token"},
+ ):
+ with patch(
+ "litellm.proxy.auth.login_utils.screen_login_password_for_breach",
+ new_callable=AsyncMock,
+ ) as mock_screen:
+ 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,
+ )
+
+ screen_kwargs = mock_screen.call_args.kwargs
+ 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
+
+
+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 fire-and-forget login-time screen: flags a breached password for a
+ forced reset, stamps the recheck timestamp, rechecks at most every 24h,
+ and never raises into the login it runs behind."""
+
+ @pytest.mark.asyncio
+ async def test_breached_password_sets_reset_flag_and_timestamp(self):
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+
+ 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),
+ )
+
+ 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)
+
+ 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(),
+ )
+
+ 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)
+
+ 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(),
+ )
+
+ 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)
+
+ 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 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)
+
+ 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(),
+ )
+
+ mock_prisma_client.db.litellm_usertable.update.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_db_failure_never_raises_into_the_login(self):
+ 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 None
+ )
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 f742c0012b1..d378bbdeb04 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
@@ -4141,6 +4141,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();
+
+ expect(screen.getByText(/must be changed before you can use the dashboard/)).toBeInTheDocument();
+ });
+
+ it("hides the forced-reset warning for a normal session", () => {
+ render();
+
+ expect(screen.queryByText(/must be changed before you can use the dashboard/)).not.toBeInTheDocument();
+ });
+
+ it("signs the user out to re-login after a successful forced change", async () => {
+ mockPasswordResetRequired = true;
+ mockChangePasswordCall.mockResolvedValue({ user_id: "user-123", message: "Password updated successfully." });
+ const replaceMock = vi.fn();
+ const realLocation = window.location;
+ Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
+
+ try {
+ render();
+ fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" });
+ submit();
+
+ await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/ui/login/"));
+ expect(mockClearTokenCookies).toHaveBeenCalled();
+ } finally {
+ Object.defineProperty(window, "location", { configurable: true, value: realLocation });
+ }
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
index 4c51b7f3d15..b29f2a26389 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
@@ -11,10 +11,12 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
-import { changePasswordCall } from "@/components/networking";
+import { changePasswordCall, getProxyBaseUrl } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
+import { clearTokenCookies } from "@/utils/cookieUtils";
+import { getLoginUrl } from "@/utils/returnUrlUtils";
const changePasswordSchema = z
.object({
@@ -30,7 +32,7 @@ const changePasswordSchema = z
type ChangePasswordValues = z.infer;
export function ChangePasswordForm() {
- const { accessToken } = useAuthorized();
+ const { accessToken, passwordResetRequired } = useAuthorized();
const form = useZodForm(changePasswordSchema, {
defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" },
});
@@ -43,6 +45,13 @@ export function ChangePasswordForm() {
setIsPending(true);
try {
await changePasswordCall(accessToken, values.currentPassword, values.newPassword);
+ if (passwordResetRequired) {
+ // The session key was minted restricted; only a fresh login lifts it.
+ toast.success("Password updated. Please log in with your new password.");
+ clearTokenCookies();
+ window.location.replace(getLoginUrl(getProxyBaseUrl()));
+ return;
+ }
toast.success("Password updated");
form.reset();
} catch (error) {
@@ -62,6 +71,16 @@ export function ChangePasswordForm() {
policy.
+ {passwordResetRequired && (
+
+
+
+ Your password must be changed before you can use the dashboard. After updating it, you will be signed
+ out to log in again.
+
+
+ )}
+