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.
|
# 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
|
||||||
|
|
|
||||||
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 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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>".
|
||||||
|
|
|
||||||
|
|
@ -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
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