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}",
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
async function bootstrap() {
|
||||
await initKeycloak();
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user