|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
import logging
|
|
|
import os
|
|
|
import secrets
|
|
|
+import time
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
from typing import Annotated
|
|
|
|
|
|
@@ -395,7 +396,7 @@ def require_energy_cost_update():
|
|
|
if username is None:
|
|
|
raise credentials_exception
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise credentials_exception
|
|
|
iat: int | float | None = payload.get("iat")
|
|
|
except JWTError:
|
|
|
@@ -798,10 +799,18 @@ async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None
|
|
|
await db.rollback() # jti already revoked — desired state, ignore
|
|
|
|
|
|
|
|
|
-async def is_jti_revoked(jti: str) -> bool:
|
|
|
- """Return True if the given jti has been revoked."""
|
|
|
- async with async_session() as db:
|
|
|
- result = await db.execute(
|
|
|
+async def is_jti_revoked(jti: str, db: AsyncSession | None = None) -> bool:
|
|
|
+ """Return True if the given jti has been revoked.
|
|
|
+
|
|
|
+ Pass ``db`` to reuse the caller's session instead of opening a new one
|
|
|
+ (issue #2572): the permission dependencies already hold a session, and a
|
|
|
+ second checkout per request doubled pool pressure — a login burst then
|
|
|
+ exhausted the pool. With ``db`` omitted a short session is opened as before,
|
|
|
+ for callers that check the jti before they have a session open.
|
|
|
+ """
|
|
|
+
|
|
|
+ async def _query(session: AsyncSession) -> bool:
|
|
|
+ result = await session.execute(
|
|
|
select(AuthEphemeralToken).where(
|
|
|
AuthEphemeralToken.token == jti,
|
|
|
AuthEphemeralToken.token_type == "revoked_jti",
|
|
|
@@ -809,6 +818,11 @@ async def is_jti_revoked(jti: str) -> bool:
|
|
|
)
|
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
|
|
+ if db is not None:
|
|
|
+ return await _query(db)
|
|
|
+ async with async_session() as own_db:
|
|
|
+ return await _query(own_db)
|
|
|
+
|
|
|
|
|
|
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
|
|
"""Get a user by username (case-insensitive) with groups loaded for permission checks."""
|
|
|
@@ -862,6 +876,33 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
|
|
|
return user
|
|
|
|
|
|
|
|
|
+# Short-lived cache for the auth-enabled flag (issue #2572). The middleware
|
|
|
+# and every ownership/permission dependency probe this once (or more) per
|
|
|
+# request; on a large farm that DB round-trip is pure overhead because the
|
|
|
+# value changes only when an admin toggles auth.
|
|
|
+#
|
|
|
+# SECURITY: only a ``True`` (auth-enabled) result is EVER cached. A disabled /
|
|
|
+# unconfigured result is never cached, so a stale cache can only ever cause a
|
|
|
+# request to REQUIRE auth that a moment ago wasn't required — it can never skip
|
|
|
+# an auth check that is now required. Staleness fails CLOSED, never open (cf.
|
|
|
+# GHSA-6mf4-q26m-47pv). ``set_auth_enabled`` invalidates explicitly on any
|
|
|
+# toggle; the TTL is only a backstop for out-of-band changes (a direct DB edit,
|
|
|
+# or another worker process in a multi-worker deployment).
|
|
|
+_AUTH_ENABLED_CACHE_TTL_SECONDS = 30.0
|
|
|
+_auth_enabled_cached_value: bool = False
|
|
|
+_auth_enabled_cached_until: float = 0.0
|
|
|
+
|
|
|
+
|
|
|
+def invalidate_auth_enabled_cache() -> None:
|
|
|
+ """Drop the cached auth-enabled flag so the next probe re-reads the DB.
|
|
|
+
|
|
|
+ Call after any write that toggles the ``auth_enabled`` setting.
|
|
|
+ """
|
|
|
+ global _auth_enabled_cached_value, _auth_enabled_cached_until
|
|
|
+ _auth_enabled_cached_value = False
|
|
|
+ _auth_enabled_cached_until = 0.0
|
|
|
+
|
|
|
+
|
|
|
async def is_auth_enabled(db: AsyncSession) -> bool:
|
|
|
"""Check if authentication is enabled.
|
|
|
|
|
|
@@ -878,12 +919,25 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
|
|
|
no exception. Any OTHER failure (connection error, fd exhaustion,
|
|
|
schema mismatch, …) propagates so the caller can deny the request
|
|
|
(503 / 500). Fail-closed is the only safe default for an auth probe.
|
|
|
+
|
|
|
+ Result is cached briefly to cut per-request DB load on large farms; only
|
|
|
+ the enabled=True result is cached, so a stale read can only fail closed.
|
|
|
+ See the module-level cache comment above.
|
|
|
"""
|
|
|
+ global _auth_enabled_cached_value, _auth_enabled_cached_until
|
|
|
+ if _auth_enabled_cached_value and time.monotonic() < _auth_enabled_cached_until:
|
|
|
+ return True
|
|
|
+
|
|
|
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
|
|
|
setting = result.scalar_one_or_none()
|
|
|
- if setting is None:
|
|
|
- return False
|
|
|
- return setting.value.lower() == "true"
|
|
|
+ enabled = setting is not None and setting.value.lower() == "true"
|
|
|
+ if enabled:
|
|
|
+ _auth_enabled_cached_value = True
|
|
|
+ _auth_enabled_cached_until = time.monotonic() + _AUTH_ENABLED_CACHE_TTL_SECONDS
|
|
|
+ else:
|
|
|
+ # Never cache "disabled" — keep failing closed on any future staleness.
|
|
|
+ _auth_enabled_cached_value = False
|
|
|
+ return enabled
|
|
|
|
|
|
|
|
|
async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
|
|
|
@@ -973,13 +1027,16 @@ async def get_current_user_optional(
|
|
|
if username is None:
|
|
|
raise _unauthorized
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
- raise _unauthorized # I6: revoked token → 401, not anonymous
|
|
|
iat: int | float | None = payload.get("iat")
|
|
|
except JWTError:
|
|
|
raise _unauthorized
|
|
|
|
|
|
+ if not jti:
|
|
|
+ raise _unauthorized # I6: revoked token → 401, not anonymous
|
|
|
+
|
|
|
async with async_session() as db:
|
|
|
+ if await is_jti_revoked(jti, db):
|
|
|
+ raise _unauthorized # I6: revoked token → 401, not anonymous
|
|
|
user = await get_user_by_username(db, username)
|
|
|
if user is None or not user.is_active:
|
|
|
raise _unauthorized
|
|
|
@@ -1006,13 +1063,16 @@ async def get_current_user(
|
|
|
if username is None:
|
|
|
raise credentials_exception
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
- raise credentials_exception
|
|
|
iat: int | float | None = payload.get("iat")
|
|
|
except JWTError:
|
|
|
raise credentials_exception
|
|
|
|
|
|
+ if not jti:
|
|
|
+ raise credentials_exception
|
|
|
+
|
|
|
async with async_session() as db:
|
|
|
+ if await is_jti_revoked(jti, db):
|
|
|
+ raise credentials_exception
|
|
|
user = await get_user_by_username(db, username)
|
|
|
if user is None:
|
|
|
raise credentials_exception
|
|
|
@@ -1082,7 +1142,7 @@ async def require_auth_if_enabled(
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
)
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
detail="Could not validate credentials",
|
|
|
@@ -1191,7 +1251,7 @@ def require_admin_if_auth_enabled():
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
)
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
detail="Could not validate credentials",
|
|
|
@@ -1431,7 +1491,7 @@ def require_permission(*permissions: str | Permission):
|
|
|
if username is None:
|
|
|
raise credentials_exception
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise credentials_exception
|
|
|
iat: int | float | None = payload.get("iat")
|
|
|
except JWTError:
|
|
|
@@ -1518,7 +1578,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
)
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
detail="Could not validate credentials",
|
|
|
@@ -1618,7 +1678,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
)
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
detail="Could not validate credentials",
|
|
|
@@ -1795,7 +1855,7 @@ def require_ownership_permission(
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
)
|
|
|
jti: str | None = payload.get("jti")
|
|
|
- if not jti or await is_jti_revoked(jti):
|
|
|
+ if not jti or await is_jti_revoked(jti, db):
|
|
|
raise HTTPException(
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
detail="Could not validate credentials",
|