feat(auth): force password reset for breached or admin-set passwords

This commit is contained in:
Oliver Jensen 2026-09-07 12:47:39 +02:00 committed by GitHub
parent 11775ff817
commit fcbe23f6a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 579 additions and 14 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

@ -5,6 +5,7 @@ This module contains the core login logic that can be reused across different
login endpoints (e.g., /login and /v2/login).
"""
import asyncio
import os
import secrets
from collections.abc import Mapping
@ -16,8 +17,10 @@ import jwt
from fastapi import HTTPException
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME, LITELLM_UI_SESSION_DURATION
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
@ -27,6 +30,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
from litellm.proxy.auth.password_policy import is_breach_check_enabled, is_password_breached
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
@ -44,6 +48,48 @@ from litellm.repositories.user_repository import UserRepository
from litellm.secret_managers.main import get_secret_bool
from litellm.types.proxy.ui_sso import ReturnedUITokenObject
BREACH_RECHECK_INTERVAL: Final = timedelta(hours=24)
PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change",)
def _breach_recheck_due(last_breach_check_at: datetime | None) -> bool:
if last_breach_check_at is None:
return True
last_checked_utc: Final = (
last_breach_check_at
if last_breach_check_at.tzinfo is not None
else last_breach_check_at.replace(tzinfo=timezone.utc)
)
return datetime.now(timezone.utc) - last_checked_utc >= BREACH_RECHECK_INTERVAL
async def screen_login_password_for_breach(
user_id: str,
password: str,
last_breach_check_at: datetime | None,
general_settings: Mapping[str, object],
prisma_client: PrismaClient,
client: AsyncHTTPHandler | None = None,
) -> None:
"""Background task behind a successful password login: screens the password
against HIBP and stamps ``password_reset_required`` when breached, so the
NEXT login is restricted to the change-password flow. Never blocks or fails
the login it runs behind, and rechecks a given user at most once per
``BREACH_RECHECK_INTERVAL``."""
if not is_breach_check_enabled(general_settings):
return
if not _breach_recheck_due(last_breach_check_at):
return
breached: Final = await is_password_breached(password, general_settings, client)
update_data: Final = {
"last_breach_check_at": datetime.now(timezone.utc),
**({"password_reset_required": True} if breached else {}),
}
try:
await UserRepository(prisma_client).table.update(where={"user_id": user_id}, data=update_data)
except Exception as e: # noqa: BLE001 # fire-and-forget: a failed stamp must never surface into the login
verbose_proxy_logger.warning("Login-time breach screening could not update user %s: %s", user_id, e)
async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None:
"""Rehash legacy password (SHA256) to scrypt on successful login."""
@ -116,6 +162,7 @@ class LoginResult:
user_email: str | None
user_role: str
login_method: Literal["sso", "username_password"]
password_reset_required: bool
def __init__(
self,
@ -124,12 +171,14 @@ class LoginResult:
user_email: str | None,
user_role: str,
login_method: Literal["sso", "username_password"] = "username_password",
password_reset_required: bool = False,
):
self.user_id = user_id
self.key = key
self.user_email = user_email
self.user_role = user_role
self.login_method = login_method
self.password_reset_required = password_reset_required
async def authenticate_user(
@ -322,6 +371,17 @@ async def authenticate_user(
if verify_password(password, _password):
await _rehash_password_if_needed(_user_row.user_id, password, _password)
if prisma_client is not None:
asyncio.create_task(
screen_login_password_for_breach(
user_id=_user_row.user_id,
password=password,
last_breach_check_at=getattr(_user_row, "last_breach_check_at", None),
general_settings=general_settings,
prisma_client=prisma_client,
)
)
password_reset_required: Final = getattr(_user_row, "password_reset_required", None) is True
if os.getenv("DATABASE_URL") is not None:
response = await generate_key_helper_fn(
request_type="key",
@ -335,6 +395,14 @@ async def authenticate_user(
"spend": 0,
"user_id": user_id,
"team_id": "litellm-dashboard",
**(
{
"allowed_routes": list(PASSWORD_RESET_ALLOWED_ROUTES),
"metadata": {"password_reset_required": True},
}
if password_reset_required
else {}
),
},
)
else:
@ -353,6 +421,7 @@ async def authenticate_user(
user_email=user_email,
user_role=cast(str, user_role),
login_method="username_password",
password_reset_required=password_reset_required,
)
else:
raise ProxyException(
@ -426,4 +495,5 @@ def create_ui_token_object(
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
password_reset_required=login_result.password_reset_required,
)

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

@ -168,12 +168,17 @@ async def _hash_password_in_dict(
"""Validate and hash password field in-place if present.
``password_prevalidated`` skips the policy checks for callers that already
validated the password (the bulk path screens its whole batch upfront)."""
validated the password (the bulk path screens its whole batch upfront).
An admin-set password is known to whoever set it, so the user is also
flagged for a forced password change at next login."""
if "password" in data and data["password"] is not None:
if not password_prevalidated:
validate_password_policy(data["password"], general_settings)
await validate_password_not_breached(data["password"], general_settings)
data["password"] = hash_password(data["password"])
data["password_reset_required"] = True
data["last_breach_check_at"] = None
def _strip_password_from_response(response) -> None:
@ -1625,7 +1630,7 @@ async def user_update(
Parameters:
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
- user_email: Optional[str] - Specify a user email.
- password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
- password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. The user is required to change it at their next login. Users change their own password with POST /user/password/change.
- user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
- teams: Optional[list] - specify a list of team id's a user belongs to.
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.

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

@ -16134,6 +16134,7 @@ async def onboarding(invite_link: str, request: Request):
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
password_reset_required=False,
)
jwt_token: Final = jwt.encode(
cast(dict, returned_ui_token_object),
@ -16243,6 +16244,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
password_reset_required=False,
)
assert master_key is not None
return jwt.encode(
@ -16333,7 +16335,12 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
### UPDATE USER OBJECT ###
user_obj: Final[_UserTableRow | None] = await tx.litellm_usertable.update(
where={"user_id": invite_obj.user_id}, data={"password": hashed_pw}
where={"user_id": invite_obj.user_id},
data={
"password": hashed_pw,
"password_reset_required": False,
"last_breach_check_at": None,
},
)
if user_obj is None:

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

@ -5,13 +5,17 @@ This module tests the refactored login logic that was moved from proxy_server.py
to login_utils.py for better reusability.
"""
import hashlib
import os
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
@ -24,8 +28,13 @@ from litellm.proxy.auth.login_utils import (
authenticate_user,
get_ui_credentials,
is_env_credential_login_enabled,
screen_login_password_for_breach,
)
# Successful DB-user logins schedule the background HIBP screen; disable it so
# no test ever does live network I/O to haveibeenpwned.com from CI.
_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
def test_get_ui_credentials_prefers_explicit_password():
"""The configured UI password should be returned when available."""
@ -298,12 +307,14 @@ async def test_authenticate_user_email_case_insensitive_login():
password=correct_password,
master_key=master_key,
prisma_client=mock_prisma_client,
general_settings=_POLICY_NO_BREACH_CHECK,
)
result_lower = await authenticate_user(
username=stored_email,
password=correct_password,
master_key=master_key,
prisma_client=mock_prisma_client,
general_settings=_POLICY_NO_BREACH_CHECK,
)
assert result_mixed.user_id == result_lower.user_id == "test-user-123"
@ -541,6 +552,7 @@ async def test_authenticate_user_database_login_with_non_ascii_password():
password=password_with_special_char,
master_key=master_key,
prisma_client=mock_prisma_client,
general_settings=_POLICY_NO_BREACH_CHECK,
)
assert isinstance(result, LoginResult)
@ -956,3 +968,234 @@ class TestIsEnvCredentialLoginEnabled:
with ExitStack() as stack:
_patch_sso_configured(stack, configured=False)
assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is True
def _db_user_row(*, password: str, password_reset_required: bool | None = None, last_breach_check_at=None):
hashed = hash_token(token=password)
row = MagicMock()
row.user_id = "reset-user-1"
row.user_email = "reset@example.com"
row.password = hashed
row.user_role = LitellmUserRoles.INTERNAL_USER
row.password_reset_required = password_reset_required
row.last_breach_check_at = last_breach_check_at
return row
def _prisma_with_user(row) -> MagicMock:
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=row)
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=row)
return mock_prisma_client
_DB_LOGIN_ENV = {
"DATABASE_URL": "postgresql://test:test@localhost/test",
"UI_USERNAME": "admin",
"UI_PASSWORD": "admin-password",
}
class TestPasswordResetRequiredSessionMinting:
"""A user flagged `password_reset_required` must receive a UI session key
restricted to the change-password endpoint (server-side enforcement, so a
script driving the management API with the session key is blocked too);
an unflagged user must keep getting an unrestricted key."""
async def _login(self, mock_prisma_client) -> tuple[LoginResult, dict]:
with patch.dict(os.environ, _DB_LOGIN_ENV):
with patch(
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
new_callable=AsyncMock,
return_value={"token": "session-token"},
) as mock_generate_key:
result = await authenticate_user(
username="reset@example.com",
password="Str0ng!Passw0rd",
master_key="sk-1234",
prisma_client=mock_prisma_client,
general_settings=_POLICY_NO_BREACH_CHECK,
)
return result, mock_generate_key.call_args.kwargs
@pytest.mark.asyncio
async def test_flagged_user_gets_key_restricted_to_change_password(self):
row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=True)
result, key_kwargs = await self._login(_prisma_with_user(row))
assert key_kwargs["allowed_routes"] == ["/user/password/change"]
assert key_kwargs["metadata"] == {"password_reset_required": True}
assert result.password_reset_required is True
@pytest.mark.asyncio
async def test_unflagged_user_gets_unrestricted_key(self):
row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None)
result, key_kwargs = await self._login(_prisma_with_user(row))
assert "allowed_routes" not in key_kwargs
assert "metadata" not in key_kwargs
assert result.password_reset_required is False
@pytest.mark.asyncio
async def test_login_schedules_breach_screen_with_row_state(self):
"""The login must hand the background screen the row's recheck timestamp,
or the 24h throttle can never work."""
checked_at = datetime.now(timezone.utc) - timedelta(hours=1)
row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at)
mock_prisma_client = _prisma_with_user(row)
with patch.dict(os.environ, _DB_LOGIN_ENV):
with patch(
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
new_callable=AsyncMock,
return_value={"token": "session-token"},
):
with patch(
"litellm.proxy.auth.login_utils.screen_login_password_for_breach",
new_callable=AsyncMock,
) as mock_screen:
await authenticate_user(
username="reset@example.com",
password="Str0ng!Passw0rd",
master_key="sk-1234",
prisma_client=mock_prisma_client,
general_settings=_POLICY_NO_BREACH_CHECK,
)
screen_kwargs = mock_screen.call_args.kwargs
assert screen_kwargs["user_id"] == "reset-user-1"
assert screen_kwargs["password"] == "Str0ng!Passw0rd"
assert screen_kwargs["last_breach_check_at"] == checked_at
assert screen_kwargs["prisma_client"] is mock_prisma_client
def _sha1_upper(password: str) -> str:
return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
def _client_with_transport(handler) -> AsyncHTTPHandler:
http_handler = AsyncHTTPHandler()
http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return http_handler
def _client_returning_breach_hit(password: str) -> AsyncHTTPHandler:
body = f"{_sha1_upper(password)[5:]}:42"
return _client_with_transport(lambda request: httpx.Response(200, text=body))
def _client_returning_no_hit() -> AsyncHTTPHandler:
return _client_with_transport(lambda request: httpx.Response(200, text="0000000000000000000000000000000000A:3"))
def _client_never_called() -> AsyncHTTPHandler:
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"unexpected HTTP call to {request.url}")
return _client_with_transport(handler)
class TestScreenLoginPasswordForBreach:
"""The fire-and-forget login-time screen: flags a breached password for a
forced reset, stamps the recheck timestamp, rechecks at most every 24h,
and never raises into the login it runs behind."""
@pytest.mark.asyncio
async def test_breached_password_sets_reset_flag_and_timestamp(self):
password = "Password123!"
mock_prisma_client = _prisma_with_user(None)
await screen_login_password_for_breach(
user_id="reset-user-1",
password=password,
last_breach_check_at=None,
general_settings={},
prisma_client=mock_prisma_client,
client=_client_returning_breach_hit(password),
)
update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
assert update_kwargs["where"] == {"user_id": "reset-user-1"}
assert update_kwargs["data"]["password_reset_required"] is True
assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
@pytest.mark.asyncio
async def test_clean_password_stamps_timestamp_without_flag(self):
mock_prisma_client = _prisma_with_user(None)
await screen_login_password_for_breach(
user_id="reset-user-1",
password="Str0ng!Passw0rd",
last_breach_check_at=None,
general_settings={},
prisma_client=mock_prisma_client,
client=_client_returning_no_hit(),
)
update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
assert "password_reset_required" not in update_kwargs["data"]
assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
@pytest.mark.asyncio
async def test_skips_hibp_when_checked_within_24_hours(self):
mock_prisma_client = _prisma_with_user(None)
await screen_login_password_for_breach(
user_id="reset-user-1",
password="Password123!",
last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=23),
general_settings={},
prisma_client=mock_prisma_client,
client=_client_never_called(),
)
mock_prisma_client.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
async def test_rechecks_when_last_check_is_older_than_24_hours(self):
password = "Password123!"
mock_prisma_client = _prisma_with_user(None)
await screen_login_password_for_breach(
user_id="reset-user-1",
password=password,
last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=25),
general_settings={},
prisma_client=mock_prisma_client,
client=_client_returning_breach_hit(password),
)
assert mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True
@pytest.mark.asyncio
async def test_skips_hibp_when_check_disabled(self):
mock_prisma_client = _prisma_with_user(None)
await screen_login_password_for_breach(
user_id="reset-user-1",
password="Password123!",
last_breach_check_at=None,
general_settings=_POLICY_NO_BREACH_CHECK,
prisma_client=mock_prisma_client,
client=_client_never_called(),
)
mock_prisma_client.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
async def test_db_failure_never_raises_into_the_login(self):
password = "Password123!"
mock_prisma_client = _prisma_with_user(None)
mock_prisma_client.db.litellm_usertable.update = AsyncMock(side_effect=RuntimeError("db down"))
assert (
await screen_login_password_for_breach(
user_id="reset-user-1",
password=password,
last_breach_check_at=None,
general_settings={},
prisma_client=mock_prisma_client,
client=_client_returning_breach_hit(password),
)
is None
)

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

@ -4141,6 +4141,10 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo
written_data = mock_prisma_client.update_data.call_args.kwargs["data"]
assert written_data.get("password") is not None
assert written_data["password"] != strong_password
# An admin-set password is known to the admin, so the user must be forced
# to change it at next login and the breach screen re-armed.
assert written_data["password_reset_required"] is True
assert written_data["last_breach_check_at"] is None
@pytest.mark.asyncio

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,16 @@ 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. 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,59 @@ 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 () => {
document.cookie = `token=${sessionCookie({
user_id: "flagged-user",
key: "sk-session",
login_method: "username_password",
password_reset_required: true,
})}; 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

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

@ -16787,7 +16787,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.
@ -16847,7 +16848,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.
@ -30241,6 +30242,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 */
@ -30275,6 +30278,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 []
@ -30334,6 +30339,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 */
@ -30368,6 +30375,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 []