mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge 522b6fe4fe into cfc9069491
This commit is contained in:
commit
b48c868884
25 changed files with 630 additions and 59 deletions
|
|
@ -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);
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue