feat(2-Schritt3): nginx-Cutover — App per Token-Link, Admin hinter Authelia

Sicherheits-Architektur umgestellt (live verifiziert):
- App-Routen (/, /ws/) ohne Authelia -> Auth via Capability-Token-Cookie
  (Senioren: Ein-Klick-Link, kein 2FA). Remote-User wird in nginx HART
  geleert (Anti-Spoofing: App vertraut Remote-User von der Proxy-IP).
- /api/admin/* (und /api/admin/log) zusätzlich hinter Authelia (Doppelschutz).
- gate_web_ui: statt SSO-Redirect (würde nach Cutover schleifen) eine
  freundliche "Persönlicher Zugang nötig"-Hinweisseite (401).

Endabnahme durch nginx: Link->303+Cookie, /api/me->200 (kein 2FA),
Admin->302 Authelia, Header-Spoofing->401. DEPLOYMENT.md 2.8 aktualisiert.
Test auf neue Hinweisseite angepasst. 219 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 04:45:11 +02:00
commit 6e824d8f9b
3 changed files with 43 additions and 22 deletions

View file

@ -1019,25 +1019,7 @@ server {
}
# WebSocket-Routen /ws/chat und /ws/voice
location /ws/ {
auth_request /authelia-check;
auth_request_set $user $upstream_http_remote_user;
error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$http_host$request_uri;
proxy_pass http://127.0.0.1:8003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For "";
proxy_set_header Remote-User $user;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# Admin-Live-Log (WebSocket) — Auth wie unten, aber mit WS-Upgrade wie /ws/.
# OHNE diesen Block bleibt der Log-Tab im Admin-Panel leer ("Verbindungsfehler"),
# weil /api/admin/log sonst in location / ohne Upgrade-Header landet.
# Admin-Live-Log (WebSocket) — hinter Authelia, mit WS-Upgrade.
location = /api/admin/log {
auth_request /authelia-check;
auth_request_set $user $upstream_http_remote_user;
@ -1054,11 +1036,11 @@ server {
proxy_send_timeout 300s;
}
# Alle anderen Routen — Authelia 2FA-Schutz
location / {
# Admin-API — ZUSÄTZLICH hinter Authelia (Doppelschutz). Identität via Remote-User.
location /api/admin/ {
auth_request /authelia-check;
auth_request_set $user $upstream_http_remote_user;
error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$host$request_uri;
error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$http_host$request_uri;
proxy_pass http://127.0.0.1:8003;
proxy_http_version 1.1;
@ -1068,6 +1050,33 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Remote-User $user;
}
# Sprach-/Chat-WebSockets — KEIN Authelia. Auth via Token-Cookie (Senioren-Link).
# Remote-User HART LEEREN: sonst könnte ein Client per Header eine Identität
# vortäuschen (die App vertraut Remote-User von der Proxy-IP 127.0.0.1).
location /ws/ {
proxy_pass http://127.0.0.1:8003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For "";
proxy_set_header Remote-User "";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# App (Senioren) — KEIN Authelia. Auth via Token-Cookie / Ein-Klick-Link
# (https://voice.jamulix.de/?k=<token>). Remote-User HART LEEREN (Anti-Spoofing).
location / {
proxy_pass http://127.0.0.1:8003;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Remote-User "";
}
}
```

View file

@ -5,7 +5,7 @@ from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.responses import RedirectResponse, JSONResponse
from starlette.responses import RedirectResponse, JSONResponse, HTMLResponse
from app.config import settings
from urllib.parse import urlencode
@ -55,6 +55,19 @@ async def record_metrics(request: Request, call_next):
# Pfade mit eigener Auth, Health-Checks und Favicons: nicht gaten.
_NO_ACCESS_PAGE = """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Zugang</title>
<style>body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;color:#1e293b;
display:grid;place-items:center;min-height:100vh;margin:0;padding:1.5rem}
.box{max-width:30rem;text-align:center}h1{font-size:1.3rem;margin:0 0 .75rem}
p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem}</style></head>
<body><div class="box"><div class="emoji">🔒</div>
<h1>Persönlicher Zugang nötig</h1>
<p>Bitte öffne die App über deinen persönlichen Zugangslink.</p>
<p>Wenn du keinen Link hast, wende dich an deine Betreuungsperson.</p>
</div></body></html>"""
_PUBLIC_PREFIXES = ("/api", "/ws", "/health", "/favicon", "/apple-touch-icon")
@ -83,10 +96,9 @@ async def gate_web_ui(request: Request, call_next):
token = (_bearer_token(request.headers.get("authorization"))
or k or request.cookies.get(CAPABILITY_COOKIE))
if authenticate(request.headers, client_host, token) is None:
login = settings.sso_login_url.strip()
if login:
return RedirectResponse(login, status_code=302)
return JSONResponse({"detail": "Authentication required"}, status_code=401)
# Kein gültiger Zugang -> freundliche Hinweisseite (KEIN SSO-Redirect, der
# nach dem nginx-Cutover in eine Schleife liefe). Senioren nutzen ihren Link.
return HTMLResponse(_NO_ACCESS_PAGE, status_code=401)
return await call_next(request)

View file

@ -88,15 +88,15 @@ def test_static_index_gated_without_auth(monkeypatch):
assert r.status_code == 401
def test_static_index_redirects_to_sso(monkeypatch):
# Auth an, keine Identitaet, Login-URL gesetzt -> Redirect zum SSO-Portal.
def test_static_index_shows_no_access_page(monkeypatch):
# Auth an, keine Identitaet, kein Zugangslink -> freundliche Hinweisseite (401),
# KEIN SSO-Redirect mehr (würde nach dem nginx-Cutover in eine Schleife laufen).
monkeypatch.setattr(settings, "auth_enabled", True)
monkeypatch.setattr(settings, "trusted_auth_header", "")
monkeypatch.setattr(settings, "trusted_auth_cookie", "")
monkeypatch.setattr(settings, "sso_login_url", "https://linix.de/yunohost/sso/")
r = client.get("/", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].startswith("https://linix.de/yunohost/sso/")
assert r.status_code == 401
assert "Persönlicher Zugang" in r.text
def test_health_open_without_auth(monkeypatch):