feat: 集成 Keycloak 认证 — 前后端 + K8s 配置

This commit is contained in:
2026-06-15 17:30:57 +08:00
parent eeea55e9e4
commit 23f07dd3a7
13 changed files with 268 additions and 16 deletions
+7
View File
@@ -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
View File
@@ -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)
View File
+114
View File
@@ -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}",
)
+1
View File
@@ -47,6 +47,7 @@
"date-fns": "3.6.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"keycloak-js": "^26.2.4",
"lucide-react": "0.487.0",
"marked": "^17.0.4",
"motion": "12.23.24",
+8
View File
@@ -128,6 +128,9 @@ importers:
input-otp:
specifier: 1.4.2
version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
keycloak-js:
specifier: ^26.2.4
version: 26.2.4
lucide-react:
specifier: 0.487.0
version: 0.487.0(react@19.2.7)
@@ -2004,6 +2007,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
keycloak-js@26.2.4:
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -4233,6 +4239,8 @@ snapshots:
json5@2.2.3: {}
keycloak-js@26.2.4: {}
lightningcss-darwin-arm64@1.30.1:
optional: true
+8
View File
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
+12 -2
View File
@@ -32,11 +32,21 @@ async function request<T>(
if (parts.length) url += '?' + parts.join('&');
}
const init: RequestInit = { method };
// Build headers with optional Bearer token
const headers: Record<string, string> = {};
try {
const { getToken } = await import('./auth');
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
} catch { /* auth not initialized */ }
if (!options.formData && options.body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const init: RequestInit = { method, headers };
if (options.formData) {
init.body = options.formData;
} else if (options.body !== undefined) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(options.body);
}
+59
View File
@@ -0,0 +1,59 @@
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: 'https://keycloak.plfai.cn',
realm: 'plfai',
clientId: 'graphrag-frontend',
});
let _initialized = false;
export async function initKeycloak(): Promise<boolean> {
if (_initialized) return keycloak.authenticated ?? false;
try {
const authenticated = await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri:
window.location.origin + '/silent-check-sso.html',
pkceMethod: 'S256',
});
_initialized = true;
return authenticated;
} catch {
_initialized = true;
return false;
}
}
export function login(): void {
keycloak.login({ redirectUri: window.location.href });
}
export function logout(): void {
keycloak.logout({ redirectUri: window.location.origin });
}
export function getToken(): string | undefined {
return keycloak.token;
}
export function isAuthenticated(): boolean {
return keycloak.authenticated ?? false;
}
export function getUser(): {
username: string;
email: string;
name: string;
} | null {
if (!keycloak.tokenParsed) return null;
const p = keycloak.tokenParsed as Record<string, string>;
return {
username: p.preferred_username ?? '',
email: p.email ?? '',
name: p.name ?? '',
};
}
export default keycloak;
+28 -3
View File
@@ -1,13 +1,15 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Menu, Search, X } from 'lucide-react';
import { Menu, Search, X, LogIn, LogOut, User } from 'lucide-react';
import { useAppState, type KGNode } from '../../store';
import { api } from '../../api';
import { TYPE_COLORS } from '../../mock-data';
import { isAuthenticated, getUser, login, logout } from '../../auth';
export function Header() {
const { sidebarCollapsed, setSidebarCollapsed, health } = useAppState();
const [query, setQuery] = useState('');
const [authed, setAuthed] = useState(isAuthenticated());
const [showSuggestions, setShowSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<KGNode[]>([]);
const navigate = useNavigate();
@@ -132,8 +134,31 @@ export function Header() {
)}
</form>
{/* Right */}
<div className="flex items-center gap-2" style={{ whiteSpace: 'nowrap' }}>
{/* Right — Auth + Health */}
<div className="flex items-center gap-3" style={{ whiteSpace: 'nowrap' }}>
{authed ? (
<>
<User size={14} style={{ color: 'var(--text-3)' }} />
<span style={{ color: 'var(--text-2)', fontSize: 12 }}>
{getUser()?.username ?? 'User'}
</span>
<button
onClick={() => { logout(); setAuthed(false); }}
className="flex items-center gap-1 px-2 py-1 rounded cursor-pointer"
style={{ background: 'var(--bg-s2)', border: '1px solid var(--border-main)', color: 'var(--text-3)', fontSize: 11 }}
>
<LogOut size={12} />
</button>
</>
) : (
<button
onClick={() => login()}
className="flex items-center gap-1 px-3 py-1 rounded cursor-pointer font-medium"
style={{ background: 'var(--green-btn)', color: '#fff', fontSize: 12 }}
>
<LogIn size={12} />
</button>
)}
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: allOk ? 'var(--green)' : 'var(--red)' }}
+9 -5
View File
@@ -1,7 +1,11 @@
import { createRoot } from 'react-dom/client';
import App from './app/App.tsx';
import './styles/index.css';
import { initKeycloak } from './app/auth.ts';
import { createRoot } from "react-dom/client";
import App from "./app/App.tsx";
import "./styles/index.css";
async function bootstrap() {
await initKeycloak();
createRoot(document.getElementById('root')!).render(<App />);
}
createRoot(document.getElementById("root")!).render(<App />);
bootstrap();
+5
View File
@@ -16,6 +16,10 @@ configMapGenerator:
- DEEPSEEK_BASE_URL=https://api.deepseek.com
- MINERU_PIPELINE=/app/mineru_mvp/pipeline.py
- MINERU_PYTHON=/opt/venv/bin/python
- KEYCLOAK_SERVER_URL=https://keycloak.plfai.cn
- KEYCLOAK_REALM=plfai
- KEYCLOAK_CLIENT_ID=graphrag-backend
- KEYCLOAK_AUDIENCE=account
secretGenerator:
- name: graphrag-secrets
@@ -23,6 +27,7 @@ secretGenerator:
literals:
- DEEPSEEK_API_KEY=sk-prod-placeholder
- MINERU_API_TOKEN=prod-placeholder
- KEYCLOAK_CLIENT_SECRET=prod-placeholder
patches:
- path: ingress.yaml
+5
View File
@@ -16,6 +16,10 @@ configMapGenerator:
- DEEPSEEK_BASE_URL=https://api.deepseek.com
- MINERU_PIPELINE=/app/mineru_mvp/pipeline.py
- MINERU_PYTHON=/opt/venv/bin/python
- KEYCLOAK_SERVER_URL=https://keycloak.plfai.cn
- KEYCLOAK_REALM=plfai
- KEYCLOAK_CLIENT_ID=graphrag-backend
- KEYCLOAK_AUDIENCE=account
secretGenerator:
- name: graphrag-secrets
@@ -23,6 +27,7 @@ secretGenerator:
literals:
- DEEPSEEK_API_KEY=sk-test-placeholder
- MINERU_API_TOKEN=test-placeholder
- KEYCLOAK_CLIENT_SECRET=test-placeholder
patches:
- path: ingress.yaml