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:
parent
1899663308
commit
881a5ac2de
6 changed files with 169 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
65
app/auth.py
65
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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=<ADMIN_API_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=<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 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 <SSO-User>".
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
}
|
||||
|
|
|
|||
72
tests/test_cookie_auth.py
Normal file
72
tests/test_cookie_auth.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue