Files
blog.pyaqa.ru/app/infrastructure/auth/client.py
Sergey Vanyushkin 184b95969c
Some checks failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
feat(auth): implement Keycloak authentication with RBAC and pagination
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)
2026-05-02 11:21:45 +03:00

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 [],
)