feat: 集成 Keycloak 认证 — 前后端 + K8s 配置
This commit is contained in:
@@ -12,3 +12,10 @@ MINERU_PYTHON=/root/projects/GraphRAGAgent/mineru_mvp/.venv/bin/python
|
||||
# Linux: /home/user/GraphRAGAgent/mineru_mvp/pipeline.py
|
||||
# Windows: F:/GraphRAGAgent/mineru_mvp/pipeline.py
|
||||
MINERU_PIPELINE=/root/projects/GraphRAGAgent/mineru_mvp/pipeline.py
|
||||
|
||||
# Keycloak OIDC (authentication)
|
||||
KEYCLOAK_SERVER_URL=https://keycloak.plfai.cn
|
||||
KEYCLOAK_REALM=plfai
|
||||
KEYCLOAK_CLIENT_ID=graphrag-backend
|
||||
KEYCLOAK_CLIENT_SECRET=your_keycloak_client_secret_here
|
||||
KEYCLOAK_AUDIENCE=account
|
||||
|
||||
+12
-6
@@ -9,11 +9,12 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
load_dotenv(Path(__file__).parent / ".env", override=True)
|
||||
|
||||
from middleware.auth import get_current_user
|
||||
from routers import documents, indexing, kg, query, search, system
|
||||
|
||||
app = FastAPI(
|
||||
@@ -40,11 +41,16 @@ app.add_middleware(
|
||||
# search.router prefix="/search" → /api/v1/search
|
||||
# system.router no prefix → /api/v1/health, /api/v1/system/...
|
||||
PREFIX = "/api/v1"
|
||||
app.include_router(documents.router, prefix=PREFIX)
|
||||
app.include_router(indexing.router, prefix=PREFIX)
|
||||
app.include_router(kg.router, prefix=PREFIX)
|
||||
app.include_router(query.router, prefix=PREFIX)
|
||||
app.include_router(search.router, prefix=PREFIX)
|
||||
_auth = [Depends(get_current_user)]
|
||||
|
||||
# Protected routes (require Keycloak authentication)
|
||||
app.include_router(documents.router, prefix=PREFIX, dependencies=_auth)
|
||||
app.include_router(indexing.router, prefix=PREFIX, dependencies=_auth)
|
||||
app.include_router(kg.router, prefix=PREFIX, dependencies=_auth)
|
||||
app.include_router(query.router, prefix=PREFIX, dependencies=_auth)
|
||||
app.include_router(search.router, prefix=PREFIX, dependencies=_auth)
|
||||
|
||||
# Public routes (no authentication required)
|
||||
app.include_router(system.router, prefix=PREFIX)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Keycloak JWT authentication middleware."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from jose.constants import Algorithms
|
||||
|
||||
load_dotenv(Path(__file__).parent.parent / ".env", override=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
KEYCLOAK_SERVER = os.getenv("KEYCLOAK_SERVER_URL", "https://keycloak.plfai.cn")
|
||||
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "plfai")
|
||||
KEYCLOAK_AUDIENCE = os.getenv("KEYCLOAK_AUDIENCE", "account")
|
||||
|
||||
OIDC_CONFIG_URL = f"{KEYCLOAK_SERVER}/realms/{KEYCLOAK_REALM}/.well-known/openid-configuration"
|
||||
|
||||
# Cached OIDC config + JWKS
|
||||
_oidc_config: Optional[dict] = None
|
||||
_jwks: Optional[dict] = None
|
||||
|
||||
|
||||
async def _fetch_oidc_config() -> tuple[dict, dict]:
|
||||
"""Fetch OIDC config and JWKS, caching them."""
|
||||
global _oidc_config, _jwks
|
||||
|
||||
if _oidc_config is None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(OIDC_CONFIG_URL)
|
||||
resp.raise_for_status()
|
||||
_oidc_config = resp.json()
|
||||
|
||||
jwks_resp = await client.get(_oidc_config["jwks_uri"])
|
||||
jwks_resp.raise_for_status()
|
||||
_jwks = jwks_resp.json()
|
||||
|
||||
return _oidc_config, _jwks # type: ignore[return-value]
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> dict:
|
||||
"""Validate Bearer token and return user claims. Raises 401 if invalid."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Authorization header",
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
_, jwks = await _fetch_oidc_config()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch OIDC config: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Auth service unavailable",
|
||||
)
|
||||
|
||||
# Get kid from token header
|
||||
try:
|
||||
unverified = jwt.get_unverified_header(token)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token header",
|
||||
)
|
||||
|
||||
kid = unverified.get("kid")
|
||||
if not kid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing kid in token",
|
||||
)
|
||||
|
||||
# Find matching public key
|
||||
key = None
|
||||
for k in jwks.get("keys", []):
|
||||
if k.get("kid") == kid:
|
||||
key = k
|
||||
break
|
||||
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Unknown signing key",
|
||||
)
|
||||
|
||||
# Verify signature + expiry
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=[Algorithms.RS256],
|
||||
audience=KEYCLOAK_AUDIENCE,
|
||||
options={"verify_exp": True},
|
||||
)
|
||||
return payload
|
||||
except JWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {e}",
|
||||
)
|
||||
Reference in New Issue
Block a user