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;