From 881a5ac2de2089e655ae4bee4a01c2e013ab613d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:44:33 +0200 Subject: [PATCH] feat(auth): Forward-Auth via JWT-Cookie (YunoHost yunohost.portal) Discovery zeigte: YunoHost reicht den Usernamen nicht als Header durch, sondern im signierten Cookie "yunohost.portal" (JWT, Claim "user"). Die Forward-Auth liest jetzt die Identitaet aus Header ODER Cookie - nur von der Proxy-Quell-IP akzeptiert. HS256-Signaturpruefung optional via TRUSTED_AUTH_JWT_SECRET (stdlib hmac, kein Dep). - config: trusted_auth_cookie / _cookie_claim / _jwt_secret - nginx-Vorlage + deploy/README: keine Identitaets-Header-Zeile mehr noetig; SSO-Schutz der Subdomain ist Pflicht (sonst Spoofing) - Tests: Cookie-Extraktion, Signaturpruefung, Proxy-IP-Trust Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 +++-- app/auth.py | 65 ++++++++++++++++++++++++++++--- app/config.py | 6 +++ deploy/README.md | 32 ++++++++++------ deploy/va.linix.de.nginx.conf | 12 ++---- tests/test_cookie_auth.py | 72 +++++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 tests/test_cookie_auth.py diff --git a/.env.example b/.env.example index c97974f..0ed5059 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,12 @@ AUTH_ENABLED=true # Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. ADMIN_API_KEY= -# Forward-/Trusted-Header-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer -# Remote-Betrieb - siehe deploy/README.md. Lokal leer lassen. -# TRUSTED_AUTH_HEADER=X-Remote-User +# Forward-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer Remote-Betrieb - +# siehe deploy/README.md. Lokal leer lassen. Identitaet per Header ODER Cookie: +# TRUSTED_AUTH_HEADER=X-Remote-User # falls der Proxy einen Header setzt +# TRUSTED_AUTH_COOKIE=yunohost.portal # YunoHost: Username im JWT-Cookie +# TRUSTED_AUTH_COOKIE_CLAIM=user +# TRUSTED_AUTH_JWT_SECRET= # optional: HS256-Signatur pruefen # TRUSTED_PROXY_IPS=192.168.0.10 # ADMIN_USERS=atoor,dieterschlueter,dschlueter # SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout diff --git a/app/auth.py b/app/auth.py index bb55720..b8ff45d 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,3 +1,8 @@ +import base64 +import hashlib +import hmac +import json + from fastapi import Header, HTTPException, Request from app.config import settings, Settings @@ -9,6 +14,51 @@ def _csv_set(value: str) -> set[str]: return {item.strip() for item in (value or "").split(",") if item.strip()} +def _b64url_decode(data: str) -> bytes: + return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) + + +def _cookie_value(cookie_header: str | None, name: str) -> str | None: + """Liest einen Cookie-Wert robust aus dem Cookie-Header (ohne SimpleCookie).""" + if not cookie_header or not name: + return None + for part in cookie_header.split(";"): + part = part.strip() + if part.startswith(name + "="): + return part[len(name) + 1:] + return None + + +def _username_from_cookie(headers, cfg: Settings) -> str | None: + """Extrahiert den Usernamen aus einem JWT-Cookie (z. B. YunoHost 'yunohost.portal'). + + Mit gesetztem `trusted_auth_jwt_secret` wird die HS256-Signatur geprueft. Ohne + Secret wird die Payload ungeprueft gelesen - das ist nur sicher, weil (a) nur die + Proxy-Quell-IP akzeptiert wird und (b) das SSO unauthentifizierte Anfragen gar nicht + erst durchlaesst (also nur vom SSO validierte Cookies hier ankommen). + """ + token = _cookie_value(headers.get("cookie"), cfg.trusted_auth_cookie) + if not token or token.count(".") != 2: + return None + header_b64, payload_b64, sig_b64 = token.split(".") + secret = cfg.trusted_auth_jwt_secret.strip() + if secret: + expected = hmac.new( + secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256 + ).digest() + try: + if not hmac.compare_digest(expected, _b64url_decode(sig_b64)): + return None + except (ValueError, TypeError): + return None + try: + payload = json.loads(_b64url_decode(payload_b64)) + except (ValueError, TypeError): + return None + value = payload.get(cfg.trusted_auth_cookie_claim) + return value.strip() if isinstance(value, str) and value.strip() else None + + def is_admin_user(user: User | None, cfg: Settings = settings) -> bool: """True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht.""" if user is None or not user.external_id: @@ -29,17 +79,22 @@ def authenticate(headers, client_host: str, token: str | None, """ store = get_store() - # 1. Forward-/Trusted-Header-Auth (nur von der Proxy-Quell-IP akzeptiert). - if cfg.trusted_auth_header: + # 1. Forward-Auth: Identitaet aus Header ODER (signiertem) Cookie - nur von der + # Proxy-Quell-IP akzeptiert. + if cfg.trusted_auth_header or cfg.trusted_auth_cookie: ips = _csv_set(cfg.trusted_proxy_ips) if ips and client_host in ips: - external = (headers.get(cfg.trusted_auth_header) or "").strip() + external = None + if cfg.trusted_auth_header: + external = (headers.get(cfg.trusted_auth_header) or "").strip() or None + if not external and cfg.trusted_auth_cookie: + external = _username_from_cookie(headers, cfg) if not external: - return None # SSO sollte den Header immer setzen -> 401 + return None # SSO sollte die Identitaet immer liefern -> 401 user = store.get_or_create_user_by_external_id(external, display_name=external) user.is_admin = is_admin_user(user, cfg) return user - # Nicht von der Proxy-IP -> Header ignorieren, normale Auth unten. + # Nicht von der Proxy-IP -> ignorieren, normale Auth unten. # 2. Auth abgeschaltet (dev/Test). if not cfg.auth_enabled: diff --git a/app/config.py b/app/config.py index 92cf500..66afebb 100644 --- a/app/config.py +++ b/app/config.py @@ -146,6 +146,12 @@ class Settings(BaseSettings): # Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer # automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth. trusted_auth_header: str = "" + # Alternativ zur Header-Variante: Identitaet aus einem (signierten) JWT-Cookie lesen. + # YunoHost reicht den Usernamen nicht als Header durch, sondern im Cookie + # "yunohost.portal" (JWT, Claim "user"). Nur von der Proxy-IP akzeptiert. + trusted_auth_cookie: str = "" # Cookie-Name (z. B. yunohost.portal) + trusted_auth_cookie_claim: str = "user" # JWT-Claim mit dem Usernamen + trusted_auth_jwt_secret: str = "" # optional: HS256-Secret -> Signatur pruefen trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal) diff --git a/deploy/README.md b/deploy/README.md index 159f0cb..5422260 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -38,18 +38,28 @@ eintragen, die `map $http_upgrade …` einmalig im http{}-Kontext anlegen, dann `nginx -t && systemctl reload nginx`. Subdomain `va.linix.de` in YunoHost anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe). -## 4. Discovery: richtigen Identitäts-Header bestimmen -**Hinter dem SSO** aufrufen (durchs SSO-Portal einloggen, dann diese URL). Da das -Gateway den SSO-Admin zu diesem Zeitpunkt noch nicht kennt (Henne-Ei), den Admin-Key -als Query mitgeben: +## 4. Identität: YunoHost liefert sie im Cookie (nicht als Header) +Discovery (`/api/admin/request-headers?key=` hinter dem SSO) zeigt: +YunoHost reicht den Usernamen **nicht** als eigenen Header durch, sondern im **JWT-Cookie +`yunohost.portal`** (Claim `user`). Das Gateway liest diesen Cookie direkt — daher: + +```ini +# .env auf der GPU-Box: +TRUSTED_AUTH_COOKIE=yunohost.portal +TRUSTED_AUTH_COOKIE_CLAIM=user +TRUSTED_PROXY_IPS= +# optional (Härtung): HS256-Secret des Portals -> Signaturpruefung +# TRUSTED_AUTH_JWT_SECRET=... ``` -https://va.linix.de/api/admin/request-headers?key= -``` -In der Ausgabe den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. -`X-Remote-User`, `Remote-User`, `Auth-User`, oder `Authorization: Basic …` mit dem -Usernamen). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der nginx-`proxy_set_header -…`-Zeile eintragen, Gateway + nginx neu laden. Danach `?key=` nicht mehr nutzen -(landet in Proxy-Logs) bzw. den Key rotieren. + +In der nginx-Conf ist **keine** `proxy_set_header`-Identitätszeile nötig — Cookies +werden ohnehin durchgereicht. + +> **Sicherheit:** Ohne `TRUSTED_AUTH_JWT_SECRET` wird die Cookie-Payload ungeprüft +> gelesen. Das ist nur sicher, weil (a) nur die Proxy-Quell-IP akzeptiert wird **und** +> (b) die Subdomain **per SSO geschützt** sein muss (dann lässt YunoHost nur validierte +> Cookies durch). Für Härtung das Portal-HS256-Secret in `TRUSTED_AUTH_JWT_SECRET` +> setzen → die Signatur wird dann selbst geprüft. ## 5. Test - `https://va.linix.de/` lädt die UI, links „Angemeldet als ". diff --git a/deploy/va.linix.de.nginx.conf b/deploy/va.linix.de.nginx.conf index 5df5b95..91bcf4f 100644 --- a/deploy/va.linix.de.nginx.conf +++ b/deploy/va.linix.de.nginx.conf @@ -31,12 +31,8 @@ location / { proxy_read_timeout 3600s; proxy_send_timeout 3600s; - # Identitaets-Header: SSOwat setzt die Nutzeridentitaet. Nach der Discovery - # (GET /api/admin/request-headers hinter dem SSO) den richtigen Namen hier - # FEST setzen und Client-Spoofing verwerfen. Beispiel, wenn SSO $remote_user - # bereitstellt (Name ggf. anpassen): - # - # proxy_set_header X-Remote-User $remote_user; - # - # In der Gateway-Konfiguration dann: TRUSTED_AUTH_HEADER=X-Remote-User + # Identitaet: KEIN eigener Header noetig. YunoHost liefert den Usernamen im + # Cookie "yunohost.portal" (JWT, Claim "user"); Cookies werden hier ohnehin + # durchgereicht. Das Gateway liest den Cookie (TRUSTED_AUTH_COOKIE=yunohost.portal). + # Wichtig: die Subdomain MUSS per SSO geschuetzt sein (sonst Identitaets-Spoofing). } diff --git a/tests/test_cookie_auth.py b/tests/test_cookie_auth.py new file mode 100644 index 0000000..e51d218 --- /dev/null +++ b/tests/test_cookie_auth.py @@ -0,0 +1,72 @@ +import base64 +import hashlib +import hmac +import json + +import pytest + +from app.auth import _username_from_cookie, authenticate +from app.config import settings + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _make_jwt(payload: dict, secret: str | None = None) -> str: + header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + body = _b64url(json.dumps(payload).encode()) + signing = f"{header}.{body}".encode() + sig = hmac.new((secret or "x").encode(), signing, hashlib.sha256).digest() + return f"{header}.{body}.{_b64url(sig)}" + + +class _Headers(dict): + """Case-insensitives .get wie Starlette-Headers (nur was wir brauchen).""" + def get(self, key, default=None): + return super().get(key.lower(), default) + + +def _cfg(monkeypatch, **over): + monkeypatch.setattr(settings, "trusted_auth_cookie", "yunohost.portal") + monkeypatch.setattr(settings, "trusted_auth_cookie_claim", "user") + monkeypatch.setattr(settings, "trusted_auth_jwt_secret", over.get("secret", "")) + return settings + + +def test_username_from_unsigned_cookie(monkeypatch): + _cfg(monkeypatch) + jwt = _make_jwt({"user": "dieterschlueter", "host": "linix.de"}) + headers = _Headers({"cookie": f"foo=bar; yunohost.portal={jwt}; baz=qux"}) + assert _username_from_cookie(headers, settings) == "dieterschlueter" + + +def test_no_cookie_returns_none(monkeypatch): + _cfg(monkeypatch) + assert _username_from_cookie(_Headers({"cookie": "foo=bar"}), settings) is None + assert _username_from_cookie(_Headers({}), settings) is None + + +def test_signature_checked_when_secret_set(monkeypatch): + _cfg(monkeypatch, secret="geheim") + good = _make_jwt({"user": "atoor"}, secret="geheim") + bad = _make_jwt({"user": "atoor"}, secret="falsch") + assert _username_from_cookie(_Headers({"cookie": f"yunohost.portal={good}"}), settings) == "atoor" + assert _username_from_cookie(_Headers({"cookie": f"yunohost.portal={bad}"}), settings) is None + + +def test_authenticate_via_cookie_from_trusted_proxy(monkeypatch): + import app.dependencies as deps + _cfg(monkeypatch) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_proxy_ips", "192.168.179.10") + monkeypatch.setattr(settings, "admin_users", "atoor,dieterschlueter,dschlueter") + jwt = _make_jwt({"user": "dieterschlueter"}) + headers = _Headers({"cookie": f"yunohost.portal={jwt}"}) + + user = authenticate(headers, "192.168.179.10", None) + assert user is not None + assert user.external_id == "dieterschlueter" and user.is_admin is True + # Von fremder IP wird das Cookie ignoriert. + assert authenticate(headers, "10.0.0.1", None) is None or \ + authenticate(headers, "10.0.0.1", None).external_id != "dieterschlueter"