#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
|
|
|
"""Keycloak JWT Authentication for FastAPI (#43).
|
|
|
|
|
|
|
|
|
|
Read-Only-Endpoints (GET) bleiben offen. Write-Endpoints (POST) erfordern
|
|
|
|
|
ein gültiges Keycloak-JWT. Das Modul cached den JWKS (public keys) für
|
|
|
|
|
1 Stunde und validiert Token-Signatur + Expiry + Audience + Issuer.
|
|
|
|
|
|
|
|
|
|
Wenn Keycloak nicht konfiguriert ist (KEYCLOAK_URL leer), ist Auth
|
|
|
|
|
**deaktiviert** — alle Endpoints sind offen. Das erlaubt lokale
|
|
|
|
|
Entwicklung ohne Keycloak-Server.
|
|
|
|
|
|
|
|
|
|
Usage in main.py:
|
|
|
|
|
|
|
|
|
|
from .auth import get_current_user, require_auth
|
|
|
|
|
|
|
|
|
|
@app.post("/api/analyze-drucksache")
|
|
|
|
|
async def analyze(request: Request, user = Depends(require_auth)):
|
|
|
|
|
... # user ist ein dict mit sub, email, name, roles
|
|
|
|
|
|
|
|
|
|
@app.get("/api/auth/me")
|
|
|
|
|
async def auth_me(user = Depends(get_current_user)):
|
|
|
|
|
... # user ist None wenn nicht eingeloggt, dict wenn eingeloggt
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import time
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
|
|
|
|
|
|
|
|
from .config import settings
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# JWKS Cache — lädt die Public Keys vom Keycloak-Server, cached für 1h.
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
_jwks_cache: dict = {}
|
|
|
|
|
_jwks_cache_time: float = 0
|
|
|
|
|
_JWKS_CACHE_TTL = 3600 # 1h
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _keycloak_issuer() -> str:
|
|
|
|
|
return f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _keycloak_jwks_url() -> str:
|
|
|
|
|
return f"{_keycloak_issuer()}/protocol/openid-connect/certs"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_jwks() -> dict:
|
|
|
|
|
"""Fetch or return cached JWKS from Keycloak."""
|
|
|
|
|
global _jwks_cache, _jwks_cache_time
|
|
|
|
|
|
|
|
|
|
if _jwks_cache and (time.time() - _jwks_cache_time) < _JWKS_CACHE_TTL:
|
|
|
|
|
return _jwks_cache
|
|
|
|
|
|
|
|
|
|
url = _keycloak_jwks_url()
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
|
|
|
resp = await client.get(url)
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
_jwks_cache = resp.json()
|
|
|
|
|
_jwks_cache_time = time.time()
|
|
|
|
|
logger.info("JWKS refreshed from %s (%d keys)", url, len(_jwks_cache.get("keys", [])))
|
|
|
|
|
return _jwks_cache
|
|
|
|
|
else:
|
|
|
|
|
logger.error("JWKS fetch failed: HTTP %s from %s", resp.status_code, url)
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("JWKS fetch error from %s", url)
|
|
|
|
|
|
|
|
|
|
return _jwks_cache # Return stale cache if refresh fails
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_auth_enabled() -> bool:
|
|
|
|
|
"""Auth ist nur aktiv wenn alle drei Keycloak-Settings gesetzt sind."""
|
|
|
|
|
return bool(
|
|
|
|
|
settings.keycloak_url
|
|
|
|
|
and settings.keycloak_realm
|
|
|
|
|
and settings.keycloak_client_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# Token-Extraktion und Validierung
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_token(request: Request) -> Optional[str]:
|
|
|
|
|
"""Extrahiere Bearer-Token aus Authorization-Header oder Cookie."""
|
|
|
|
|
auth = request.headers.get("authorization", "")
|
|
|
|
|
if auth.startswith("Bearer "):
|
|
|
|
|
return auth[7:]
|
|
|
|
|
# Fallback: Cookie (für Browser-Redirects nach Keycloak-Login)
|
|
|
|
|
return request.cookies.get("access_token")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _validate_token(token: str) -> Optional[dict]:
|
|
|
|
|
"""Validiere JWT gegen Keycloak-JWKS. Returns Payload oder None."""
|
|
|
|
|
try:
|
|
|
|
|
from jose import jwt, JWTError, ExpiredSignatureError
|
|
|
|
|
|
|
|
|
|
jwks = await _get_jwks()
|
|
|
|
|
if not jwks or "keys" not in jwks:
|
|
|
|
|
logger.warning("No JWKS available for token validation")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Decode Header um den Key-ID (kid) zu finden
|
|
|
|
|
unverified_header = jwt.get_unverified_header(token)
|
|
|
|
|
kid = unverified_header.get("kid")
|
|
|
|
|
|
|
|
|
|
# Finde den passenden Public Key
|
|
|
|
|
rsa_key = None
|
|
|
|
|
for key in jwks.get("keys", []):
|
|
|
|
|
if key.get("kid") == kid:
|
|
|
|
|
rsa_key = key
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not rsa_key:
|
|
|
|
|
logger.warning("JWT kid %s not found in JWKS", kid)
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-10 21:32:08 +02:00
|
|
|
# Keycloak setzt aud="account" für Public Clients, nicht den
|
|
|
|
|
# client_id. Prüfe azp (authorized party) statt aud, und
|
|
|
|
|
# deaktiviere den strikten aud-Check.
|
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
|
|
|
payload = jwt.decode(
|
|
|
|
|
token,
|
|
|
|
|
rsa_key,
|
|
|
|
|
algorithms=["RS256"],
|
|
|
|
|
issuer=_keycloak_issuer(),
|
2026-04-10 21:32:08 +02:00
|
|
|
options={"verify_exp": True, "verify_aud": False},
|
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
|
|
|
)
|
|
|
|
|
|
2026-04-10 21:32:08 +02:00
|
|
|
# azp muss unserem Client entsprechen
|
|
|
|
|
if payload.get("azp") != settings.keycloak_client_id:
|
|
|
|
|
logger.warning("JWT azp %s != expected %s", payload.get("azp"), settings.keycloak_client_id)
|
|
|
|
|
return None
|
|
|
|
|
|
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
|
|
|
return {
|
|
|
|
|
"sub": payload.get("sub"),
|
|
|
|
|
"email": payload.get("email", ""),
|
|
|
|
|
"name": payload.get("preferred_username", payload.get("name", "")),
|
|
|
|
|
"roles": payload.get("realm_access", {}).get("roles", []),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except ExpiredSignatureError:
|
|
|
|
|
logger.debug("JWT expired")
|
|
|
|
|
return None
|
|
|
|
|
except JWTError as e:
|
|
|
|
|
logger.debug("JWT validation failed: %s", e)
|
|
|
|
|
return None
|
|
|
|
|
except ImportError:
|
|
|
|
|
logger.error("python-jose not installed — JWT validation disabled")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# FastAPI Dependencies
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user(request: Request) -> Optional[dict]:
|
|
|
|
|
"""Optionale Auth — gibt User-Dict oder None zurück.
|
|
|
|
|
|
|
|
|
|
Für Endpoints die sowohl mit als auch ohne Login funktionieren
|
|
|
|
|
(z.B. UI-Personalisierung, Bookmark-Anzeige).
|
|
|
|
|
"""
|
|
|
|
|
if not _is_auth_enabled():
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
token = _extract_token(request)
|
|
|
|
|
if not token:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return await _validate_token(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def require_auth(request: Request) -> dict:
|
|
|
|
|
"""Pflicht-Auth — gibt User-Dict oder HTTP 401.
|
|
|
|
|
|
|
|
|
|
Für Write-Endpoints (POST analyze, index).
|
|
|
|
|
Wenn Auth nicht konfiguriert ist: ALLE durchlassen (Dev-Modus).
|
|
|
|
|
"""
|
|
|
|
|
if not _is_auth_enabled():
|
|
|
|
|
return {"sub": "anonymous", "email": "", "name": "Dev-Modus", "roles": []}
|
|
|
|
|
|
|
|
|
|
token = _extract_token(request)
|
|
|
|
|
if not token:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=401,
|
|
|
|
|
detail="Anmeldung erforderlich",
|
|
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user = await _validate_token(token)
|
|
|
|
|
if not user:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=401,
|
|
|
|
|
detail="Token ungültig oder abgelaufen",
|
|
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 23:15:42 +02:00
|
|
|
async def require_admin(request: Request) -> dict:
|
|
|
|
|
"""Admin-Auth — gibt User-Dict oder HTTP 403.
|
|
|
|
|
|
|
|
|
|
Prüft ob der User die Rolle 'admin' oder 'gwoe-admin' hat.
|
|
|
|
|
Im Dev-Modus (Auth deaktiviert): durchlassen.
|
|
|
|
|
Für: Batch-Analyse, Programm-Indexierung, Assessment-Löschung.
|
|
|
|
|
"""
|
|
|
|
|
if not _is_auth_enabled():
|
|
|
|
|
return {"sub": "anonymous", "email": "", "name": "Dev-Modus", "roles": ["admin"]}
|
|
|
|
|
|
|
|
|
|
user = await require_auth(request)
|
|
|
|
|
roles = user.get("roles", [])
|
|
|
|
|
if "admin" in roles or "gwoe-admin" in roles:
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=403,
|
|
|
|
|
detail="Admin-Berechtigung erforderlich",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
# Auth-Info-Endpoint
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
|
|
|
async def keycloak_admin_token() -> str:
|
|
|
|
|
"""Holt ein Admin-Token vom Keycloak-Master-Realm.
|
|
|
|
|
|
|
|
|
|
Verwendet die Credentials aus den Umgebungsvariablen KEYCLOAK_ADMIN_USER
|
|
|
|
|
und KEYCLOAK_ADMIN_PASSWORD. Wirft HTTPException bei Fehlschlag.
|
|
|
|
|
"""
|
|
|
|
|
import httpx
|
|
|
|
|
if not settings.keycloak_admin_user or not settings.keycloak_admin_password:
|
|
|
|
|
raise HTTPException(status_code=500, detail="Keycloak-Admin-Credentials nicht konfiguriert")
|
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
f"{settings.keycloak_url}/realms/master/protocol/openid-connect/token",
|
|
|
|
|
data={
|
|
|
|
|
"grant_type": "password",
|
|
|
|
|
"client_id": "admin-cli",
|
|
|
|
|
"username": settings.keycloak_admin_user,
|
|
|
|
|
"password": settings.keycloak_admin_password,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
raise HTTPException(status_code=500, detail="Keycloak-Verbindung fehlgeschlagen")
|
|
|
|
|
return resp.json()["access_token"]
|
|
|
|
|
|
|
|
|
|
|
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
|
|
|
def keycloak_login_url(redirect_uri: str) -> str:
|
|
|
|
|
"""Baut die Keycloak-Login-URL für den Browser-Redirect."""
|
|
|
|
|
if not _is_auth_enabled():
|
|
|
|
|
return ""
|
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
return (
|
|
|
|
|
f"{_keycloak_issuer()}/protocol/openid-connect/auth"
|
|
|
|
|
f"?client_id={settings.keycloak_client_id}"
|
|
|
|
|
f"&redirect_uri={quote(redirect_uri)}"
|
|
|
|
|
f"&response_type=code"
|
|
|
|
|
f"&scope=openid profile email"
|
|
|
|
|
)
|
feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
(ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze
Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
/api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie
Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def direct_login(username: str, password: str) -> dict:
|
|
|
|
|
"""Login via Keycloak Direct Access Grant (#129).
|
|
|
|
|
|
|
|
|
|
Gibt bei Erfolg {access_token, refresh_token, expires_in} zurück.
|
|
|
|
|
Wirft HTTPException bei Fehler (falsche Credentials, Account gesperrt, etc.).
|
|
|
|
|
"""
|
|
|
|
|
if not _is_auth_enabled():
|
|
|
|
|
raise HTTPException(status_code=400, detail="Auth nicht aktiviert")
|
|
|
|
|
token_url = f"{_keycloak_issuer()}/protocol/openid-connect/token"
|
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
token_url,
|
|
|
|
|
data={
|
|
|
|
|
"grant_type": "password",
|
|
|
|
|
"client_id": settings.keycloak_client_id,
|
|
|
|
|
"username": username,
|
|
|
|
|
"password": password,
|
|
|
|
|
"scope": "openid profile email",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if resp.status_code == 401:
|
|
|
|
|
error = resp.json().get("error_description", "Ungültige Anmeldedaten")
|
|
|
|
|
raise HTTPException(status_code=401, detail=error)
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
error = resp.json().get("error_description", f"Keycloak-Fehler ({resp.status_code})")
|
|
|
|
|
raise HTTPException(status_code=resp.status_code, detail=error)
|
|
|
|
|
return resp.json()
|