feat(auth): screen the login password inline and restrict the session on a fresh breach hit

A breach found during a login previously only flagged the account for the
NEXT login, handing out one free unrestricted 24h session. The HIBP screen
is now awaited before the session key is minted (worst case one 5s window
per user per 24h, fail-open unchanged), so a fresh hit restricts the
current session and the dashboard routes straight to change-password.

Also repairs two casualties of merge f5e47974db that the layout tests
caught: the lost usePathname import and a call to migratedHref, which
staging renamed to uiHref.
This commit is contained in:
Oliver Jensen 2026-09-09 10:56:49 +02:00 committed by GitHub
parent fcbe23f6a8
commit 7e8683aef0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 79 additions and 52 deletions

View file

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

View file

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

View file

@ -75,8 +75,9 @@ export function ChangePasswordForm() {
<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.
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>
)}

View file

@ -7,7 +7,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { useAuth } from "@/contexts/AuthContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
@ -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]);