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
|
# Linux: /home/user/GraphRAGAgent/mineru_mvp/pipeline.py
|
||||||
# Windows: F:/GraphRAGAgent/mineru_mvp/pipeline.py
|
# Windows: F:/GraphRAGAgent/mineru_mvp/pipeline.py
|
||||||
MINERU_PIPELINE=/root/projects/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))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
load_dotenv(Path(__file__).parent / ".env", override=True)
|
load_dotenv(Path(__file__).parent / ".env", override=True)
|
||||||
|
|
||||||
|
from middleware.auth import get_current_user
|
||||||
from routers import documents, indexing, kg, query, search, system
|
from routers import documents, indexing, kg, query, search, system
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -40,11 +41,16 @@ app.add_middleware(
|
|||||||
# search.router prefix="/search" → /api/v1/search
|
# search.router prefix="/search" → /api/v1/search
|
||||||
# system.router no prefix → /api/v1/health, /api/v1/system/...
|
# system.router no prefix → /api/v1/health, /api/v1/system/...
|
||||||
PREFIX = "/api/v1"
|
PREFIX = "/api/v1"
|
||||||
app.include_router(documents.router, prefix=PREFIX)
|
_auth = [Depends(get_current_user)]
|
||||||
app.include_router(indexing.router, prefix=PREFIX)
|
|
||||||
app.include_router(kg.router, prefix=PREFIX)
|
# Protected routes (require Keycloak authentication)
|
||||||
app.include_router(query.router, prefix=PREFIX)
|
app.include_router(documents.router, prefix=PREFIX, dependencies=_auth)
|
||||||
app.include_router(search.router, prefix=PREFIX)
|
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)
|
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",
|
"date-fns": "3.6.0",
|
||||||
"embla-carousel-react": "8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
|
"keycloak-js": "^26.2.4",
|
||||||
"lucide-react": "0.487.0",
|
"lucide-react": "0.487.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"motion": "12.23.24",
|
"motion": "12.23.24",
|
||||||
|
|||||||
Generated
+8
@@ -128,6 +128,9 @@ importers:
|
|||||||
input-otp:
|
input-otp:
|
||||||
specifier: 1.4.2
|
specifier: 1.4.2
|
||||||
version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
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:
|
lucide-react:
|
||||||
specifier: 0.487.0
|
specifier: 0.487.0
|
||||||
version: 0.487.0(react@19.2.7)
|
version: 0.487.0(react@19.2.7)
|
||||||
@@ -2004,6 +2007,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
keycloak-js@26.2.4:
|
||||||
|
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -4233,6 +4239,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
keycloak-js@26.2.4: {}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
optional: true
|
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('&');
|
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) {
|
if (options.formData) {
|
||||||
init.body = options.formData;
|
init.body = options.formData;
|
||||||
} else if (options.body !== undefined) {
|
} else if (options.body !== undefined) {
|
||||||
init.headers = { 'Content-Type': 'application/json' };
|
|
||||||
init.body = JSON.stringify(options.body);
|
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 React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
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 { useAppState, type KGNode } from '../../store';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import { TYPE_COLORS } from '../../mock-data';
|
import { TYPE_COLORS } from '../../mock-data';
|
||||||
|
import { isAuthenticated, getUser, login, logout } from '../../auth';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { sidebarCollapsed, setSidebarCollapsed, health } = useAppState();
|
const { sidebarCollapsed, setSidebarCollapsed, health } = useAppState();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [authed, setAuthed] = useState(isAuthenticated());
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [suggestions, setSuggestions] = useState<KGNode[]>([]);
|
const [suggestions, setSuggestions] = useState<KGNode[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -132,8 +134,31 @@ export function Header() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Right */}
|
{/* Right — Auth + Health */}
|
||||||
<div className="flex items-center gap-2" style={{ whiteSpace: 'nowrap' }}>
|
<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
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-full"
|
className="inline-block w-2 h-2 rounded-full"
|
||||||
style={{ background: allOk ? 'var(--green)' : 'var(--red)' }}
|
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";
|
async function bootstrap() {
|
||||||
import App from "./app/App.tsx";
|
await initKeycloak();
|
||||||
import "./styles/index.css";
|
createRoot(document.getElementById('root')!).render(<App />);
|
||||||
|
}
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ configMapGenerator:
|
|||||||
- DEEPSEEK_BASE_URL=https://api.deepseek.com
|
- DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
- MINERU_PIPELINE=/app/mineru_mvp/pipeline.py
|
- MINERU_PIPELINE=/app/mineru_mvp/pipeline.py
|
||||||
- MINERU_PYTHON=/opt/venv/bin/python
|
- 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:
|
secretGenerator:
|
||||||
- name: graphrag-secrets
|
- name: graphrag-secrets
|
||||||
@@ -23,6 +27,7 @@ secretGenerator:
|
|||||||
literals:
|
literals:
|
||||||
- DEEPSEEK_API_KEY=sk-prod-placeholder
|
- DEEPSEEK_API_KEY=sk-prod-placeholder
|
||||||
- MINERU_API_TOKEN=prod-placeholder
|
- MINERU_API_TOKEN=prod-placeholder
|
||||||
|
- KEYCLOAK_CLIENT_SECRET=prod-placeholder
|
||||||
|
|
||||||
patches:
|
patches:
|
||||||
- path: ingress.yaml
|
- path: ingress.yaml
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ configMapGenerator:
|
|||||||
- DEEPSEEK_BASE_URL=https://api.deepseek.com
|
- DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
- MINERU_PIPELINE=/app/mineru_mvp/pipeline.py
|
- MINERU_PIPELINE=/app/mineru_mvp/pipeline.py
|
||||||
- MINERU_PYTHON=/opt/venv/bin/python
|
- 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:
|
secretGenerator:
|
||||||
- name: graphrag-secrets
|
- name: graphrag-secrets
|
||||||
@@ -23,6 +27,7 @@ secretGenerator:
|
|||||||
literals:
|
literals:
|
||||||
- DEEPSEEK_API_KEY=sk-test-placeholder
|
- DEEPSEEK_API_KEY=sk-test-placeholder
|
||||||
- MINERU_API_TOKEN=test-placeholder
|
- MINERU_API_TOKEN=test-placeholder
|
||||||
|
- KEYCLOAK_CLIENT_SECRET=test-placeholder
|
||||||
|
|
||||||
patches:
|
patches:
|
||||||
- path: ingress.yaml
|
- path: ingress.yaml
|
||||||
|
|||||||
Reference in New Issue
Block a user