Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):** - Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`. - Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls). - SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt. - Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens. - Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt. - Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife. - Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt. - Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
144 lines
6.7 KiB
Python
144 lines
6.7 KiB
Python
"""OIDC single sign-on: public login endpoints and admin-only settings (see services/oidc_service.py)."""
|
|
import logging
|
|
import re
|
|
import secrets
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.errors import ErrorCode, api_error
|
|
from app.models.user import AdminUser
|
|
from app.routers.auth import _set_session_cookie, client_ip, current_user
|
|
from app.config import settings
|
|
from app.schemas.oidc import OidcSettingsRead, OidcSettingsUpdate
|
|
from app.security_middleware import _origin_allowed
|
|
from app.services import auth_service, notification_secret_store, oidc_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["oidc"])
|
|
CALLBACK_PATH = "/api/auth/oidc/callback"
|
|
STATE_COOKIE = "opnexus-oidc-state"
|
|
# SSO sessions are short: the provider's group membership is only re-checked at login, so a removed person
|
|
# must not keep a 30-day session.
|
|
SSO_SESSION_SECONDS = 12 * 3600
|
|
|
|
|
|
def _login_error(code: str) -> RedirectResponse:
|
|
response = RedirectResponse(f"/login?sso_error={code}", status_code=302)
|
|
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
|
return response
|
|
|
|
|
|
def _printable(value: str) -> str:
|
|
"""Query text from an unauthenticated caller, made safe for a log line (no CR/LF or other control chars)."""
|
|
return re.sub(r"[^\x20-\x7e]", "?", value)[:60]
|
|
|
|
|
|
@router.get("/auth/oidc/config")
|
|
async def public_config(db: AsyncSession = Depends(get_db)):
|
|
"""For the login page: is there an SSO button, and what does it say. Nothing else is revealed."""
|
|
cfg = await oidc_service.get_settings(db)
|
|
ready = oidc_service.configured(cfg)
|
|
return {"enabled": ready, "name": cfg.display_name if ready else None}
|
|
|
|
|
|
@router.get("/auth/oidc/start")
|
|
async def start(request: Request, origin: str, db: AsyncSession = Depends(get_db)):
|
|
cfg = await oidc_service.get_settings(db)
|
|
parts = urlsplit(origin)
|
|
# The redirect URI is built from the browser's own origin (so any hostname the operator uses works), but only
|
|
# an origin this server is actually reachable at is accepted: no path, no credentials, same host as the request.
|
|
if (not oidc_service.configured(cfg) or parts.path not in ("", "/") or parts.query or parts.fragment or parts.username
|
|
or not _origin_allowed(f"{parts.scheme}://{parts.netloc}", request.headers.get("host", ""))):
|
|
return _login_error("disabled")
|
|
try:
|
|
url, state = await oidc_service.start(db, cfg, f"{parts.scheme}://{parts.netloc}{CALLBACK_PATH}")
|
|
except oidc_service.OidcError as exc:
|
|
logger.warning("OIDC start fehlgeschlagen: %s", exc)
|
|
return _login_error(exc.code)
|
|
response = RedirectResponse(url, status_code=302)
|
|
# Binds the attempt to THIS browser: a callback URL handed to somebody else (login CSRF) or a captured
|
|
# code+state pair is useless without the cookie.
|
|
response.set_cookie(STATE_COOKIE, state, max_age=600, httponly=True, samesite="lax", secure=settings.cookie_secure, path="/api/auth/oidc")
|
|
return response
|
|
|
|
|
|
@router.get("/auth/oidc/callback")
|
|
async def callback(request: Request, code: str = "", state: str = "", error: str = "", db: AsyncSession = Depends(get_db)):
|
|
ip = client_ip(request)
|
|
if auth_service.throttle.retry_after(ip, "oidc") > 0:
|
|
return _login_error("throttled")
|
|
cfg = await oidc_service.get_settings(db)
|
|
if not oidc_service.configured(cfg):
|
|
return _login_error("disabled")
|
|
try:
|
|
if error or not code or not state:
|
|
raise oidc_service.OidcError("provider", "Anbieter meldet " + (_printable(error) if error else "keinen Code"))
|
|
if not secrets.compare_digest(request.cookies.get(STATE_COOKIE, ""), state):
|
|
raise oidc_service.OidcError("state", "Der Anmeldeversuch gehört nicht zu diesem Browser")
|
|
user = await oidc_service.complete(db, cfg, code, state)
|
|
except oidc_service.OidcError as exc:
|
|
auth_service.throttle.record_failure(ip, "oidc")
|
|
logger.warning("OIDC-Anmeldung abgelehnt (%s): %s", exc.code, exc)
|
|
return _login_error(exc.code)
|
|
auth_service.throttle.reset(ip, "oidc")
|
|
await auth_service.finalize_login(db, user, ip)
|
|
token, _ = await auth_service.create_session(
|
|
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, lifetime_seconds=SSO_SESSION_SECONDS
|
|
)
|
|
response = RedirectResponse("/dashboard", status_code=302)
|
|
_set_session_cookie(response, token, max_age=SSO_SESSION_SECONDS)
|
|
response.delete_cookie(STATE_COOKIE, path="/api/auth/oidc")
|
|
logger.info("SSO-Anmeldung von '%s' (%s) aus %s", user.username, user.role, ip)
|
|
return response
|
|
|
|
|
|
def _require_admin(user: AdminUser) -> None:
|
|
if user.role != "admin":
|
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
|
|
|
|
|
@router.get("/settings/oidc", response_model=OidcSettingsRead)
|
|
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
return await oidc_service.get_settings(db)
|
|
|
|
|
|
@router.put("/settings/oidc", response_model=OidcSettingsRead)
|
|
async def update(payload: OidcSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
cfg = await oidc_service.get_settings(db)
|
|
for field in ("enabled", "display_name", "issuer", "client_id", "scopes", "groups_claim", "admin_group", "operator_group", "read_only_group"):
|
|
value = getattr(payload, field)
|
|
if value is not None:
|
|
setattr(cfg, field, value.strip() if isinstance(value, str) else value)
|
|
if payload.clear_client_secret:
|
|
notification_secret_store.delete_secret(oidc_service.SECRET_NAME)
|
|
cfg.client_secret_set = False
|
|
elif payload.client_secret is not None:
|
|
notification_secret_store.save_secret(oidc_service.SECRET_NAME, payload.client_secret)
|
|
cfg.client_secret_set = True
|
|
await db.commit()
|
|
oidc_service.clear_caches()
|
|
return cfg
|
|
|
|
|
|
@router.post("/settings/oidc/test")
|
|
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
"""Fetch the provider's discovery document and signing keys: proves the issuer URL, not the login."""
|
|
_require_admin(user)
|
|
cfg = await oidc_service.get_settings(db)
|
|
if not cfg.issuer:
|
|
raise api_error(422, ErrorCode.OIDC_TEST_FAILED, reason="Kein Aussteller eingetragen")
|
|
try:
|
|
oidc_service.clear_caches()
|
|
doc = await oidc_service.discovery(cfg.issuer)
|
|
await oidc_service._jwk_for(doc, None)
|
|
except oidc_service.OidcError as exc:
|
|
raise api_error(502, ErrorCode.OIDC_TEST_FAILED, reason=str(exc)) from exc
|
|
return {"success": True, "authorization_endpoint": doc["authorization_endpoint"]}
|