Files
OPNexus/backend/app/auth_middleware.py
T
Julian Boldt c662a58426
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
Release v1.15.0
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:

- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
  neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
  automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
  (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
  **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
  in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
  **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
  hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
  ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
  „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
  Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
  Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
  zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
2026-10-10 00:24:01 +02:00

142 lines
6.8 KiB
Python

import uuid
from app.services import audit_service
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.database import SessionLocal
from app.errors import ErrorCode
from app.routers.auth import COOKIE_NAME
from app.services import auth_service
_PUBLIC_PATHS = {
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
"/api/health",
}
# /docs, /redoc and /openapi.json used to be public — fully documenting a
# firewall-fleet-management API surface to anyone unauthenticated is
# unnecessary recon for an attacker probing a public instance. Kept out
# of _PUBLIC_PATHS so a logged-in browser session (cookie sent
# automatically, same-origin) still sees them fine, anonymous requests
# now get the normal 401 like any other endpoint.
# A read_only user can call any GET, plus these specific writes — both are
# purely self-scoped (only ever touch the caller's own row, never app
# config or another account) so blocking them would serve no security
# purpose while breaking normal self-service: change-password so a
# read_only account can always get out of a leaked/weak password, and
# PUT /settings because that endpoint only ever writes the caller's own
# UserPreferences.theme (see routers/settings.py) — a read_only user must
# still be able to pick light/dark/system for themselves.
_READ_ONLY_ALLOWED_WRITES = {
"/api/auth/change-password",
"/api/settings",
# Self-scoped: a user managing their own second factor.
"/api/auth/2fa/setup",
"/api/auth/2fa/enable",
"/api/auth/2fa/disable",
}
# Resource families whose URLs are /api/<family>/<collection>/<id>/<action>
# (or /api/<family>/<action> for the account/notification endpoints). The
# older kinds below keep their original /api/<kind>/<id>/<action> parsing.
_SECURITY_KINDS = {
"ipsec": "ipsec_tunnel", "openvpn": "openvpn_instance", "wireguard": "wireguard_tunnel",
"certificates": "certificate", "acme": "acme", "notifications": "notification", "auth": "auth",
}
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings"}
def _is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
return True
except ValueError:
return False
def _security_event(parts: list[str], method: str) -> dict:
"""kind/action/entity/host for VPN, certificate, notification and account-security
requests. Only path segments are used — never query strings or bodies."""
ids = [p for p in parts[2:] if _is_uuid(p)]
tail = parts[-1]
if parts[1] == "auth":
action = "/".join(parts[2:])
elif method == "DELETE":
action = "delete"
elif ids and not _is_uuid(tail):
action = tail # deploy / push / remove / register / sign / poll
elif method in ("PUT", "PATCH"):
action = "edit"
elif not ids and tail not in _COLLECTIONS and len(parts) > 2:
action = tail # e.g. notifications/test
else:
action = "create"
return {"kind": _SECURITY_KINDS[parts[1]], "action": action[:40], "entity_id": ids[0] if ids else None,
"host_id": ids[1] if len(ids) > 1 else None}
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path in _PUBLIC_PATHS:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME, "")
if token:
async with SessionLocal() as db:
user = await auth_service.resolve_session(db, token)
if user is not None:
parts = request.url.path.strip("/").split("/")
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"}
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
kind = event["kind"]
else:
kind = kinds.get(parts[1]) if len(parts) > 1 else None
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None,
"host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
"details": {"method": request.method, "path": request.url.path}}
tracked = kind and writing and not request.url.path.endswith(("/preview", "/carp-preview", "/revisions"))
if (
user.role == "read_only"
and request.method not in ("GET", "HEAD", "OPTIONS")
and request.url.path not in _READ_ONLY_ALLOWED_WRITES
):
if tracked:
# Refused attempts at security-relevant writes stay attributable.
ctx = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
try:
await audit_service.persist(**event, status="denied", message="Schreibzugriff für Nur-Lese-Konto verweigert")
finally:
audit_service.context.reset(ctx)
return JSONResponse(
status_code=403,
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
)
request.state.user = user
token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
try:
if tracked:
await audit_service.persist(**event, status="requested")
response = await call_next(request)
if tracked:
await audit_service.persist(**event, status="accepted" if response.status_code < 400 else "rejected",
message=f"HTTP {response.status_code}")
return response
except Exception:
if tracked:
await audit_service.persist(**event, status="interrupted", message="Anfrage nicht abgeschlossen; Zustand vor Wiederholung prüfen.")
raise
finally:
audit_service.context.reset(token)
return JSONResponse(status_code=401, content={"detail": {"code": ErrorCode.NICHT_AUTHENTIFIZIERT.value, "params": {}}})