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:
parent
bd55807a1c
commit
6e824d8f9b
3 changed files with 43 additions and 22 deletions
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
22
app/main.py
22
app/main.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue