Files
sriphat-dataplatform/03-apiservice/app/middleware/auth_middleware.py
jigoong 3a5f9e9001 feat: replace SQLAdmin with Keycloak-protected API management page
- Disable SQLAdmin basic auth (comment out mount_admin, statics, redirect)
- Add /api-management page (Keycloak admin role required)
- Add admin_api_keys.py: REST endpoints for list/create clients and keys
- Add api_management.html: manage API clients, keys, permissions with copy-once key display
- Update index.html: API Management link -> /api-management
- Update auth middleware: add /api-management and /admin/users to PROTECTED_PATHS
- Add CHANGES-2026-06-04.md dev notes
2026-06-04 18:22:22 +07:00

172 lines
7.6 KiB
Python

"""
Authentication middleware for web pages
Protects web UI routes while allowing API endpoints to use API Key auth
"""
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class WebAuthenticationMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce Keycloak authentication for web pages
Protected routes (require user login):
- / (landing page)
- /docs (API documentation)
- /data-management/* (data management pages)
Excluded routes (no auth required or use different auth):
- /auth/* (authentication endpoints)
- /api/v1/* (API endpoints - use API Key auth)
- /admin/* (SQLAdmin - has its own auth)
- /apiservice/admin/statics/* (admin static files)
- /admin/statics/* (admin static files)
"""
# Routes that require user authentication
PROTECTED_PATHS = [
"/",
"/docs",
"/redoc",
"/openapi.json",
"/data-management",
"/api-management",
"/admin/users",
]
# Routes that are excluded from user authentication
EXCLUDED_PATHS = [
"/auth", # Authentication endpoints
"/api/v1", # API endpoints (use API Key)
"/admin", # Admin API endpoints (use require_role dependency)
]
async def dispatch(self, request: Request, call_next):
"""Process request and check authentication if needed"""
# Get the path without root_path prefix
path = request.url.path
# Remove root_path prefix if it exists
if settings.ROOT_PATH and path.startswith(settings.ROOT_PATH):
path = path[len(settings.ROOT_PATH):]
# Ensure path starts with /
if not path.startswith("/"):
path = "/" + path
# Check if path is excluded from authentication
is_excluded = any(path.startswith(excluded) for excluded in self.EXCLUDED_PATHS)
if is_excluded:
# Skip authentication for excluded paths (including /api/v1/*)
return await call_next(request)
# Check if path requires authentication
requires_auth = any(
path == protected or path.startswith(protected + "/")
for protected in self.PROTECTED_PATHS
)
if requires_auth:
# Check if user is authenticated
user = request.session.get("user")
if not user:
# User not authenticated - redirect to login
# Preserve the original path for redirect after login
original_path = request.url.path
# Build login URL with redirect
login_url = f"{settings.ROOT_PATH}/auth/login?redirect_to={original_path}"
logger.info(f"Unauthenticated access to {original_path}, redirecting to login")
return RedirectResponse(url=login_url, status_code=302)
# Role-based access control for specific paths
user_roles = user.get("roles", [])
# /data-management/* requires 'operation' or 'admin' role
if path.startswith("/data-management"):
if not any(role in user_roles for role in ["admin", "operation"]):
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access {path}"
)
# Return 403 Forbidden page
from fastapi.responses import HTMLResponse
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}}
.container {{
background: white;
padding: 60px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 500px;
}}
h1 {{ color: #ff6b6b; font-size: 48px; margin-bottom: 20px; }}
p {{ color: #666; font-size: 18px; margin: 15px 0; }}
.role-info {{
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin: 20px 0;
}}
.required {{ color: #ff6b6b; font-weight: bold; }}
.your-roles {{ color: #667eea; font-weight: bold; }}
a {{
display: inline-block;
margin-top: 30px;
padding: 12px 30px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
}}
a:hover {{ background: #5568d3; transform: translateY(-2px); }}
</style>
</head>
<body>
<div class="container">
<h1>🚫</h1>
<h2>Access Denied</h2>
<p>You don't have permission to access this page.</p>
<div class="role-info">
<p>Required role: <span class="required">operation</span> or <span class="required">admin</span></p>
<p>Your roles: <span class="your-roles">{', '.join(user_roles) if user_roles else 'None'}</span></p>
</div>
<p>Please contact your administrator if you need access.</p>
<a href="{settings.ROOT_PATH}/">← Go to Home</a>
</div>
</body>
</html>
""",
status_code=403
)
# Continue with request
return await call_next(request)