From 23f07dd3a73e8318e5f05457e057e718d0425f20 Mon Sep 17 00:00:00 2001 From: panlf Date: Mon, 15 Jun 2026 17:30:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20Keycloak=20?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=20=E2=80=94=20=E5=89=8D=E5=90=8E=E7=AB=AF=20?= =?UTF-8?q?+=20K8s=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 7 ++ backend/main.py | 18 ++- backend/middleware/__init__.py | 0 backend/middleware/auth.py | 114 ++++++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 8 ++ frontend/public/silent-check-sso.html | 8 ++ frontend/src/app/api.ts | 14 ++- frontend/src/app/auth.ts | 59 +++++++++ frontend/src/app/components/layout/Header.tsx | 31 ++++- frontend/src/main.tsx | 14 ++- k8s/overlays/prod/kustomization.yaml | 5 + k8s/overlays/test/kustomization.yaml | 5 + 13 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 backend/middleware/__init__.py create mode 100644 backend/middleware/auth.py create mode 100644 frontend/public/silent-check-sso.html create mode 100644 frontend/src/app/auth.ts diff --git a/backend/.env.example b/backend/.env.example index 070c7bc..4ee51a7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/main.py b/backend/main.py index 0bcd2e7..96eb6d7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py new file mode 100644 index 0000000..54f41b4 --- /dev/null +++ b/backend/middleware/auth.py @@ -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}", + ) diff --git a/frontend/package.json b/frontend/package.json index 9f6dee2..590944a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 00dc0df..9fb4a56 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html new file mode 100644 index 0000000..b80af7f --- /dev/null +++ b/frontend/public/silent-check-sso.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/frontend/src/app/api.ts b/frontend/src/app/api.ts index 1d71402..79b3e9f 100644 --- a/frontend/src/app/api.ts +++ b/frontend/src/app/api.ts @@ -32,11 +32,21 @@ async function request( if (parts.length) url += '?' + parts.join('&'); } - const init: RequestInit = { method }; + // Build headers with optional Bearer token + const headers: Record = {}; + 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); } diff --git a/frontend/src/app/auth.ts b/frontend/src/app/auth.ts new file mode 100644 index 0000000..313602c --- /dev/null +++ b/frontend/src/app/auth.ts @@ -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 { + 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; + return { + username: p.preferred_username ?? '', + email: p.email ?? '', + name: p.name ?? '', + }; +} + +export default keycloak; diff --git a/frontend/src/app/components/layout/Header.tsx b/frontend/src/app/components/layout/Header.tsx index 8ba728f..9b2a2d1 100644 --- a/frontend/src/app/components/layout/Header.tsx +++ b/frontend/src/app/components/layout/Header.tsx @@ -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([]); const navigate = useNavigate(); @@ -132,8 +134,31 @@ export function Header() { )} - {/* Right */} -
+ {/* Right — Auth + Health */} +
+ {authed ? ( + <> + + + {getUser()?.username ?? 'User'} + + + + ) : ( + + )} ); +} - createRoot(document.getElementById("root")!).render(); - \ No newline at end of file +bootstrap(); diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml index 4563134..71b8711 100644 --- a/k8s/overlays/prod/kustomization.yaml +++ b/k8s/overlays/prod/kustomization.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-prod-placeholder - MINERU_API_TOKEN=prod-placeholder + - KEYCLOAK_CLIENT_SECRET=prod-placeholder patches: - path: ingress.yaml diff --git a/k8s/overlays/test/kustomization.yaml b/k8s/overlays/test/kustomization.yaml index 3066d8c..d7b642b 100644 --- a/k8s/overlays/test/kustomization.yaml +++ b/k8s/overlays/test/kustomization.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