From c38b27ca85dd4f1203afea2a24833854b1c004de Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 17 Aug 2026 00:28:31 +0200 Subject: [PATCH] Anmeldung: Host-Abweichung frueh erkennen, weniger auf der Anmeldeseite verraten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Betrieb aufgetreten: Die Anwendung lief hinter einem Reverse Proxy unter einer Domain, in der .env stand aber noch die interne Adresse aus der Einrichtung. Der Anmeldeknopf setzt den Cookie mit dem laufenden Vorgang auf dem Host, ueber den der Browser da ist — die Rueckkehradresse fuer den Anbieter kommt dagegen aus BASE_URL. Der Anbieter schickte den Browser also auf den anderen Host, der Cookie blieb zurueck, und am Ende stand „Der Anmeldevorgang ist abgelaufen". Eine Meldung, die die Ursache gut versteckt. Jetzt prueft der Start beide Adressen und bricht sofort mit einer Erklaerung ab. Auf dem Bildschirm steht nur die aufgerufene Adresse — die interne gehoert ins Protokoll, nicht auf eine Seite, die jeder sieht. Aus demselben Grund faellt der Hinweis „Die Anmeldung mit Passwort ist abgeschaltet (OIDC_ONLY=true)" weg. Wie die Anmeldung eingerichtet ist, geht Fremde nichts an; wer hierher gehoert, sieht den einen Knopf und weiss, was zu tun ist. Beim allerersten Aufruf bleibt der Hinweis, dass der Erste Administrator wird — der ist dann nuetzlich. Dazu die Anleitungen: Der Reverse-Proxy-Abschnitt im README nennt jetzt die drei Kopfzeilen, die die Anwendung vom Proxy erwartet, und was ohne sie passiert; dazu client_max_body_size fuer Uploads, ein Weg zum Nachpruefen und die Frage, fuer wen die Codes erreichbar sein sollen. In authentik.md steht, warum BASE_URL und die Adresse im Browser dieselbe sein muessen. --- README.md | 97 ++++++++++++++++++++++++++-- docs/authentik.md | 31 ++++++++- src/app/(app)/anmelden/page.tsx | 16 +++-- src/app/api/auth/oidc/start/route.ts | 8 ++- src/lib/auth/oidc.ts | 52 +++++++++++++++ 5 files changed, 189 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 149d5c1..d068ed1 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,25 @@ BASE_URL=http://192.168.1.50:3000 > **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht > das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort > `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy -> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die -> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht -> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen. +> läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr +> umbiegen, also am besten vor dem ersten produktiven Code festlegen. +> +> Die Frage dahinter lautet: **Wer soll die Codes scannen können?** +> +> | Wer scannt | Was in `BASE_URL` gehört | +> | --- | --- | +> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` | +> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` | +> +> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine +> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann +> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden +> Hinweis darauf, woran es liegt. +> +> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche +> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung +> über einen externen Anbieter, siehe +> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der Servicename aus der `docker-compose.yml`, nicht dein Server. @@ -234,7 +250,7 @@ BASE_URL=https://qr.deine-domain.de | Scheme | `http` | | Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | | Forward Port | `3000` | -| Websockets Support | an | +| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht | | Block Common Exploits | an | | SSL | Zertifikat anfordern, *Force SSL* aktivieren | @@ -249,9 +265,76 @@ Port. Danach: docker compose up -d ``` -> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung -> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue -> dynamische Codes ins interne Netz statt auf die Domain. +> `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung +> aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin +> `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz +> statt auf die Domain, und die Anmeldung über einen externen Anbieter +> scheitert (siehe unten). + +### Was der Proxy weiterreichen muss + +Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx +Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man +sie eintragen. + +| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das | +| --- | --- | --- | +| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit | +| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet | +| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender | + +Für ein reines nginx (ohne NPM) heißt das: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +``` + +Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen +von sich aus. + +### Größe der Uploads + +PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von +`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu +sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der +Oberfläche. + +Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung +10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter +**Advanced → Custom Nginx Configuration**: + +```nginx +client_max_body_size 20m; +``` + +### Nachprüfen, ob es stimmt + +```bash +# Kommt die Anwendung überhaupt an? +curl -I https://qr.deine-domain.de/ + +# Kennt der Container dieselbe Adresse wie der Browser? +docker compose exec app env | grep BASE_URL +``` + +Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain +öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der +Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber + +- jeder neue dynamische Code zeigt auf die interne Adresse, und +- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung, + weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe + [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). + +### Erreichbar für wen? + +Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im +internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte +Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain +öffentlich auflösbar und der Proxy von außen erreichbar sein. ### Mehrere Domains diff --git a/docs/authentik.md b/docs/authentik.md index f450bc3..b060d77 100644 --- a/docs/authentik.md +++ b/docs/authentik.md @@ -94,6 +94,34 @@ Zu beachten: > falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert, > bevor der erste dynamische Code entsteht: Sie steckt in jedem davon. +### `BASE_URL` und die Adresse im Browser müssen dieselbe sein + +Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die +Anwendung tatsächlich aufruft. + +Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung +einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser +gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`. +Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den +anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit +**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der +Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen +hat. + +Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide +Adressen, statt den Vorgang bis zum Ende laufen zu lassen. + +Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter +`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse +`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in +Übereinstimmung gebracht: + +| | | +| --- | --- | +| Aufruf im Browser | `https://qr.example.com` | +| `BASE_URL` in der `.env` | `https://qr.example.com` | +| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` | + --- ## Schritt 2 — Eine Application anlegen @@ -259,7 +287,8 @@ Danach verschwindet das Passwortfeld von der Anmeldeseite. | Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. | | „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. | | „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. | -| „Der Anmeldevorgang ist abgelaufen“ | Zwischen Klick und Rückkehr sind mehr als 10 Minuten vergangen, oder der Cookie ging verloren. Häufigste echte Ursache: `BASE_URL` zeigt auf eine andere Adresse als die, über die der Browser kommt. | +| „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). | +| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. | | „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. | | Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. | diff --git a/src/app/(app)/anmelden/page.tsx b/src/app/(app)/anmelden/page.tsx index e8017b5..2fed961 100644 --- a/src/app/(app)/anmelden/page.tsx +++ b/src/app/(app)/anmelden/page.tsx @@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) { Gruppen, Benutzer und die QR-Typen verwalten. ) : oidc?.nurOidc ? ( -

