Files
Onyscribe-Admin/app/auth.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

163 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OIDC-Login der Admin-Plattform über login.onyva.dev (Authorization-Code + PKCE, wie im Core).
Aktiv mit AUTH_ENABLED=true. Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI,
OIDC_LOGOUT_REDIRECT_URI, APP_COOKIE_SECURE, sowie die Zugriffsregel (mind. eine):
ADMIN_ALLOWED_EMAILS=a@x.de,b@y.de und/oder ADMIN_REQUIRED_ROLE=admin (gegen das Claim `app_roles`)
Ohne Zugriffsregel wird jeder Login abgelehnt (Default-Deny) – die Admin-Plattform hält alle Secrets.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.settings import AUTH_ENABLED, SECRET_KEY
COOKIE, STATE_COOKIE, STATE_TTL, SESSION_TTL = "oa_session", "oa_oauth", 600, 12 * 3600
router = APIRouter()
PUBLIC = re.compile(r"^/(healthz|auth/.*|static/.*|api/tenants/[^/]+/bundle|api/core/releases)$") # bundle: Token · releases: CI-Token (POST), GET prüft die Session selbst
DEV_USER = {"sub": "dev", "email": "dev@local", "name": "Dev (Auth aus)", "app_roles": ["admin"]}
def cfg() -> dict:
g = lambda k, d="": os.getenv(k, d).strip() # noqa: E731
return {
"issuer": g("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/"), "client_id": g("OIDC_CLIENT_ID"),
"client_secret": g("OIDC_CLIENT_SECRET"), "redirect": g("OIDC_REDIRECT_URI", "http://localhost:8300/auth/callback"),
"logout_redirect": g("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8300/"),
"secure": g("APP_COOKIE_SECURE", "false").lower() == "true",
"emails": {e.strip().lower() for e in g("ADMIN_ALLOWED_EMAILS").split(",") if e.strip()},
"role": g("ADMIN_REQUIRED_ROLE"),
}
def _sign(data: dict) -> str:
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=")
return f"{payload}.{hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()}"
def _verify(token: str) -> dict | None:
try:
payload, _, sig = token.partition(".")
if not sig or not hmac.compare_digest(sig, hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()):
return None
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
return data if int(data.get("exp", 0)) > time.time() else None
except Exception: # noqa: BLE001
return None
def session_user(request: Request) -> dict | None:
if not AUTH_ENABLED:
return DEV_USER
tok = request.cookies.get(COOKIE)
return _verify(tok) if tok else None
def is_allowed(user: dict) -> bool:
c = cfg()
if not c["emails"] and not c["role"]:
return False
if user.get("email", "").lower() in c["emails"]:
return True
return bool(c["role"]) and c["role"] in (user.get("app_roles") or [])
async def middleware(request: Request, call_next):
user = session_user(request)
request.state.user = user or {}
if user is None and not PUBLIC.match(request.url.path):
if request.url.path.startswith(("/api/", "/preview/")):
return JSONResponse({"detail": "Nicht angemeldet"}, status_code=401)
return RedirectResponse("/auth/login", status_code=302)
return await call_next(request)
def actor_name(request: Request) -> str:
u = getattr(request.state, "user", None) or {}
return u.get("email") or u.get("name") or "unbekannt"
# ── Routen ──────────────────────────────────────────────────────────────────
def exchange_code(code: str, verifier: str) -> dict:
"""Token-Tausch beim IdP (in Tests ersetzbar)."""
c = cfg()
r = httpx.post(f"{c['issuer']}/oauth/token", timeout=20, data={
"grant_type": "authorization_code", "code": code, "redirect_uri": c["redirect"], "client_id": c["client_id"],
"client_secret": c["client_secret"], "code_verifier": verifier})
if r.status_code >= 400:
raise HTTPException(502, f"Token-Tausch fehlgeschlagen ({r.status_code})")
return r.json()
def _claims(id_token: str) -> dict:
try:
p = id_token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
except Exception: # noqa: BLE001
return {}
@router.get("/auth/login")
def login():
c = cfg()
if not AUTH_ENABLED:
return RedirectResponse("/", 302)
if not c["client_id"] or not c["client_secret"]:
raise HTTPException(503, "OIDC nicht konfiguriert (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).")
state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
q = urlencode({"response_type": "code", "client_id": c["client_id"], "redirect_uri": c["redirect"], "scope": "openid profile email",
"state": state, "code_challenge": challenge, "code_challenge_method": "S256"})
resp = RedirectResponse(f"{c['issuer']}/oauth/authorize?{q}", 302)
resp.set_cookie(STATE_COOKIE, _sign({"state": state, "cv": verifier, "exp": int(time.time()) + STATE_TTL}),
max_age=STATE_TTL, httponly=True, secure=c["secure"], samesite="lax")
return resp
@router.get("/auth/callback")
def callback(request: Request, code: str = "", state: str = "", error: str = ""):
c = cfg()
if error:
raise HTTPException(400, f"Login abgebrochen: {error}")
pending = _verify(request.cookies.get(STATE_COOKIE, "") or "")
if not pending or not hmac.compare_digest(str(pending.get("state", "")), state) or not code:
return RedirectResponse("/auth/login", 303) # abgelaufen/Reload → neu starten
tokens = exchange_code(code, pending["cv"])
cl = _claims(tokens.get("id_token", ""))
user = {"sub": cl.get("sub", ""), "email": cl.get("email", ""), "name": cl.get("name", ""), "app_roles": cl.get("app_roles", [])}
if not is_allowed(user):
return HTMLResponse(f"<h2 style='font-family:sans-serif'>Kein Zugriff</h2><p style='font-family:sans-serif'>{user['email'] or 'Dieses Konto'} "
"ist für die Onyscribe-Admin-Plattform nicht freigeschaltet.</p><p><a href='/auth/logout'>Abmelden</a></p>", status_code=403)
resp = RedirectResponse("/", 303)
resp.set_cookie(COOKIE, _sign({**user, "exp": int(time.time()) + SESSION_TTL}), max_age=SESSION_TTL, httponly=True,
secure=c["secure"], samesite="lax")
resp.delete_cookie(STATE_COOKIE)
return resp
@router.get("/auth/logout")
def logout():
c = cfg()
url = f"{c['issuer']}/logout?{urlencode({'post_logout_redirect_uri': c['logout_redirect']})}" if AUTH_ENABLED else "/"
resp = RedirectResponse(url, 302)
resp.delete_cookie(COOKIE)
return resp
@router.get("/auth/me")
def me(request: Request):
return {"user": getattr(request.state, "user", {}), "auth_enabled": AUTH_ENABLED}