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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 08:44:33 +02:00
commit 881a5ac2de
6 changed files with 169 additions and 27 deletions

View file

@ -24,9 +24,12 @@ AUTH_ENABLED=true
# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. # Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung.
ADMIN_API_KEY= ADMIN_API_KEY=
# Forward-/Trusted-Header-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer # Forward-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer Remote-Betrieb -
# Remote-Betrieb - siehe deploy/README.md. Lokal leer lassen. # siehe deploy/README.md. Lokal leer lassen. Identitaet per Header ODER Cookie:
# TRUSTED_AUTH_HEADER=X-Remote-User # 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 # TRUSTED_PROXY_IPS=192.168.0.10
# ADMIN_USERS=atoor,dieterschlueter,dschlueter # ADMIN_USERS=atoor,dieterschlueter,dschlueter
# SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout # SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout

View file

@ -1,3 +1,8 @@
import base64
import hashlib
import hmac
import json
from fastapi import Header, HTTPException, Request from fastapi import Header, HTTPException, Request
from app.config import settings, Settings 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()} 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: def is_admin_user(user: User | None, cfg: Settings = settings) -> bool:
"""True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht.""" """True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht."""
if user is None or not user.external_id: 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() store = get_store()
# 1. Forward-/Trusted-Header-Auth (nur von der Proxy-Quell-IP akzeptiert). # 1. Forward-Auth: Identitaet aus Header ODER (signiertem) Cookie - nur von der
if cfg.trusted_auth_header: # Proxy-Quell-IP akzeptiert.
if cfg.trusted_auth_header or cfg.trusted_auth_cookie:
ips = _csv_set(cfg.trusted_proxy_ips) ips = _csv_set(cfg.trusted_proxy_ips)
if ips and client_host in 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: 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 = store.get_or_create_user_by_external_id(external, display_name=external)
user.is_admin = is_admin_user(user, cfg) user.is_admin = is_admin_user(user, cfg)
return user 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). # 2. Auth abgeschaltet (dev/Test).
if not cfg.auth_enabled: if not cfg.auth_enabled:

View file

@ -146,6 +146,12 @@ class Settings(BaseSettings):
# Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer # Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer
# automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth. # automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth.
trusted_auth_header: str = "" 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 trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys
admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten
sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal) sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal)

View file

@ -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 `nginx -t && systemctl reload nginx`. Subdomain `va.linix.de` in YunoHost
anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe). anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe).
## 4. Discovery: richtigen Identitäts-Header bestimmen ## 4. Identität: YunoHost liefert sie im Cookie (nicht als Header)
**Hinter dem SSO** aufrufen (durchs SSO-Portal einloggen, dann diese URL). Da das Discovery (`/api/admin/request-headers?key=<ADMIN_API_KEY>` hinter dem SSO) zeigt:
Gateway den SSO-Admin zu diesem Zeitpunkt noch nicht kennt (Henne-Ei), den Admin-Key YunoHost reicht den Usernamen **nicht** als eigenen Header durch, sondern im **JWT-Cookie
als Query mitgeben: `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=<LAN-IP des YunoHost-Servers>
# optional (Härtung): HS256-Secret des Portals -> Signaturpruefung
# TRUSTED_AUTH_JWT_SECRET=...
``` ```
https://va.linix.de/api/admin/request-headers?key=<ADMIN_API_KEY>
``` In der nginx-Conf ist **keine** `proxy_set_header`-Identitätszeile nötig — Cookies
In der Ausgabe den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. werden ohnehin durchgereicht.
`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 > **Sicherheit:** Ohne `TRUSTED_AUTH_JWT_SECRET` wird die Cookie-Payload ungeprüft
`-Zeile eintragen, Gateway + nginx neu laden. Danach `?key=` nicht mehr nutzen > gelesen. Das ist nur sicher, weil (a) nur die Proxy-Quell-IP akzeptiert wird **und**
(landet in Proxy-Logs) bzw. den Key rotieren. > (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 ## 5. Test
- `https://va.linix.de/` lädt die UI, links „Angemeldet als <SSO-User>". - `https://va.linix.de/` lädt die UI, links „Angemeldet als <SSO-User>".

View file

@ -31,12 +31,8 @@ location / {
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
# Identitaets-Header: SSOwat setzt die Nutzeridentitaet. Nach der Discovery # Identitaet: KEIN eigener Header noetig. YunoHost liefert den Usernamen im
# (GET /api/admin/request-headers hinter dem SSO) den richtigen Namen hier # Cookie "yunohost.portal" (JWT, Claim "user"); Cookies werden hier ohnehin
# FEST setzen und Client-Spoofing verwerfen. Beispiel, wenn SSO $remote_user # durchgereicht. Das Gateway liest den Cookie (TRUSTED_AUTH_COOKIE=yunohost.portal).
# bereitstellt (Name ggf. anpassen): # Wichtig: die Subdomain MUSS per SSO geschuetzt sein (sonst Identitaets-Spoofing).
#
# proxy_set_header X-Remote-User $remote_user;
#
# In der Gateway-Konfiguration dann: TRUSTED_AUTH_HEADER=X-Remote-User
} }

72
tests/test_cookie_auth.py Normal file
View file

@ -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"