- Die Anmeldung mit Passwort ist abgeschaltet (OIDC_ONLY=true). - {setup - ? " Wer sich zuerst anmeldet, wird Administrator dieser Installation." - : ""} -

+ /* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung + abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt — + wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer + hierher gehört, sieht den einen Knopf und weiß, was zu tun ist. + Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */ + setup ? ( +

+ Wer sich zuerst anmeldet, wird Administrator dieser Installation. +

+ ) : null ) : (

Passwort vergessen? Ein Administrator kann es unter Verwaltung → diff --git a/src/app/api/auth/oidc/start/route.ts b/src/app/api/auth/oidc/start/route.ts index 0595e61..41911cd 100644 --- a/src/app/api/auth/oidc/start/route.ts +++ b/src/app/api/auth/oidc/start/route.ts @@ -1,8 +1,10 @@ import { createHash, randomBytes } from "node:crypto"; import { cookies } from "next/headers"; +import type { NextRequest } from "next/server"; import { isAuthEnabled } from "@/lib/auth/actor"; import { + assertHostMatchesBaseUrl, discover, getOidcConfig, isOidcEnabled, @@ -26,7 +28,7 @@ function base64url(buffer: Buffer): string { * zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann, * wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll. */ -export async function GET(): Promise { +export async function GET(request: NextRequest): Promise { try { if (!isAuthEnabled()) { throw new QrRequestError( @@ -41,6 +43,10 @@ export async function GET(): Promise { ); } + // Vor allem anderen: Kommt der Browser über die Adresse, auf die + // BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren. + assertHostMatchesBaseUrl(request.headers); + const config = getOidcConfig(); const meta = await discover(config.issuer); diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 73c0a35..185a3c5 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -96,6 +96,58 @@ export function redirectUri(): string { return `${getBaseUrl()}/api/auth/oidc/callback`; } +/** + * Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt. + * + * Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden + * Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die + * Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die + * beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen + * Host, der Cookie geht dabei verloren, und die Anmeldung endet mit + * „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche + * Ursache gut versteckt. + * + * Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt. + */ +export function assertHostMatchesBaseUrl(headers: Headers): void { + const angekommen = ( + headers.get("x-forwarded-host") ?? + headers.get("host") ?? + "" + ) + .split(",")[0] + .trim() + .toLowerCase(); + + if (!angekommen) return; + + let erwartet = ""; + try { + erwartet = new URL(getBaseUrl()).host.toLowerCase(); + } catch { + return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen. + } + + if (angekommen === erwartet) return; + + // Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server + // bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf + // ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die + // interne Adresse der Installation geht Fremde nichts an. + console.error( + `OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`, + ); + + throw new QrRequestError( + `Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` + + `In der Konfiguration steht eine andere Adresse, deshalb ginge der ` + + `Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` + + `findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` + + `Protokoll des Containers.`, + 400, + ); +} + type Discovery = { issuer: string; authorization_endpoint: string;