diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index a5809ce0899..75573561266 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -5,7 +5,6 @@ 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 @@ -70,16 +69,16 @@ async def screen_login_password_for_breach( 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``.""" +) -> 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 + return False if not _breach_recheck_due(last_breach_check_at): - return + return False breached: Final = await is_password_breached(password, general_settings, client) update_data: Final = { "last_breach_check_at": datetime.now(timezone.utc), @@ -87,8 +86,9 @@ async def screen_login_password_for_breach( } 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 + 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: @@ -371,17 +371,14 @@ 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 + 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", diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index 16547db527c..1afb459a74f 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -1036,38 +1036,57 @@ class TestPasswordResetRequiredSessionMinting: 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) - + 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( + 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"}, - ): - with patch( - "litellm.proxy.auth.login_utils.screen_login_password_for_breach", - new_callable=AsyncMock, - ) as mock_screen: - await authenticate_user( + ) 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) - 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 + @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() @@ -1096,16 +1115,17 @@ def _client_never_called() -> AsyncHTTPHandler: 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.""" + """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) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password=password, last_breach_check_at=None, @@ -1114,6 +1134,7 @@ class TestScreenLoginPasswordForBreach: 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 @@ -1123,7 +1144,7 @@ class TestScreenLoginPasswordForBreach: async def test_clean_password_stamps_timestamp_without_flag(self): mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password="Str0ng!Passw0rd", last_breach_check_at=None, @@ -1132,6 +1153,7 @@ class TestScreenLoginPasswordForBreach: 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) @@ -1140,7 +1162,7 @@ class TestScreenLoginPasswordForBreach: async def test_skips_hibp_when_checked_within_24_hours(self): mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + 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), @@ -1149,6 +1171,7 @@ class TestScreenLoginPasswordForBreach: client=_client_never_called(), ) + assert breached is False mock_prisma_client.db.litellm_usertable.update.assert_not_called() @pytest.mark.asyncio @@ -1156,7 +1179,7 @@ class TestScreenLoginPasswordForBreach: password = "Password123!" mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + 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), @@ -1165,13 +1188,16 @@ class TestScreenLoginPasswordForBreach: client=_client_returning_breach_hit(password), ) - assert mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True + 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) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password="Password123!", last_breach_check_at=None, @@ -1180,10 +1206,13 @@ class TestScreenLoginPasswordForBreach: 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_into_the_login(self): + 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")) @@ -1197,5 +1226,5 @@ class TestScreenLoginPasswordForBreach: prisma_client=mock_prisma_client, client=_client_returning_breach_hit(password), ) - is None + is True ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx index b29f2a26389..05a6bf3ae94 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx @@ -75,8 +75,9 @@ export function ChangePasswordForm() { - Your password must be changed before you can use the dashboard. After updating it, you will be signed - out to log in again. + 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. )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index e914aa5e346..309d7dd1ac2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -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"; @@ -162,7 +162,7 @@ function LayoutContent({ children }: { children: React.ReactNode }) { // endpoint server-side; keep the UI on the matching page. useEffect(() => { if (!authLoading && passwordResetRequired && !pathname?.endsWith("/change-password")) { - router.replace(migratedHref("change-password")); + router.replace(uiHref("change-password")); } }, [authLoading, passwordResetRequired, pathname, router]);