Major changes: - Add Keycloak integration via token introspection endpoint - Implement RBAC system with roles: admin, user, guest - Add role-based permissions for post operations - Add pagination support (default limit: 10) to list endpoints - Add published_only filter with admin-only override for unpublished posts Security improvements: - Remove hardcoded default secrets (SECRET_KEY, KEYCLOAK_CLIENT_SECRET) - Update .env.example with proper security placeholders - Add comprehensive RBAC unit tests Infrastructure: - Add httpx dependency for HTTP client - Add KeycloakAuthClient with token caching (TTL: 60s) - Add role-based dependencies (RequireAdmin, RequireUser, etc.) - Update DI container with Keycloak provider Endpoints updated: - GET /posts: filter by published status (admin can see all) - Add pagination params (limit, offset) to list endpoints - Enforce RBAC on post operations Tests: - Add 16 auth infrastructure tests - Add 13 RBAC role tests - Update existing tests for new required settings Breaking changes: - SECRET_KEY and KEYCLOAK_CLIENT_SECRET now required (no defaults)
128 lines
4.4 KiB
Python
128 lines
4.4 KiB
Python
"""Keycloak authentication client."""
|
|
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
|
|
from app.infrastructure.config.settings import Settings
|
|
|
|
|
|
class KeycloakAuthClient:
|
|
"""Client for Keycloak authentication operations."""
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
"""Initialize Keycloak client with settings."""
|
|
self._settings = settings
|
|
self._base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}"
|
|
self._client_id = settings.kc.client_id
|
|
self._client_secret = settings.kc.client_secret
|
|
self._cache: dict[str, tuple[TokenInfo, float]] = {}
|
|
self._cache_ttl = settings.kc.token_cache_ttl
|
|
|
|
def _get_introspection_url(self) -> str:
|
|
"""Get token introspection endpoint URL."""
|
|
return f"{self._base_url}/protocol/openid-connect/token/introspection"
|
|
|
|
def _get_userinfo_url(self) -> str:
|
|
"""Get userinfo endpoint URL."""
|
|
return f"{self._base_url}/protocol/openid-connect/userinfo"
|
|
|
|
def _get_cached_token(self, token: str) -> TokenInfo | None:
|
|
"""Get cached token info if valid."""
|
|
if token not in self._cache:
|
|
return None
|
|
|
|
token_info, cached_at = self._cache[token]
|
|
if time.time() - cached_at > self._cache_ttl:
|
|
del self._cache[token]
|
|
return None
|
|
|
|
return token_info
|
|
|
|
def _cache_token(self, token: str, token_info: TokenInfo) -> None:
|
|
"""Cache token info."""
|
|
self._cache[token] = (token_info, time.time())
|
|
# Simple cleanup of old entries
|
|
current_time = time.time()
|
|
expired_keys = [
|
|
k for k, (_, t) in self._cache.items() if current_time - t > self._cache_ttl
|
|
]
|
|
for k in expired_keys:
|
|
del self._cache[k]
|
|
|
|
async def introspect_token(self, token: str) -> TokenInfo:
|
|
"""Introspect access token using Keycloak."""
|
|
# Check cache first
|
|
cached = self._get_cached_token(token)
|
|
if cached:
|
|
return cached
|
|
|
|
# Prepare introspection request
|
|
data = {
|
|
"token": token,
|
|
"client_id": self._client_id,
|
|
"client_secret": self._client_secret,
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
self._get_introspection_url(),
|
|
data=data,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
timeout=10.0,
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
except httpx.HTTPError as e:
|
|
return TokenInfo(active=False, raw_claims={"error": str(e)})
|
|
|
|
if not result.get("active", False):
|
|
return TokenInfo(active=False, raw_claims=result)
|
|
|
|
# Extract roles from realm_access or resource_access
|
|
roles: list[str] = []
|
|
realm_access = result.get("realm_access", {})
|
|
if isinstance(realm_access, dict):
|
|
roles.extend(realm_access.get("roles", []))
|
|
|
|
token_info = TokenInfo(
|
|
active=True,
|
|
user_id=result.get("sub", ""),
|
|
username=result.get("preferred_username", ""),
|
|
email=result.get("email", ""),
|
|
roles=roles,
|
|
raw_claims=result,
|
|
)
|
|
|
|
# Cache valid token
|
|
self._cache_token(token, token_info)
|
|
|
|
return token_info
|
|
|
|
async def get_userinfo(self, token: str) -> KeycloakUser | None:
|
|
"""Get user information from Keycloak using access token."""
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(
|
|
self._get_userinfo_url(),
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10.0,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
except httpx.HTTPError:
|
|
return None
|
|
|
|
return KeycloakUser(
|
|
id=data.get("sub", ""),
|
|
username=data.get("preferred_username", ""),
|
|
email=data.get("email", ""),
|
|
first_name=data.get("given_name", ""),
|
|
last_name=data.get("family_name", ""),
|
|
roles=data.get("realm_access", {}).get("roles", [])
|
|
if isinstance(data.get("realm_access"), dict)
|
|
else [],
|
|
)
|