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)
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"""API dependencies using Dishka."""
|
||||
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from dishka.integrations.fastapi import FromDishka
|
||||
from fastapi import Depends, Header
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.application import (
|
||||
CreatePostUseCase,
|
||||
@@ -13,6 +14,9 @@ from app.application import (
|
||||
PublishPostUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
from app.domain.exceptions import ForbiddenException, UnauthorizedException
|
||||
from app.domain.roles import Role, get_effective_role
|
||||
from app.infrastructure.auth import KeycloakAuthClient, TokenInfo
|
||||
|
||||
# Use case dependencies - injected via Dishka
|
||||
CreatePostDep = FromDishka[CreatePostUseCase]
|
||||
@@ -22,13 +26,106 @@ DeletePostDep = FromDishka[DeletePostUseCase]
|
||||
ListPostsDep = FromDishka[ListPostsUseCase]
|
||||
PublishPostDep = FromDishka[PublishPostUseCase]
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
||||
"""Get Keycloak client from DI container via request state."""
|
||||
client: KeycloakAuthClient = request.state.dishka_container.get(KeycloakAuthClient)
|
||||
return client
|
||||
|
||||
|
||||
async def get_current_token_info(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
|
||||
request: Request,
|
||||
) -> TokenInfo:
|
||||
"""Validate token and return token info from Keycloak."""
|
||||
if not credentials:
|
||||
raise UnauthorizedException("Authentication required")
|
||||
|
||||
keycloak_client = get_keycloak_client(request)
|
||||
token = credentials.credentials
|
||||
token_info = await keycloak_client.introspect_token(token)
|
||||
|
||||
if not token_info.is_valid:
|
||||
raise UnauthorizedException("Invalid or expired token")
|
||||
|
||||
return token_info
|
||||
|
||||
|
||||
# Mock current user dependency (replace with real auth)
|
||||
async def get_current_user_id(
|
||||
x_user_id: Annotated[str | None, Header()] = "user-123",
|
||||
token_info: Annotated[TokenInfo, Depends(get_current_token_info)],
|
||||
) -> str:
|
||||
"""Get current user ID from header."""
|
||||
return x_user_id or "user-123"
|
||||
"""Get current user ID from validated token."""
|
||||
return token_info.user_id
|
||||
|
||||
|
||||
CurrentUserDep = Annotated[str, Depends(get_current_user_id)]
|
||||
TokenInfoDep = Annotated[TokenInfo, Depends(get_current_token_info)]
|
||||
|
||||
|
||||
# Optional auth - doesn't require authentication but provides user info if available
|
||||
async def get_optional_token_info(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
|
||||
request: Request,
|
||||
) -> TokenInfo | None:
|
||||
"""Get token info if valid token provided, otherwise None (guest)."""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
keycloak_client = get_keycloak_client(request)
|
||||
token = credentials.credentials
|
||||
token_info = await keycloak_client.introspect_token(token)
|
||||
|
||||
if token_info.is_valid:
|
||||
return token_info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
OptionalTokenInfoDep = Annotated[TokenInfo | None, Depends(get_optional_token_info)]
|
||||
|
||||
|
||||
async def get_optional_user_id(
|
||||
token_info: OptionalTokenInfoDep,
|
||||
) -> str | None:
|
||||
"""Get current user ID if token is valid, otherwise None."""
|
||||
if token_info:
|
||||
return token_info.user_id
|
||||
return None
|
||||
|
||||
|
||||
OptionalUserDep = Annotated[str | None, Depends(get_optional_user_id)]
|
||||
|
||||
|
||||
def get_current_role(token_info: OptionalTokenInfoDep) -> Role:
|
||||
"""Get effective role from token info.
|
||||
|
||||
Returns GUEST if no valid token provided.
|
||||
"""
|
||||
if token_info and token_info.roles:
|
||||
return get_effective_role(token_info.roles)
|
||||
return Role.GUEST
|
||||
|
||||
|
||||
CurrentRoleDep = Annotated[Role, Depends(get_current_role)]
|
||||
|
||||
|
||||
def require_roles(allowed_roles: list[Role]) -> Any:
|
||||
"""Create dependency that checks if user has one of the allowed roles."""
|
||||
|
||||
async def check_role(role: CurrentRoleDep) -> Role:
|
||||
if role not in allowed_roles:
|
||||
raise ForbiddenException(
|
||||
f"Access denied. Required roles: {[r.value for r in allowed_roles]}"
|
||||
)
|
||||
return role
|
||||
|
||||
return Depends(check_role)
|
||||
|
||||
|
||||
# Predefined role requirements
|
||||
RequireAdmin = require_roles([Role.ADMIN])
|
||||
RequireUser = require_roles([Role.USER, Role.ADMIN])
|
||||
RequireAny = require_roles([Role.GUEST, Role.USER, Role.ADMIN])
|
||||
|
||||
Reference in New Issue
Block a user