This commit is contained in:
Oliver Jensen 2026-09-12 17:52:42 +02:00 committed by GitHub
commit b48c868884
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 630 additions and 59 deletions

View file

@ -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);

View file

@ -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?

View file

@ -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

View file

@ -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,
)

View file

@ -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}",

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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?

View file

@ -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):

View file

@ -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?

View file

@ -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(

View file

@ -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
)

View file

@ -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()

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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(<ChangePasswordForm />);
expect(screen.getByText(/must be changed before you can use the dashboard/)).toBeInTheDocument();
});
it("hides the forced-reset warning for a normal session", () => {
render(<ChangePasswordForm />);
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(<ChangePasswordForm />);
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 });
}
});
});
});

View file

@ -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<typeof changePasswordSchema>;
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,17 @@ export function ChangePasswordForm() {
policy.
</p>
{passwordResetRequired && (
<Alert variant="warning" className="mt-4">
<CircleAlert />
<AlertTitle>
Your password must be changed before you can use the dashboard: it was either found in a known data
breach or set by an administrator as a temporary password. After updating it, you will be signed out to
log in again.
</AlertTitle>
</Alert>
)}
<form className="mb-2 mt-8" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<FormField control={form.control} name="currentPassword" label="Current Password">

View file

@ -52,6 +52,7 @@ const useAuthorized = () => {
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
loginMethod: decoded?.login_method ?? null,
showSSOBanner: decoded?.login_method === "username_password",
passwordResetRequired: decoded?.password_reset_required === true,
};
};

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { AuthProvider } from "@/contexts/AuthContext";
import Layout from "./layout";
@ -117,4 +117,60 @@ describe("(dashboard) Layout", () => {
expect(screen.queryByTestId("dashboard-header")).not.toBeInTheDocument();
expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument();
});
describe("forced password reset routing", () => {
const sessionCookie = (claims: Record<string, unknown>) => {
const encode = (part: Record<string, unknown>) =>
btoa(JSON.stringify(part)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const exp = Math.floor(Date.now() / 1000) + 3600;
return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ ...claims, exp })}.sig`;
};
afterEach(() => {
document.cookie = "token=; Max-Age=0; Path=/";
});
it("routes a session flagged password_reset_required to the change-password page", async () => {
const flaggedClaims = {
user_id: "flagged-user",
key: "sk-session",
login_method: "username_password",
password_reset_required: true,
};
document.cookie = `token=${sessionCookie(flaggedClaims)}; Path=/`;
render(
<AuthProvider>
<Layout>
<div data-testid="page-content" />
</Layout>
</AuthProvider>,
);
pendingUiConfig.resolve();
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/change-password")));
});
it("does not reroute an unflagged session", async () => {
document.cookie = `token=${sessionCookie({
user_id: "normal-user",
key: "sk-session",
login_method: "username_password",
})}; Path=/`;
render(
<AuthProvider>
<Layout>
<div data-testid="page-content" />
</Layout>
</AuthProvider>,
);
pendingUiConfig.resolve();
expect(await screen.findByTestId("page-content")).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
});
});

View file

@ -7,7 +7,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { useAuth } from "@/contexts/AuthContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
@ -146,7 +146,8 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
function LayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
const { accessToken, authLoading } = useAuth();
const pathname = usePathname();
const { accessToken, authLoading, passwordResetRequired } = useAuth();
const isInvitationFlow = Boolean(searchParams.get("invitation_id"));
// Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own
@ -157,6 +158,14 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
}
}, [authLoading, isInvitationFlow, router, searchParams]);
// A session flagged for a forced password reset can only reach the change-password
// endpoint server-side; keep the UI on the matching page.
useEffect(() => {
if (!authLoading && passwordResetRequired && !pathname?.endsWith("/change-password")) {
router.replace(uiHref("change-password"));
}
}, [authLoading, passwordResetRequired, pathname, router]);
if (authLoading || isInvitationFlow) {
return <LoadingScreen />;
}

View file

@ -24,6 +24,7 @@ type AuthContextValue = {
premiumUser: boolean;
disabledPersonalKeyCreation: boolean;
showSSOBanner: boolean;
passwordResetRequired: boolean;
setToken: React.Dispatch<React.SetStateAction<string | null>>;
setUserID: React.Dispatch<React.SetStateAction<string | null>>;
@ -46,6 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [premiumUser, setPremiumUser] = useState(false);
const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false);
const [showSSOBanner, setShowSSOBanner] = useState(true);
const [passwordResetRequired, setPasswordResetRequired] = useState(false);
// Load runtime UI config (populates proxyBaseUrl etc.) before clearing
// authLoading, so any consumer that builds proxy-rooted URLs from authLoading=false
@ -124,6 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (decoded.user_id) {
setUserID(decoded.user_id);
}
setPasswordResetRequired(decoded.password_reset_required === true);
}, [token]);
const value: AuthContextValue = {
@ -136,6 +139,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
premiumUser,
disabledPersonalKeyCreation,
showSSOBanner,
passwordResetRequired,
setToken,
setUserID,
setUserRole,

View file

@ -16796,7 +16796,8 @@ export interface paths {
* 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.
@ -16856,7 +16857,7 @@ export interface paths {
* 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.
@ -30250,6 +30251,8 @@ export interface components {
budget_reset_at?: string | null;
/** Created At */
created_at?: string | null;
/** Last Breach Check At */
last_breach_check_at?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
@ -30284,6 +30287,8 @@ export interface components {
organization_id?: string | null;
/** Organization Memberships */
organization_memberships?: components["schemas"]["LiteLLM_OrganizationMembershipTable"][] | null;
/** Password Reset Required */
password_reset_required?: boolean | null;
/**
* Policies
* @default []
@ -30343,6 +30348,8 @@ export interface components {
* @default 0
*/
key_count: number;
/** Last Breach Check At */
last_breach_check_at?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
@ -30377,6 +30384,8 @@ export interface components {
organization_id?: string | null;
/** Organization Memberships */
organization_memberships?: components["schemas"]["LiteLLM_OrganizationMembershipTable"][] | null;
/** Password Reset Required */
password_reset_required?: boolean | null;
/**
* Policies
* @default []