170 lines
7.5 KiB
Python
170 lines
7.5 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"
|
|
]
|
|
|
|
# Routes that are excluded from user authentication
|
|
EXCLUDED_PATHS = [
|
|
"/auth", # Authentication endpoints
|
|
"/api/v1", # API endpoints (use API Key)
|
|
"/admin", # SQLAdmin (has own auth)
|
|
]
|
|
|
|
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)
|