Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec532296d | ||
|
|
75252b32b4 | ||
|
|
1064915d37 | ||
|
|
7fcdcba694 | ||
|
|
055d8caaa0 | ||
|
|
e4ff55a6c9 | ||
|
|
c8d60d8006 |
@@ -26,6 +26,46 @@ AUTH_ENABLED=false
|
|||||||
# grosse Werte machen die Sicherung entsprechend schwer.
|
# grosse Werte machen die Sicherung entsprechend schwer.
|
||||||
MAX_UPLOAD_MB=10
|
MAX_UPLOAD_MB=10
|
||||||
|
|
||||||
|
# Sitzungsdauer in Tagen.
|
||||||
|
# SESSION_IDLE_DAYS — ohne Aktivitaet laeuft die Sitzung ab.
|
||||||
|
# SESSION_MAX_DAYS — harte Obergrenze ab dem Anmelden, unabhaengig davon,
|
||||||
|
# wie oft sie benutzt wurde.
|
||||||
|
# OWASP empfiehlt fuer oeffentliche Anwendungen deutlich kuerzere Werte
|
||||||
|
# (Minuten bzw. Stunden); siehe docs/sicherheit.md.
|
||||||
|
SESSION_IDLE_DAYS=30
|
||||||
|
SESSION_MAX_DAYS=90
|
||||||
|
|
||||||
|
# --- Anmeldung ueber Authentik oder einen anderen OIDC-Anbieter -------------
|
||||||
|
# Setzt AUTH_ENABLED=true voraus. Einrichtung siehe docs/authentik.md.
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
|
||||||
|
# Basis-URL des Anbieters, unter der /.well-known/openid-configuration liegt.
|
||||||
|
# Bei Authentik: https://auth.example.com/application/o/<slug>/
|
||||||
|
OIDC_ISSUER=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Beschriftung des Knopfs auf der Anmeldeseite.
|
||||||
|
OIDC_BUTTON_LABEL=Mit Authentik anmelden
|
||||||
|
|
||||||
|
# Angeforderte Bereiche. "openid" ist Pflicht, "email" wird gebraucht.
|
||||||
|
OIDC_SCOPES=openid email profile
|
||||||
|
|
||||||
|
# Nur noch ueber den Anbieter anmelden? Dann verschwindet das Passwortfeld.
|
||||||
|
# Vorsicht: Faellt der Anbieter aus, kommt niemand mehr hinein — zum Loesen
|
||||||
|
# hier auf false setzen und neu starten.
|
||||||
|
OIDC_ONLY=false
|
||||||
|
|
||||||
|
# Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen.
|
||||||
|
# VIEWER (nur lesen), EDITOR (Codes anlegen und aendern) oder ADMIN.
|
||||||
|
OIDC_DEFAULT_ROLE=VIEWER
|
||||||
|
|
||||||
|
# Gruppe beim Anbieter, deren Mitglieder die Verwaltung oeffnen duerfen.
|
||||||
|
# Leer lassen, um die Administratorrechte hier in der Anwendung zu pflegen.
|
||||||
|
OIDC_ADMIN_GROUP=
|
||||||
|
# Name des Anspruchs, in dem die Gruppen stehen (Authentik: groups).
|
||||||
|
OIDC_GROUPS_CLAIM=groups
|
||||||
|
|
||||||
# --- PostgreSQL ------------------------------------------------------------
|
# --- PostgreSQL ------------------------------------------------------------
|
||||||
POSTGRES_USER=qrcode
|
POSTGRES_USER=qrcode
|
||||||
POSTGRES_PASSWORD=bitte-aendern
|
POSTGRES_PASSWORD=bitte-aendern
|
||||||
|
|||||||
@@ -8,6 +8,45 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Was die Anwendung kann — kurz
|
||||||
|
|
||||||
|
- **15 QR-Typen:** Website, vCard, App-Link, Mehrere Links, Social Media,
|
||||||
|
WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN, Feedback.
|
||||||
|
- **Statisch oder dynamisch.** Dynamische Codes zeigen auf eine Kurz-URL:
|
||||||
|
Ziel jederzeit änderbar, ohne neu zu drucken — mit Scan-Zähler.
|
||||||
|
- **Eigene Seiten** für Linksammlungen, Profile, hinterlegte PDFs und Bilder
|
||||||
|
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
||||||
|
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
||||||
|
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||||
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
||||||
|
Ziel ändern, löschen.
|
||||||
|
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||||
|
erhoben, ohne Name und IP-Adresse.
|
||||||
|
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||||
|
Administrator, Bearbeiter und Betrachter.
|
||||||
|
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
||||||
|
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||||
|
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
||||||
|
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||||
|
von selbst; alles Nötige steht in einer `.env`.
|
||||||
|
|
||||||
|
→ Ausführlich: **[Alle Funktionen im Detail](docs/funktionen.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anleitungen
|
||||||
|
|
||||||
|
- **[Bedienungsanleitung mit Bildern](docs/anleitung.md)** — vom ersten Code
|
||||||
|
bis zur Auswertung von Rückmeldungen
|
||||||
|
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
||||||
|
Rollen und der Weg zurück
|
||||||
|
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
||||||
|
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
|
||||||
|
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
||||||
|
Härtungen und was bewusst offen bleibt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Inhalt
|
## Inhalt
|
||||||
|
|
||||||
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
|
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
|
||||||
@@ -17,10 +56,11 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
- [Konfiguration](#konfiguration)
|
- [Konfiguration](#konfiguration)
|
||||||
- [Bedienung](#bedienung)
|
- [Bedienung](#bedienung)
|
||||||
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||||
|
- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc)
|
||||||
- [Technischer Aufbau](#technischer-aufbau)
|
- [Technischer Aufbau](#technischer-aufbau)
|
||||||
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
- [Ausblick Version 2](#ausblick-version-2)
|
- [Einen weiteren Typ ergänzen](#einen-weiteren-typ-ergänzen)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -655,6 +695,37 @@ zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen —
|
|||||||
sonst führten gedruckte Codes ins Leere.
|
sonst führten gedruckte Codes ins Leere.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## Anmeldung über Authentik (OIDC)
|
||||||
|
|
||||||
|
Statt Passwörter hier zu pflegen, lässt sich die Anmeldung an einen
|
||||||
|
OpenID-Connect-Anbieter abgeben — getestet mit **Authentik**, ebenso nutzbar
|
||||||
|
mit Keycloak, Zitadel oder Entra ID.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
|
||||||
|
OIDC_CLIENT_ID=…
|
||||||
|
OIDC_CLIENT_SECRET=…
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf der Anmeldeseite erscheint dann ein Knopf für den Anbieter; das
|
||||||
|
Passwortfeld bleibt daneben stehen, bis `OIDC_ONLY=true` gesetzt wird.
|
||||||
|
|
||||||
|
**Übernommen wird, wer jemand ist** — Kennung, E-Mail-Adresse, Anzeigename —
|
||||||
|
und auf Wunsch über `OIDC_ADMIN_GROUP` das Recht zur Verwaltung. Gruppen und
|
||||||
|
Rollen bleiben Sache dieser Anwendung: Die Gruppen im Verzeichnis bedeuten
|
||||||
|
etwas anderes als die hier, und eine automatische Übersetzung wäre eine Quelle
|
||||||
|
stiller Überraschungen.
|
||||||
|
|
||||||
|
Bestehende Konten mit Passwort werden beim ersten Anmelden anhand der
|
||||||
|
**bestätigten** E-Mail-Adresse verknüpft, nicht verdoppelt.
|
||||||
|
|
||||||
|
→ **Vollständige Anleitung mit allen Feldern in Authentik, Fehlerbildern und
|
||||||
|
dem Weg zurück: [docs/authentik.md](docs/authentik.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Technischer Aufbau
|
## Technischer Aufbau
|
||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
@@ -819,25 +890,39 @@ curl -X POST http://localhost:3000/api/codes \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ausblick Version 2
|
## Einen weiteren Typ ergänzen
|
||||||
|
|
||||||
Geplant sind die übrigen 14 Typen: vCard, App-Link, Mehrere Links, Social
|
Alle 15 vorgesehenen Typen sind gebaut. Kommt ein sechzehnter dazu, besteht er
|
||||||
Media, WhatsApp, PDF, Bild, Video, E-Mail, SMS, Standort, Zahlung, WLAN und
|
aus:
|
||||||
Feedback.
|
|
||||||
|
|
||||||
Die Architektur ist darauf ausgelegt — ein neuer Typ besteht aus:
|
|
||||||
|
|
||||||
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
|
1. **einem Modul** in `src/lib/qr/types/` (Zod-Schema, Kodierung, Kurzfassung),
|
||||||
2. **einer Formular-Komponente** in `src/components/qr-forms/`,
|
2. **einer Formular-Komponente** in `src/components/qr-forms/`,
|
||||||
3. **je einem Eintrag** in `src/lib/qr/registry.ts` (ersetzt dort den
|
3. **je einem Eintrag** in `src/lib/qr/registry.ts` und in
|
||||||
`defineComingSoonType`-Platzhalter) und in
|
`src/components/qr-forms/index.ts`,
|
||||||
`src/components/qr-forms/index.ts`.
|
4. **einer Landingpage** in `src/components/landing/`, falls er `hasLandingPage`
|
||||||
|
meldet.
|
||||||
|
|
||||||
Am bestehenden Code aus Version 1 ist dafür nichts zu ändern: Vorschau,
|
Am übrigen Code ist dafür nichts zu ändern: Vorschau, Download, Gestaltung,
|
||||||
Download, Gestaltung, Speicherung dynamischer Codes und die Weiterleitung
|
Speicherung, Weiterleitung, Liste und Verwaltung arbeiten für alle Typen
|
||||||
arbeiten für alle Typen gleich.
|
gleich. Die Kennung gehört zusätzlich in `QR_TYPE_IDS` in
|
||||||
|
`src/lib/qr/type-module.ts`.
|
||||||
|
|
||||||
Typen, die statt einer Weiterleitung eine eigene Landingpage brauchen (etwa
|
Für einen angekündigten, aber noch nicht gebauten Typ gibt es
|
||||||
„Mehrere Links“ oder „Feedback“), setzen dafür `redirectTarget` auf eine
|
`defineComingSoonType`: Er erscheint als Kachel mit dem Badge „Bald
|
||||||
interne Adresse — auch dafür ist keine Änderung am Datenmodell nötig, weil
|
verfügbar“ und lässt sich nicht öffnen.
|
||||||
`data` als JSONB gespeichert wird.
|
|
||||||
|
Braucht der Typ eine hochgeladene Datei, meldet er ihre Ids über `uploadIds` —
|
||||||
|
daran hängt, was beim Löschen des Codes mitgeht und was als verwaist
|
||||||
|
weggeräumt wird.
|
||||||
|
|
||||||
|
### Was als Nächstes sinnvoll wäre
|
||||||
|
|
||||||
|
- **Gruppen aus dem Verzeichnis übernehmen.** Die OIDC-Anbindung übernimmt
|
||||||
|
heute bewusst nur die Identität. Eine Zuordnung von Verzeichnisgruppen zu
|
||||||
|
Gruppen dieser Anwendung wäre möglich, braucht aber eine klare Regel dafür,
|
||||||
|
was mit von Hand vergebenen Rollen passiert.
|
||||||
|
- **Statistik über die Zeit.** Der Zähler weiß heute nur, wie oft und wann
|
||||||
|
zuletzt. Ein Verlauf je Tag wäre eine eigene Tabelle — und die Frage, wie
|
||||||
|
lange man ihn aufbewahrt.
|
||||||
|
- **Codes exportieren.** Eine CSV aller Codes einer Gruppe, für die Übergabe
|
||||||
|
an Druckereien.
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ services:
|
|||||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||||
|
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
||||||
|
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
||||||
|
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
OIDC_BUTTON_LABEL: ${OIDC_BUTTON_LABEL:-Mit Authentik anmelden}
|
||||||
|
OIDC_SCOPES: ${OIDC_SCOPES:-openid email profile}
|
||||||
|
OIDC_ONLY: ${OIDC_ONLY:-false}
|
||||||
|
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-VIEWER}
|
||||||
|
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
|
||||||
|
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
|
||||||
ports:
|
ports:
|
||||||
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# Bedienungsanleitung
|
||||||
|
|
||||||
|
Diese Anleitung führt durch die tägliche Arbeit mit dem QR-Code-Generator —
|
||||||
|
vom ersten Code bis zur Auswertung von Rückmeldungen. Wie der Stack
|
||||||
|
aufgesetzt wird, steht im [README](../README.md); die Anmeldung über
|
||||||
|
Authentik in [authentik.md](authentik.md).
|
||||||
|
|
||||||
|
> Die Bilder zeigen die Oberfläche mit eingeschalteter Anmeldung. Ohne
|
||||||
|
> Anmeldung fehlen Kopfzeile rechts und der Bereich „Verwaltung“, alles
|
||||||
|
> Übrige ist gleich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
- [Der schnellste Weg zum Code](#der-schnellste-weg-zum-code)
|
||||||
|
- [Statisch oder dynamisch — die wichtigste Entscheidung](#statisch-oder-dynamisch--die-wichtigste-entscheidung)
|
||||||
|
- [Gestaltung](#gestaltung)
|
||||||
|
- [Meine Codes](#meine-codes)
|
||||||
|
- [Die Typen mit eigener Seite](#die-typen-mit-eigener-seite)
|
||||||
|
- [Feedback einsammeln und auswerten](#feedback-einsammeln-und-auswerten)
|
||||||
|
- [Verwaltung](#verwaltung)
|
||||||
|
- [Konto](#konto)
|
||||||
|
- [Häufige Fragen](#häufige-fragen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der schnellste Weg zum Code
|
||||||
|
|
||||||
|
Die Startseite fragt „Was möchtest du erstellen?“ und zeigt alle
|
||||||
|
freigegebenen Typen als Kacheln.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ein Klick auf **Website**, die Adresse eintippen — die Vorschau erscheint
|
||||||
|
sofort — und mit **PNG** oder **SVG** herunterladen. Fertig.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Links stehen Inhalt, Art des Codes und Gestaltung, rechts die Vorschau mit
|
||||||
|
den beiden Download-Knöpfen. Die Vorschau aktualisiert sich bei jeder
|
||||||
|
Änderung.
|
||||||
|
|
||||||
|
**PNG oder SVG?**
|
||||||
|
|
||||||
|
| | PNG | SVG |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Wofür | Bildschirm, Office, Web | Druck, große Formate, Schilder |
|
||||||
|
| Größe | fest (256–2048 px) | frei skalierbar ohne Qualitätsverlust |
|
||||||
|
| Faustregel | wenn du es einfügst | wenn es jemand druckt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statisch oder dynamisch — die wichtigste Entscheidung
|
||||||
|
|
||||||
|
| | Statisch | Dynamisch |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Im Code steht | die Zieladresse selbst | eine Kurz-URL dieser Anwendung |
|
||||||
|
| Ziel später änderbar | **nein** | **ja**, jederzeit |
|
||||||
|
| Scans werden gezählt | nein | ja |
|
||||||
|
| Braucht diesen Server | nein, nie | ja, dauerhaft |
|
||||||
|
| Code ist | dichter (mehr Daten) | schlanker, leichter scannbar |
|
||||||
|
|
||||||
|
**Nimm statisch,** wenn die Adresse feststeht und nie wieder geändert wird —
|
||||||
|
und wenn der Code auch dann noch funktionieren soll, wenn es diese Anwendung
|
||||||
|
nicht mehr gibt.
|
||||||
|
|
||||||
|
**Nimm dynamisch,** wenn etwas gedruckt wird. Ein Aufsteller, ein Flyer, ein
|
||||||
|
Schild am Werkstor: Solange der Code dynamisch ist, kannst du das Ziel
|
||||||
|
umbiegen, ohne neu zu drucken.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Beim Umschalten auf **Dynamisch** erscheint ein Feld für den Namen und der
|
||||||
|
Knopf **Dynamischen Code speichern**. Erst beim Speichern entsteht die
|
||||||
|
Kurz-URL — deshalb ist der Download bis dahin gesperrt.
|
||||||
|
|
||||||
|
> **Der Name ist nur für dich.** Er steht nirgends im Code und sieht ihn
|
||||||
|
> niemand, der scannt. Er hilft dir, den Code später in der Liste
|
||||||
|
> wiederzufinden — bei vielen Projekten ist er Gold wert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestaltung
|
||||||
|
|
||||||
|
Farben, Größe, Fehlerkorrektur und Rand gelten für Vorschau und Download
|
||||||
|
gleichermaßen.
|
||||||
|
|
||||||
|
**Farben.** Fünf Vorschläge stehen bereit, eigene Werte gehen über den
|
||||||
|
Farbwähler oder als Hex-Wert. Wird der Kontrast zu schwach, warnt die
|
||||||
|
Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem. Faustregel:
|
||||||
|
dunkler Vordergrund auf hellem Hintergrund. Umgekehrt kommen viele
|
||||||
|
Scanner-Apps nicht zurecht.
|
||||||
|
|
||||||
|
**Größe.** 256 bis 2048 Pixel, betrifft nur den PNG-Download. Für Druck lieber
|
||||||
|
SVG nehmen und die Größe im Layoutprogramm bestimmen.
|
||||||
|
|
||||||
|
**Fehlerkorrektur.** Wie viel vom Code beschädigt sein darf, ohne dass er
|
||||||
|
unlesbar wird:
|
||||||
|
|
||||||
|
| Stufe | Verträgt | Wofür |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| L | ~7 % | saubere Bildschirmanzeige |
|
||||||
|
| **M** | ~15 % | **Voreinstellung, für fast alles richtig** |
|
||||||
|
| Q | ~25 % | Aufkleber, die abgerieben werden |
|
||||||
|
| H | ~30 % | Außenbereich, Logo mitten im Code |
|
||||||
|
|
||||||
|
Höhere Stufen machen den Code dichter — er braucht mehr Fläche, um noch
|
||||||
|
gescannt zu werden.
|
||||||
|
|
||||||
|
**Rand.** Die weiße Zone rundherum. Vier Module sind Standard; weniger kann
|
||||||
|
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||||
|
trennen kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meine Codes
|
||||||
|
|
||||||
|
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
|
||||||
|
werden nicht gespeichert.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die **Liste** ist die Voreinstellung: eine Zeile je Code, gedacht für viele
|
||||||
|
Projekte nebeneinander.
|
||||||
|
|
||||||
|
- Das **Suchfeld** durchsucht Name, Ziel, Kurz-URL und Typ.
|
||||||
|
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||||
|
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||||
|
liegen.
|
||||||
|
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||||
|
|
||||||
|
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||||
|
Vorschau. Die Wahl merkt sich der Browser.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Ziel ändern
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Das ist der eigentliche Sinn dynamischer Codes: **Die Kurz-URL bleibt, der
|
||||||
|
gedruckte Code bleibt gültig, nur das Ziel wechselt.** Die Änderung wirkt
|
||||||
|
sofort — beim nächsten Scan.
|
||||||
|
|
||||||
|
### Löschen
|
||||||
|
|
||||||
|
Löschen fragt nach und nennt den betroffenen Code. Danach führt der gedruckte
|
||||||
|
Code ins Leere und zeigt eine freundliche Seite „Diese Seite gibt es nicht“.
|
||||||
|
Ein gelöschter Code lässt sich nicht wiederherstellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Typen mit eigener Seite
|
||||||
|
|
||||||
|
Fünf Typen leiten nicht auf eine fremde Adresse weiter, sondern auf eine
|
||||||
|
Seite, die diese Anwendung selbst ausliefert: **Mehrere Links**, **Social
|
||||||
|
Media**, **PDF**, **Bild** und **Feedback**.
|
||||||
|
|
||||||
|
Diese Seiten sind öffentlich und zeigen bewusst nichts von der Anwendung —
|
||||||
|
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer
|
||||||
|
einen Aushang scannt, soll nur den Inhalt sehen.
|
||||||
|
|
||||||
|
### Mehrere Links
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Eine Überschrift, ein kurzer Text und bis zu 20 beschriftete Links. Mit den
|
||||||
|
Pfeilen im Formular lässt sich die Reihenfolge ändern — das oberste wird am
|
||||||
|
häufigsten geklickt, also gehört das Wichtigste nach oben.
|
||||||
|
|
||||||
|
### Social Media
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Dasselbe Prinzip, aber mit einem Katalog von Netzwerken. Als Eingabe genügt
|
||||||
|
das Kürzel: Aus `@firma` wird beim Speichern `https://instagram.com/firma`.
|
||||||
|
Eine vollständige Adresse bleibt unverändert, damit auch abweichende
|
||||||
|
Profil-Adressen möglich sind. Jedes Netzwerk kann nur einmal vorkommen.
|
||||||
|
|
||||||
|
### PDF und Bild
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Beim PDF-Typ lädst du ein Dokument hoch; die Seite zeigt Titel, Dateiname,
|
||||||
|
Größe und zwei Knöpfe: **Dokument öffnen** und **Herunterladen**.
|
||||||
|
|
||||||
|
Beim Bild-Typ sind es bis zu zehn Bilder mit Bildunterschriften, untereinander
|
||||||
|
als Galerie.
|
||||||
|
|
||||||
|
Beides lässt sich später austauschen, **ohne den Code neu zu drucken** — genau
|
||||||
|
wie ein geändertes Ziel. Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF
|
||||||
|
bis zur eingestellten Grenze (Voreinstellung 10 MB, siehe `MAX_UPLOAD_MB`).
|
||||||
|
|
||||||
|
> SVG wird bewusst nicht angenommen: Eine SVG-Datei darf Skripte enthalten,
|
||||||
|
> und sie würde von derselben Adresse ausgeliefert wie die Anwendung selbst.
|
||||||
|
|
||||||
|
### Video
|
||||||
|
|
||||||
|
Der Video-Typ erkennt YouTube, Vimeo und direkte Videodateien. Auf der Seite
|
||||||
|
erscheint ein Abspieler — der Rahmen zum Anbieter lädt aber **erst nach einem
|
||||||
|
Klick**, und davor steht, dass dabei Daten an den Anbieter gehen.
|
||||||
|
|
||||||
|
Video ist der einzige dieser Typen, der auch **statisch** funktioniert: Dann
|
||||||
|
steht die Videoadresse direkt im Code und führt ohne Umweg dorthin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback einsammeln und auswerten
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Der Feedback-Typ erzeugt ein Formular mit fünf Sternen oder fünf Gesichtern
|
||||||
|
und wahlweise einem Textfeld. Alle Texte — Überschrift, Frage, Beschriftung
|
||||||
|
des Textfelds, Dank nach dem Absenden — lassen sich frei formulieren.
|
||||||
|
|
||||||
|
Gespeichert werden nur die Note und der Text. **Kein Name, keine IP-Adresse,
|
||||||
|
keine Gerätekennung.** Wer einen Aushang scannt und eine Note vergibt, bleibt
|
||||||
|
anonym — das steht auch für die Scannenden sichtbar unter dem Formular.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Auswertung erreichst du in „Meine Codes“ über das Stern-Symbol in der
|
||||||
|
Zeile. Sie zeigt den Durchschnitt, die Verteilung der Noten und alle
|
||||||
|
Kommentare mit Datum.
|
||||||
|
|
||||||
|
**Ein praktischer Hinweis:** Ein Code pro Ort. Zwei Codes — einer im
|
||||||
|
Gastraum, einer am Ausgang — sagen mehr als einer für beides, weil du die
|
||||||
|
Auswertungen getrennt lesen kannst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwaltung
|
||||||
|
|
||||||
|
Der Bereich **Verwaltung** erscheint nur für Administratoren der Installation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### QR-Typen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
15 Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Hier lassen
|
||||||
|
sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
||||||
|
|
||||||
|
**Bereits erstellte Codes eines abgeschalteten Typs funktionieren
|
||||||
|
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||||
|
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||||
|
|
||||||
|
### Gruppen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Eine Gruppe bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
|
||||||
|
gehört genau einer Gruppe; wer nicht in der Gruppe ist, sieht ihn nicht.
|
||||||
|
|
||||||
|
Die drei Rollen je Gruppe:
|
||||||
|
|
||||||
|
| Rolle | Darf |
|
||||||
|
| --- | --- |
|
||||||
|
| **Betrachter** | Codes sehen und herunterladen |
|
||||||
|
| **Bearbeiter** | zusätzlich anlegen, ändern und löschen |
|
||||||
|
| **Administrator** | zusätzlich Mitglieder und deren Rollen verwalten |
|
||||||
|
|
||||||
|
Darüber liegt der **Administrator der Installation**: Nur er legt Gruppen,
|
||||||
|
Benutzer und Domains überhaupt an.
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
|
||||||
|
werden die Domains hier eingetragen und Gruppen zugeteilt. Eine Domain kann
|
||||||
|
mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
||||||
|
getrennt.
|
||||||
|
|
||||||
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
|
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konto
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Hier stehen die eigene Adresse, die Gruppen mit der jeweiligen Rolle, die
|
||||||
|
Passwortänderung und der Weg hinaus.
|
||||||
|
|
||||||
|
Meldet sich das Konto über Authentik an, steht statt der Passwortänderung ein
|
||||||
|
Hinweis darauf — das Passwort wird dann beim Anbieter geändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Häufige Fragen
|
||||||
|
|
||||||
|
**Der Code funktioniert auf dem Handy nicht, am Rechner aber schon.**
|
||||||
|
Dann zeigt die Kurz-URL auf eine Adresse, die das Handy nicht erreicht.
|
||||||
|
`BASE_URL` in der `.env` muss die Adresse sein, unter der die Anwendung **von
|
||||||
|
außen** erreichbar ist — nicht `localhost`.
|
||||||
|
|
||||||
|
**Ich habe den Code schon gedruckt und brauche ein anderes Ziel.**
|
||||||
|
Ist er dynamisch: „Meine Codes“ → Ziel bearbeiten. Ist er statisch: Der Code
|
||||||
|
muss neu erstellt und neu gedruckt werden. Deshalb bei allem Gedruckten
|
||||||
|
dynamisch nehmen.
|
||||||
|
|
||||||
|
**Der Scan-Zähler steht auf 0, obwohl jemand gescannt hat.**
|
||||||
|
Gezählt wird nur der Aufruf der Kurz-URL. Statische Codes zählen gar nicht,
|
||||||
|
weil sie diesen Server nie berühren. Bei Typen mit eigener Seite zählt der
|
||||||
|
Scan, nicht das Neuladen der Seite.
|
||||||
|
|
||||||
|
**Ein Kollege sieht meine Codes nicht.**
|
||||||
|
Er ist nicht in derselben Gruppe. Verwaltung → Gruppen → Mitglied hinzufügen.
|
||||||
|
|
||||||
|
**Der QR-Code wird nicht erkannt.**
|
||||||
|
Meist zu wenig Kontrast, ein zu kleiner Rand oder zu kleiner Druck.
|
||||||
|
Faustregel für Druck: mindestens 2 × 2 cm, bei Entfernung mehr — als grober
|
||||||
|
Anhalt ein Zehntel des Leseabstands.
|
||||||
|
|
||||||
|
**Kann ich ein Logo in die Mitte setzen?**
|
||||||
|
Nicht in dieser Anwendung. Wer es im Layoutprogramm macht: SVG nehmen,
|
||||||
|
Fehlerkorrektur **H** wählen und höchstens ein Fünftel der Fläche verdecken —
|
||||||
|
danach unbedingt mit mehreren Geräten testen.
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Anmeldung über Authentik (OIDC)
|
||||||
|
|
||||||
|
Diese Anleitung richtet die Anmeldung am QR-Code-Generator über **Authentik**
|
||||||
|
ein. Sie funktioniert genauso mit anderen OpenID-Connect-Anbietern — Keycloak,
|
||||||
|
Zitadel, Entra ID —, weil die Anwendung alles Nötige aus dem
|
||||||
|
Discovery-Dokument des Anbieters liest. Die Beispiele nennen Authentik, weil
|
||||||
|
die Feldnamen dort so heißen.
|
||||||
|
|
||||||
|
**Zeitbedarf:** etwa 15 Minuten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorher klären
|
||||||
|
|
||||||
|
| Frage | Antwort für diese Anleitung |
|
||||||
|
| --- | --- |
|
||||||
|
| Adresse der Anwendung | `https://qr.example.com` |
|
||||||
|
| Adresse von Authentik | `https://auth.example.com` |
|
||||||
|
| Läuft die Anwendung über HTTPS? | Ja — siehe Hinweis unten |
|
||||||
|
|
||||||
|
> **HTTPS ist Pflicht, sobald es über den eigenen Rechner hinausgeht.** Beim
|
||||||
|
> Anmelden gehen Token über die Leitung, und der Sitzungs-Cookie wird nur bei
|
||||||
|
> `https://` in der `BASE_URL` als `Secure` gesetzt. Für einen Versuch im LAN
|
||||||
|
> geht auch HTTP; produktiv gehört ein Reverse Proxy davor (siehe README,
|
||||||
|
> Abschnitt „Hinter einem Reverse Proxy“).
|
||||||
|
|
||||||
|
Die Anwendung muss Authentik erreichen können und umgekehrt der Browser beide.
|
||||||
|
Laufen beide in Docker auf demselben Host, genügt es meist, sie ins selbe
|
||||||
|
Netz zu hängen — der Browser braucht aber in jedem Fall öffentlich auflösbare
|
||||||
|
Adressen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 — In Authentik einen Provider anlegen
|
||||||
|
|
||||||
|
**Applications → Providers → Create → OAuth2/OpenID Provider**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
| --- | --- |
|
||||||
|
| Name | `QR-Code-Generator` |
|
||||||
|
| Authorization flow | `default-provider-authorization-explicit-consent` (oder implicit, wenn niemand zustimmen soll) |
|
||||||
|
| Client type | **Confidential** |
|
||||||
|
| Client ID | notieren — kommt gleich in die `.env` |
|
||||||
|
| Client Secret | notieren — ebenfalls |
|
||||||
|
| Redirect URIs | `https://qr.example.com/api/auth/oidc/callback` |
|
||||||
|
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
||||||
|
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
|
||||||
|
|
||||||
|
Zu den **Scopes**: `openid`, `email` und `profile` müssen ausgewählt sein.
|
||||||
|
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
|
||||||
|
Anmeldung mit einer entsprechenden Meldung ab.
|
||||||
|
|
||||||
|
> **Redirect URI genau so eintragen.** Ein fehlendes `https://`, ein Schrägstrich
|
||||||
|
> zu viel oder ein anderer Hostname führt zu `redirect_uri mismatch` —
|
||||||
|
> Authentik bricht dann ab, bevor die Anwendung überhaupt etwas mitbekommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2 — Eine Application anlegen
|
||||||
|
|
||||||
|
**Applications → Applications → Create**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
| --- | --- |
|
||||||
|
| Name | `QR-Code-Generator` |
|
||||||
|
| Slug | `qrcode-generator` |
|
||||||
|
| Provider | der eben angelegte Provider |
|
||||||
|
| Launch URL | `https://qr.example.com` |
|
||||||
|
|
||||||
|
Der **Slug** steckt gleich in der Issuer-Adresse — merken.
|
||||||
|
|
||||||
|
Wer den Zugang einschränken will, hängt hier unter **Policy / Group / User
|
||||||
|
Bindings** eine Gruppe ein. Das ist der empfohlene Weg: Wer sich bei Authentik
|
||||||
|
anmelden darf, kommt sonst auch in den QR-Code-Generator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3 — Die Issuer-Adresse herausfinden
|
||||||
|
|
||||||
|
Authentik zeigt sie beim Provider unter **Provider Info** als *OpenID
|
||||||
|
Configuration Issuer*. Sie sieht so aus:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://auth.example.com/application/o/qrcode-generator/
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen lässt sich das direkt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://auth.example.com/application/o/qrcode-generator/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Kommt ein JSON-Dokument mit `authorization_endpoint`, `token_endpoint` und
|
||||||
|
`jwks_uri` zurück, stimmt die Adresse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4 — Die `.env` ergänzen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
|
||||||
|
OIDC_CLIENT_ID=<Client ID aus Schritt 1>
|
||||||
|
OIDC_CLIENT_SECRET=<Client Secret aus Schritt 1>
|
||||||
|
OIDC_BUTTON_LABEL=Mit Authentik anmelden
|
||||||
|
OIDC_SCOPES=openid email profile
|
||||||
|
OIDC_DEFAULT_ROLE=EDITOR
|
||||||
|
OIDC_ADMIN_GROUP=qr-admins
|
||||||
|
OIDC_GROUPS_CLAIM=groups
|
||||||
|
OIDC_ONLY=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach neu starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> `OIDC_ENABLED` allein genügt nicht — **`AUTH_ENABLED` muss ebenfalls `true`
|
||||||
|
> sein**. Ohne Anmeldung gibt es keine Benutzer, denen eine Anmeldung
|
||||||
|
> zugeordnet werden könnte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 5 — Ausprobieren
|
||||||
|
|
||||||
|
`https://qr.example.com/anmelden` aufrufen. Über dem Passwortfeld steht jetzt
|
||||||
|
**„Mit Authentik anmelden“**. Ein Klick führt zu Authentik, nach der Anmeldung
|
||||||
|
geht es zurück in die Anwendung.
|
||||||
|
|
||||||
|
Alle Anmeldenden bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
|
||||||
|
Standardgruppe. Wer die **Verwaltung** öffnen darf, hängt davon ab, ob
|
||||||
|
`OIDC_ADMIN_GROUP` gesetzt ist:
|
||||||
|
|
||||||
|
- **Leer:** Der erste Mensch, der sich anmeldet, wird Administrator dieser
|
||||||
|
Installation — genau wie bei der Ersteinrichtung mit Passwort. Danach vergibt
|
||||||
|
man das Recht unter Verwaltung → Benutzer.
|
||||||
|
- **Gesetzt:** Dann entscheidet allein die Gruppe, und zwar bei **jeder**
|
||||||
|
Anmeldung neu — auch beim allerersten Benutzer.
|
||||||
|
|
||||||
|
> **Achtung beim ersten Mal:** Wenn du `OIDC_ADMIN_GROUP` setzt, vergewissere
|
||||||
|
> dich, dass dein eigenes Konto in dieser Gruppe ist. Sonst hat niemand Zugang
|
||||||
|
> zur Verwaltung. Der Weg zurück ist einfach: die Variable leeren,
|
||||||
|
> `docker compose up -d`, neu anmelden — dann greift wieder die Regel „der
|
||||||
|
> erste wird Administrator“, sofern es noch gar keinen gibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was die Anwendung aus Authentik übernimmt — und was nicht
|
||||||
|
|
||||||
|
| Übernommen | Bleibt in der Anwendung |
|
||||||
|
| --- | --- |
|
||||||
|
| Wer jemand ist (`sub`) | Mitgliedschaft in Gruppen |
|
||||||
|
| E-Mail-Adresse | Rolle je Gruppe (Admin/Bearbeiter/Betrachter) |
|
||||||
|
| Anzeigename | Zuteilung von Domains |
|
||||||
|
| Instanz-Administration, falls `OIDC_ADMIN_GROUP` gesetzt ist | alles Übrige |
|
||||||
|
|
||||||
|
Das ist eine bewusste Entscheidung. Die Gruppen in Authentik und die Gruppen
|
||||||
|
hier bedeuten nicht dasselbe: dort geht es um Organisation und Zugang, hier um
|
||||||
|
die Frage, wer welche QR-Codes sehen und ändern darf. Eine automatische
|
||||||
|
Übersetzung wäre eine Quelle stiller Überraschungen — deshalb pflegt man
|
||||||
|
Gruppen und Rollen weiterhin unter **Verwaltung → Gruppen**.
|
||||||
|
|
||||||
|
Wiedererkannt wird jemand an der **Kennung von Authentik** (`sub`), nicht an
|
||||||
|
der E-Mail-Adresse. Ändert sich die Adresse, bleibt es dasselbe Konto.
|
||||||
|
|
||||||
|
### Die Admin-Gruppe
|
||||||
|
|
||||||
|
Steht in `OIDC_ADMIN_GROUP` ein Gruppenname, entscheidet Authentik bei **jeder
|
||||||
|
Anmeldung** neu, wer die Verwaltung öffnen darf: Mitglieder der Gruppe
|
||||||
|
bekommen das Recht, alle anderen verlieren es. Bleibt die Variable leer,
|
||||||
|
rührt die Anmeldung das Recht nicht an — dann pflegt man es hier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bestehende Konten übernehmen
|
||||||
|
|
||||||
|
Gibt es hier schon Konten mit Passwort, werden sie beim ersten Anmelden über
|
||||||
|
Authentik **anhand der E-Mail-Adresse verknüpft**. Aus dem lokalen
|
||||||
|
Administrator wird also kein zweites Konto; Gruppen, Rollen und die
|
||||||
|
angelegten Codes bleiben.
|
||||||
|
|
||||||
|
Verknüpft wird nur, wenn Authentik die Adresse als **bestätigt** meldet. Sonst
|
||||||
|
könnte sich beim Anbieter jemand eine fremde Adresse eintragen und damit ein
|
||||||
|
hiesiges Konto übernehmen. Meldet Authentik sie als unbestätigt, bricht die
|
||||||
|
Anmeldung mit einer Erklärung ab.
|
||||||
|
|
||||||
|
Ist ein Konto einmal verknüpft, gibt es dafür kein Passwort mehr: Der Bereich
|
||||||
|
**Konto** zeigt dann statt der Passwortänderung einen Hinweis auf den
|
||||||
|
Anbieter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nur noch über Authentik anmelden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_ONLY=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach verschwindet das Passwortfeld von der Anmeldeseite.
|
||||||
|
|
||||||
|
> **Vorher an den Notausgang denken.** Fällt Authentik aus oder ist der
|
||||||
|
> Provider falsch konfiguriert, kommt niemand mehr hinein. Der Weg zurück:
|
||||||
|
> `OIDC_ONLY=false` in der `.env`, `docker compose up -d` — dann geht die
|
||||||
|
> Anmeldung mit Passwort wieder. Lege dir dafür ein lokales Administratorkonto
|
||||||
|
> an, **bevor** du umstellst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht klappt
|
||||||
|
|
||||||
|
| Symptom | Ursache und Abhilfe |
|
||||||
|
| --- | --- |
|
||||||
|
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
|
||||||
|
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
|
||||||
|
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
|
||||||
|
| Authentik meldet `redirect_uri mismatch` | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein. Auf Schema, Hostname und Schrägstriche 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. |
|
||||||
|
| „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. |
|
||||||
|
|
||||||
|
Ausführlichere Meldungen stehen im Protokoll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was technisch passiert
|
||||||
|
|
||||||
|
1. Klick auf den Knopf → `/api/auth/oidc/start`. Die Anwendung holt das
|
||||||
|
Discovery-Dokument, würfelt `state`, `nonce` und ein PKCE-Geheimnis und legt
|
||||||
|
sie in einen kurzlebigen, `httpOnly`-Cookie.
|
||||||
|
2. Weiterleitung zu Authentik mit `code_challenge` (S256).
|
||||||
|
3. Nach der Anmeldung kommt Authentik mit `code` und `state` zurück auf
|
||||||
|
`/api/auth/oidc/callback`. Stimmt der `state` nicht, endet es hier.
|
||||||
|
4. Die Anwendung tauscht den Code gegen Token — mit Client-Geheimnis **und**
|
||||||
|
PKCE-Verifier. Ein abgefangener Code allein nützt also niemandem.
|
||||||
|
5. Das ID-Token wird gegen den JWKS von Authentik geprüft: Signatur,
|
||||||
|
Aussteller, Empfänger, Ablauf und `nonce`.
|
||||||
|
6. Konto suchen (über `sub`, ersatzweise über die bestätigte Adresse), anlegen
|
||||||
|
oder verknüpfen — und die Sitzung wie bei der Passwort-Anmeldung setzen.
|
||||||
|
|
||||||
|
Die Sitzung selbst ist danach dieselbe wie sonst: ein Zufallstoken im Cookie,
|
||||||
|
in der Datenbank nur dessen SHA-256. Abmelden beendet sie hier; bei Authentik
|
||||||
|
bleibt man angemeldet.
|
||||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,423 @@
|
|||||||
|
# Alle Funktionen im Detail
|
||||||
|
|
||||||
|
Die lange Fassung der Übersicht aus dem [README](../README.md). Sie
|
||||||
|
beschreibt, was die Anwendung kann, wo die Grenzen liegen und warum manches
|
||||||
|
so entschieden ist. Wie man es bedient, steht in der
|
||||||
|
[Bedienungsanleitung](anleitung.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
- [QR-Typen](#qr-typen)
|
||||||
|
- [Statisch und dynamisch](#statisch-und-dynamisch)
|
||||||
|
- [Landingpages](#landingpages)
|
||||||
|
- [Dateien](#dateien)
|
||||||
|
- [Feedback](#feedback)
|
||||||
|
- [Gestaltung und Ausgabe](#gestaltung-und-ausgabe)
|
||||||
|
- [Meine Codes](#meine-codes)
|
||||||
|
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||||
|
- [Single Sign-on](#single-sign-on)
|
||||||
|
- [Mehrere Domains](#mehrere-domains)
|
||||||
|
- [Verwaltung](#verwaltung)
|
||||||
|
- [Betrieb](#betrieb)
|
||||||
|
- [Schnittstelle](#schnittstelle)
|
||||||
|
- [Was die Anwendung bewusst nicht tut](#was-die-anwendung-bewusst-nicht-tut)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## QR-Typen
|
||||||
|
|
||||||
|
Alle 15 vorgesehenen Typen sind gebaut.
|
||||||
|
|
||||||
|
| Typ | Was hineinkommt | Statisch | Dynamisch | Eigene Seite |
|
||||||
|
| --- | --- | :-: | :-: | :-: |
|
||||||
|
| **Website** | eine Adresse | ✓ | ✓ | — |
|
||||||
|
| **vCard** | Name, Firma, Telefon, E-Mail, Anschrift, Web | ✓ | — | — |
|
||||||
|
| **App-Link** | je eine Adresse für iOS, Android und Ausweichfall | — | ✓ | — |
|
||||||
|
| **Mehrere Links** | Überschrift, Text, bis zu 20 beschriftete Links | — | ✓ | ✓ |
|
||||||
|
| **Social Media** | Überschrift, Text, bis zu 12 Profile aus 15 Netzwerken | — | ✓ | ✓ |
|
||||||
|
| **WhatsApp** | Rufnummer und vorbereitete Nachricht | ✓ | ✓ | — |
|
||||||
|
| **PDF** | eine hochgeladene Datei, Titel, Text | — | ✓ | ✓ |
|
||||||
|
| **Bild** | bis zu 10 hochgeladene Bilder mit Bildunterschriften | — | ✓ | ✓ |
|
||||||
|
| **Video** | YouTube, Vimeo oder eine direkte Videodatei | ✓ | ✓ | ✓ |
|
||||||
|
| **E-Mail** | Empfänger, Betreff, Text | ✓ | ✓ | — |
|
||||||
|
| **SMS** | Rufnummer und Text | ✓ | ✓ | — |
|
||||||
|
| **Standort** | Koordinaten oder Adresse | ✓ | ✓ | — |
|
||||||
|
| **Zahlung** | GiroCode (EPC069-12) **oder** Zahlungslink | ✓ | nur Link | — |
|
||||||
|
| **WLAN** | Netzwerkname, Passwort, Verschlüsselung, versteckt | ✓ | — | — |
|
||||||
|
| **Feedback** | Frage, Skala, Textfeld, Dankestext | — | ✓ | ✓ |
|
||||||
|
|
||||||
|
### Warum manche Typen nur eine Betriebsart können
|
||||||
|
|
||||||
|
- **vCard und WLAN** wertet das Gerät direkt aus — die Kamera legt einen
|
||||||
|
Kontakt an oder richtet das Netz ein. Über eine Kurz-URL ginge stattdessen
|
||||||
|
nur ein Browser auf.
|
||||||
|
- **App-Link** kann nicht statisch sein: Welches Ziel richtig ist, steht erst
|
||||||
|
beim Scannen fest. Die Weiterleitung wertet den User-Agent aus — iOS in den
|
||||||
|
App Store, Android zu Google Play, alles Übrige auf die Ausweichadresse.
|
||||||
|
(iPadOS meldet sich seit Version 13 als Macintosh und landet deshalb
|
||||||
|
mitunter auf dem Desktop-Weg; für einen Store-Link ist das hinnehmbar.)
|
||||||
|
- **GiroCode** ist kein Web-Ziel, sondern ein Datensatz für Banking-Apps. Als
|
||||||
|
dynamischer Code funktioniert nur die Variante „Zahlungslink“. Die
|
||||||
|
Anwendung weist das beim Speichern **und beim Bearbeiten** ab, statt einen
|
||||||
|
Code entstehen zu lassen, der erst beim Scannen auffällt — also dann, wenn
|
||||||
|
er schon gedruckt ist.
|
||||||
|
- **Die fünf Typen mit eigener Seite** brauchen eine Seite, die die Anwendung
|
||||||
|
ausliefert. Eine Linkliste oder ein Formular passt nicht in einen einzelnen
|
||||||
|
Code. Video ist die Ausnahme: statisch steht dort die Videoadresse selbst.
|
||||||
|
|
||||||
|
### Besonderheiten einzelner Typen
|
||||||
|
|
||||||
|
**Social Media.** Als Eingabe genügt das Kürzel — aus `@firma` wird beim
|
||||||
|
Speichern `https://instagram.com/firma`. Eine vollständige Adresse bleibt
|
||||||
|
unverändert, damit auch abweichende Profil-Adressen möglich sind. Jedes
|
||||||
|
Netzwerk kann nur einmal vorkommen. Der Katalog umfasst Instagram, Facebook,
|
||||||
|
LinkedIn, Xing, YouTube, TikTok, X, Threads, Mastodon, WhatsApp, Telegram,
|
||||||
|
Pinterest, Snapchat, GitHub und „Website“.
|
||||||
|
|
||||||
|
**Video.** Erkannt werden `youtube.com/watch`, `youtu.be`, `/shorts/`,
|
||||||
|
`/embed/`, `/live/`, Vimeo-Adressen sowie die Endungen `.mp4`, `.webm`,
|
||||||
|
`.ogv`, `.ogg`, `.mov` und `.m4v`. Alles Übrige erscheint als schlichter Link
|
||||||
|
— ehrlicher als ein eingebetteter Rahmen, den der Anbieter nicht erlaubt.
|
||||||
|
|
||||||
|
**WLAN.** Das Passwort steht im Klartext im Code; wer ihn scannt oder
|
||||||
|
abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht Netzwerkname
|
||||||
|
oder Passwort nur aus Hex-Ziffern, setzt die Anwendung den Wert wie in der
|
||||||
|
Spezifikation vorgesehen in Anführungszeichen — sonst deuten manche Geräte ihn
|
||||||
|
als Hexzahl.
|
||||||
|
|
||||||
|
**Standort.** Statisch entsteht ein `geo:`-Link, den Karten-Apps direkt
|
||||||
|
öffnen. Dynamisch führt der Weg über OpenStreetMap, weil eine Kurz-URL im
|
||||||
|
Browser landet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statisch und dynamisch
|
||||||
|
|
||||||
|
| | Statisch | Dynamisch |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Im Code steht | die Zieladresse selbst | `{BASE_URL}/r/{slug}` |
|
||||||
|
| Ziel später änderbar | nein | ja, jederzeit |
|
||||||
|
| Scans gezählt | nein | ja, mit Datum des letzten |
|
||||||
|
| Braucht diesen Server | nie | dauerhaft |
|
||||||
|
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
|
||||||
|
|
||||||
|
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare
|
||||||
|
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
|
||||||
|
global — derselbe Slug darf auf zwei Domains liegen und dort auf
|
||||||
|
Verschiedenes zeigen.
|
||||||
|
|
||||||
|
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
|
||||||
|
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
|
||||||
|
und genau die Änderbarkeit zunichtemachen, wegen der man dynamische Codes
|
||||||
|
nimmt. Der Scan-Zähler wird beiläufig erhöht; scheitert er, geht die
|
||||||
|
Weiterleitung trotzdem durch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Landingpages
|
||||||
|
|
||||||
|
Fünf Typen führen auf eine Seite unter `{BASE_URL}/s/{slug}`, die die
|
||||||
|
Anwendung selbst ausliefert.
|
||||||
|
|
||||||
|
Diese Seiten sind **öffentlich** und zeigen bewusst nichts von der Anwendung:
|
||||||
|
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator, und im
|
||||||
|
Seitentitel steht der Inhalt statt des Produktnamens. Wer einen Aushang
|
||||||
|
scannt, hat mit dem Generator nichts zu tun.
|
||||||
|
|
||||||
|
Technisch liegt das App-Chrome in einer Routengruppe, die Landingpages
|
||||||
|
außerhalb — sie können es also gar nicht versehentlich erben.
|
||||||
|
|
||||||
|
Weitere Eigenschaften:
|
||||||
|
|
||||||
|
- Alle Texte kommen aus den Formulardaten und werden als **Text** ausgegeben,
|
||||||
|
nie als HTML. Eingegebene Zeichen wie `<b>` erscheinen als solche.
|
||||||
|
- Verlinkte Ziele bekommen `rel="noopener noreferrer"` — kein Zugriff auf das
|
||||||
|
Fenster, aus dem sie geöffnet wurden.
|
||||||
|
- Adressen müssen mit `http://` oder `https://` beginnen; `javascript:` und
|
||||||
|
Ähnliches wird abgewiesen.
|
||||||
|
- Passen die gespeicherten Daten nicht mehr zum Schema, erscheint eine
|
||||||
|
freundliche Erklärung statt eines Fehlers.
|
||||||
|
- Der Scan-Zähler zählt den Aufruf des **Codes**, nicht das Neuladen der
|
||||||
|
Seite.
|
||||||
|
|
||||||
|
**Video-Landingpage.** Der Rahmen zu YouTube oder Vimeo lädt erst nach einem
|
||||||
|
Klick, und davor steht, dass dabei Daten an den Anbieter übertragen werden.
|
||||||
|
Eingebettet wird die Variante ohne Cookies (`youtube-nocookie.com`). Direkte
|
||||||
|
Videodateien laden mit `preload="none"` — wer unterwegs scannt, zahlt das
|
||||||
|
Volumen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
PDFs und Bilder liegen **in der Datenbank**, nicht auf der Platte. Damit gibt
|
||||||
|
es weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
||||||
|
alles. Ein zweites Volumen wird beim Sichern zu leicht vergessen.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Erlaubt | PDF; PNG, JPEG, WebP, GIF, AVIF |
|
||||||
|
| Nicht erlaubt | SVG und alles Übrige |
|
||||||
|
| Größe | `MAX_UPLOAD_MB`, Voreinstellung 10 MB |
|
||||||
|
| Geprüft wird | der Dateiinhalt, nicht die Endung |
|
||||||
|
|
||||||
|
**Warum kein SVG:** Eine SVG-Datei darf Skripte enthalten. Ausgeliefert von
|
||||||
|
derselben Adresse wie die Anwendung könnte sie damit auf angemeldete
|
||||||
|
Sitzungen zugreifen.
|
||||||
|
|
||||||
|
**Wie geprüft wird:** Der vom Browser gemeldete Typ kommt aus der Endung und
|
||||||
|
lässt sich beliebig setzen. Entschieden wird deshalb anhand der ersten Bytes
|
||||||
|
der Datei. Eine Textdatei namens `harmlos.pdf` wird abgewiesen.
|
||||||
|
|
||||||
|
**Wie ausgeliefert wird:** mit dem erkannten Typ, `X-Content-Type-Options:
|
||||||
|
nosniff` und einer `Content-Security-Policy: sandbox` — damit auch ein PDF mit
|
||||||
|
eingebetteten Skripten nichts anrichten kann. Ein Anhängen von `?download`
|
||||||
|
erzwingt das Speichern statt der Anzeige.
|
||||||
|
|
||||||
|
**Wem eine Datei gehört:** Sie hängt am Code. Wird er gelöscht, verschwindet
|
||||||
|
sie mit ihm. Eine ausgetauschte Datei verliert ihren Bezug und wird nach 24
|
||||||
|
Stunden weggeräumt — ebenso eine hochgeladene, deren Code nie gespeichert
|
||||||
|
wurde. Eine fremde Datei lässt sich nicht an den eigenen Code hängen; sonst
|
||||||
|
könnte man sie einem anderen Code entziehen.
|
||||||
|
|
||||||
|
Die Adresse einer Datei ist nicht erratbar, aber öffentlich abrufbar —
|
||||||
|
dieselbe Überlegung wie beim Slug einer Kurz-URL. Vertrauliche Unterlagen
|
||||||
|
gehören nicht hinter einen QR-Code, den man abfotografieren kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback
|
||||||
|
|
||||||
|
Ein Feedback-Code erzeugt ein Formular mit fünf **Sternen** oder fünf
|
||||||
|
**Gesichtern** und wahlweise einem Textfeld. Überschrift, Frage, Beschriftung
|
||||||
|
des Textfelds und der Text nach dem Absenden sind frei formulierbar.
|
||||||
|
|
||||||
|
Gespeichert werden **nur die Note von 1 bis 5 und der Text**. Kein Name, keine
|
||||||
|
IP-Adresse, keine Gerätekennung — das steht auch für die Antwortenden sichtbar
|
||||||
|
unter dem Formular.
|
||||||
|
|
||||||
|
Gegen Fluten gibt es eine Bremse von 10 Rückmeldungen pro Minute und Absender.
|
||||||
|
Die Adresse dient dabei nur als Schlüssel im Arbeitsspeicher und wird nirgends
|
||||||
|
gespeichert. Rückmeldungen zu Codes, die gar kein Feedback einsammeln, werden
|
||||||
|
abgewiesen.
|
||||||
|
|
||||||
|
Die **Auswertung** ist aus der Codeliste erreichbar und zeigt Durchschnitt,
|
||||||
|
Verteilung der Noten und alle Kommentare mit Datum (die 200 jüngsten). Sie
|
||||||
|
sieht nur, wer den Code auch sehen darf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestaltung und Ausgabe
|
||||||
|
|
||||||
|
| Einstellung | Bereich | Voreinstellung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vordergrundfarbe | frei (Hex oder Farbwähler) | `#0F172A` |
|
||||||
|
| Hintergrundfarbe | frei | `#FFFFFF` |
|
||||||
|
| Größe | 256–2048 px | 512 px |
|
||||||
|
| Fehlerkorrektur | L, M, Q, H | M |
|
||||||
|
| Rand | 0–16 Module | 4 |
|
||||||
|
|
||||||
|
Fünf Farbvorschläge (Klassisch, Indigo, Waldgrün, Bordeaux, Marine) und ein
|
||||||
|
Knopf zum Tauschen von Vorder- und Hintergrund. Reicht der Kontrast nicht,
|
||||||
|
warnt die Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem.
|
||||||
|
|
||||||
|
Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
|
||||||
|
Änderung. Herunterladen geht als **PNG** (in der eingestellten Größe) und als
|
||||||
|
**SVG** (frei skalierbar). Die Größe betrifft nur das PNG.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meine Codes
|
||||||
|
|
||||||
|
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
|
||||||
|
gespeichert werden.
|
||||||
|
|
||||||
|
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
||||||
|
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
||||||
|
- **Suche** über Name, Ziel, Kurz-URL und Typ.
|
||||||
|
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
||||||
|
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
||||||
|
es auch eine Gruppenspalte.
|
||||||
|
- **Kurz-URL kopieren** mit einem Klick.
|
||||||
|
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
||||||
|
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
||||||
|
- **PNG und SVG** erneut herunterladen.
|
||||||
|
- Bei Feedback-Codes ein Weg zur **Auswertung**.
|
||||||
|
|
||||||
|
Auf schmalen Bildschirmen weichen die hinteren Spalten; die Kurz-URL rückt
|
||||||
|
unter den Namen, Bearbeiten und Löschen bleiben erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anmeldung, Gruppen und Rollen
|
||||||
|
|
||||||
|
Die Anmeldung ist **abschaltbar** (`AUTH_ENABLED`). Bewusst über die
|
||||||
|
Konfiguration und nicht über die Oberfläche: So kann man sich nicht
|
||||||
|
aussperren.
|
||||||
|
|
||||||
|
**Ohne Anmeldung** darf jeder, der die Anwendung erreicht, alles. Für den
|
||||||
|
Einzelplatz- oder LAN-Betrieb ist das gewollt; ins offene Internet gehört die
|
||||||
|
Anwendung dann nicht.
|
||||||
|
|
||||||
|
**Mit Anmeldung** gibt es vier Ebenen:
|
||||||
|
|
||||||
|
1. **Benutzer** — meldet sich mit E-Mail und Passwort an.
|
||||||
|
2. **Gruppe** — bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
|
||||||
|
gehört genau einer Gruppe.
|
||||||
|
3. **Rolle je Gruppe:**
|
||||||
|
| Rolle | Darf |
|
||||||
|
| --- | --- |
|
||||||
|
| Betrachter | sehen und herunterladen |
|
||||||
|
| Bearbeiter | zusätzlich anlegen, ändern, löschen |
|
||||||
|
| Administrator | zusätzlich Mitglieder und Rollen der Gruppe verwalten |
|
||||||
|
4. **Administrator der Installation** — legt Gruppen, Benutzer und Domains
|
||||||
|
überhaupt erst an.
|
||||||
|
|
||||||
|
Zur Umsetzung:
|
||||||
|
|
||||||
|
- Passwörter werden mit **scrypt** gehasht (Node-Bordmittel, kein natives
|
||||||
|
Modul). Mindestlänge 10 Zeichen.
|
||||||
|
- Nach **8 Fehlversuchen** ist das Konto 15 Minuten gesperrt.
|
||||||
|
- Sitzungen liegen in der **Datenbank**, nicht in einem signierten Cookie —
|
||||||
|
so lassen sie sich serverseitig beenden, etwa beim Deaktivieren eines
|
||||||
|
Benutzers. Im Cookie steht ein Zufallstoken, in der Datenbank nur dessen
|
||||||
|
SHA-256. Laufzeit 30 Tage.
|
||||||
|
- Der Cookie ist `httpOnly` und `SameSite=Lax`; `Secure` wird gesetzt, sobald
|
||||||
|
`BASE_URL` mit `https://` beginnt.
|
||||||
|
- Alle Berechtigungsregeln stehen an **einer** Stelle
|
||||||
|
(`src/lib/auth/policy.ts`) — nicht verteilt über die Oberfläche.
|
||||||
|
- Im **Konto** sieht man die eigenen Gruppen und Rollen, ändert sein Passwort
|
||||||
|
und meldet sich ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Single Sign-on
|
||||||
|
|
||||||
|
Die Anmeldung lässt sich an einen **OpenID-Connect-Anbieter** abgeben —
|
||||||
|
getestet mit Authentik, ebenso nutzbar mit Keycloak, Zitadel oder Entra ID.
|
||||||
|
Alles Nötige kommt aus dem Discovery-Dokument des Anbieters.
|
||||||
|
|
||||||
|
- Anmeldefluss mit **PKCE** (S256), `state` und `nonce`.
|
||||||
|
- Das ID-Token wird gegen den **JWKS** des Anbieters geprüft: Signatur,
|
||||||
|
Aussteller, Empfänger, Ablauf und Nonce.
|
||||||
|
- Wiedererkannt wird jemand an der **Kennung des Anbieters** (`sub`), nicht an
|
||||||
|
der E-Mail-Adresse — Adressen ändern sich.
|
||||||
|
- Ein bestehendes Konto mit derselben Adresse wird **verknüpft statt
|
||||||
|
verdoppelt**, aber nur bei **bestätigter** Adresse. Sonst könnte sich beim
|
||||||
|
Anbieter jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||||
|
übernehmen.
|
||||||
|
- Neue Konten bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
|
||||||
|
Standardgruppe.
|
||||||
|
- `OIDC_ADMIN_GROUP` hängt die Instanz-Administration an eine Gruppe beim
|
||||||
|
Anbieter; sie wird dann bei jeder Anmeldung neu entschieden.
|
||||||
|
- `OIDC_ONLY=true` blendet die Passwort-Anmeldung aus.
|
||||||
|
|
||||||
|
**Bewusst nicht übernommen** werden Gruppen und Rollen. Die Gruppen im
|
||||||
|
Verzeichnis bedeuten etwas anderes als die hier: dort geht es um Organisation
|
||||||
|
und Zugang, hier darum, wer welche Codes sehen und ändern darf. Eine
|
||||||
|
automatische Übersetzung wäre eine Quelle stiller Überraschungen.
|
||||||
|
|
||||||
|
→ Einrichtung Feld für Feld: [authentik.md](authentik.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mehrere Domains
|
||||||
|
|
||||||
|
Kurz-URLs können unter verschiedenen Adressen laufen — etwa je Kunde eine.
|
||||||
|
Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
|
||||||
|
|
||||||
|
- Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
||||||
|
**prüft ihn gegen die registrierten Domains**. Ein unbekannter Hostname
|
||||||
|
endet mit 404 — über einen gefälschten Header lässt sich also keine fremde
|
||||||
|
Domain ansprechen.
|
||||||
|
- Der **Slug ist je Domain eindeutig**, nicht global.
|
||||||
|
- Eine Domain kann **mehreren Gruppen** dienen. Die Codes bleiben trotzdem
|
||||||
|
strikt nach Gruppe getrennt.
|
||||||
|
- Je Gruppe lässt sich eine Domain als Voreinstellung festlegen.
|
||||||
|
- Eine Domain lässt sich abschalten, ohne sie zu löschen; ihre Codes
|
||||||
|
antworten dann mit einer Erklärung.
|
||||||
|
|
||||||
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
|
Anwendung leiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwaltung
|
||||||
|
|
||||||
|
| Bereich | Was dort geht |
|
||||||
|
| --- | --- |
|
||||||
|
| **QR-Typen** | einzelne Typen ausblenden |
|
||||||
|
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
||||||
|
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
||||||
|
| **Domains** | anlegen, Gruppen zuteilen, abschalten |
|
||||||
|
|
||||||
|
Zu den **QR-Typen**: 15 Kacheln sind viel, wenn ihr nur drei davon braucht.
|
||||||
|
Ein abgeschalteter Typ verschwindet aus der Auswahl; **bereits erstellte Codes
|
||||||
|
funktionieren unverändert weiter** — sie leiten weiter, zählen Scans und
|
||||||
|
lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
|
||||||
|
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
|
||||||
|
alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- **Zwei Container:** die Next.js-Anwendung und PostgreSQL. Kein Reverse Proxy
|
||||||
|
nötig — Port 3000 wird direkt veröffentlicht. Ein Proxy davor geht
|
||||||
|
selbstverständlich auch.
|
||||||
|
- **Migrationen laufen beim Start** von selbst. Es gibt keinen Extraschritt
|
||||||
|
nach `docker compose up -d --build`.
|
||||||
|
- Die Datenbank hat einen **Healthcheck**; die Anwendung startet erst, wenn
|
||||||
|
sie wirklich Anfragen annimmt.
|
||||||
|
- **Ein Volumen** für die Datenbank — dort liegen auch die hochgeladenen
|
||||||
|
Dateien.
|
||||||
|
- Die Konfiguration steht vollständig in einer **`.env`**; eine `.env.example`
|
||||||
|
ist mitgeliefert und dokumentiert jeden Wert.
|
||||||
|
- Die Anwendung liest die Werte **zur Laufzeit**, nicht beim Bauen — eine
|
||||||
|
geänderte `BASE_URL` braucht keinen neuen Build.
|
||||||
|
- Die Oberfläche ist durchgängig **deutsch**, hell, responsiv und ohne
|
||||||
|
externe Schriften oder Skripte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnittstelle
|
||||||
|
|
||||||
|
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||||
|
|
||||||
|
| Route | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/qr/preview` | Vorschau erzeugen (statisch oder dynamisch) |
|
||||||
|
| `POST /api/qr/download` | PNG oder SVG erzeugen |
|
||||||
|
| `GET/POST /api/codes` | dynamische Codes auflisten und anlegen |
|
||||||
|
| `GET/PATCH/DELETE /api/codes/{id}` | einzelnen Code lesen, ändern, löschen |
|
||||||
|
| `GET /api/codes/{id}/qr` | Bild eines gespeicherten Codes |
|
||||||
|
| `POST /api/uploads` | Datei hochladen (`GET` nennt die Größengrenze) |
|
||||||
|
| `GET /api/uploads/{id}` | Datei ausliefern (`?download` erzwingt Speichern) |
|
||||||
|
| `POST /api/feedback` | Rückmeldung abgeben |
|
||||||
|
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
|
||||||
|
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
|
||||||
|
| `/api/admin/types` · `users` · `groups` · `domains` | Verwaltung |
|
||||||
|
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
|
||||||
|
| `GET /s/{slug}` | öffentliche Landingpage |
|
||||||
|
|
||||||
|
Fehler kommen als `{ "error": "…" }` mit einer deutschen Meldung, die sich
|
||||||
|
unverändert anzeigen lässt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was die Anwendung bewusst nicht tut
|
||||||
|
|
||||||
|
- **Kein Logo im QR-Code.** Wer es braucht, nimmt das SVG, setzt es im
|
||||||
|
Layoutprogramm ein, wählt Fehlerkorrektur H und testet mit mehreren Geräten.
|
||||||
|
- **Keine Statistik über die Zeit.** Der Zähler weiß, wie oft und wann zuletzt
|
||||||
|
— kein Verlauf, keine Herkunft, keine Geräte. Das wäre eine eigene Tabelle
|
||||||
|
und die Frage, wie lange man so etwas aufbewahrt.
|
||||||
|
- **Keine Übernahme von Gruppen aus dem Verzeichnis.** Siehe oben.
|
||||||
|
- **Keine Stapelverarbeitung.** Codes entstehen einzeln.
|
||||||
|
- **Keine Mehrsprachigkeit.** Die Oberfläche ist deutsch.
|
||||||
|
- **Kein Papierkorb.** Ein gelöschter Code ist weg, samt seiner Dateien und
|
||||||
|
Rückmeldungen.
|
||||||
|
|
||||||
|
Was davon als Nächstes sinnvoll wäre, steht in der [Roadmap](roadmap.md).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Was gebaut ist, was als Nächstes kommt und was danach denkbar wäre. Die
|
||||||
|
Aufwände sind grobe Einschätzungen, keine Zusagen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gebaut
|
||||||
|
|
||||||
|
### Version 1 — Grundgerüst (13.08.2026)
|
||||||
|
|
||||||
|
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
|
||||||
|
Proxy, Migrationen beim Start
|
||||||
|
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
||||||
|
Formular, ohne Eingriff in den übrigen Code
|
||||||
|
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
||||||
|
Scan-Zähler
|
||||||
|
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
|
||||||
|
- Download als PNG und SVG, serverseitig gerendert
|
||||||
|
- „Meine Codes“ mit Ziel ändern und Löschen
|
||||||
|
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
|
||||||
|
- Update-Skript für Linux/macOS und Windows
|
||||||
|
|
||||||
|
### Typen einzeln freigeben (14.08.2026)
|
||||||
|
|
||||||
|
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
|
||||||
|
eines abgeschalteten Typs funktionieren weiter.
|
||||||
|
|
||||||
|
### Phase A — acht weitere Typen (14.08.2026)
|
||||||
|
|
||||||
|
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
|
||||||
|
Zahlungslink), WLAN.
|
||||||
|
|
||||||
|
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
|
||||||
|
|
||||||
|
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
|
||||||
|
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
|
||||||
|
scrypt-Passwörter, Sperre nach Fehlversuchen.
|
||||||
|
|
||||||
|
### Phase B2 — mehrere Domains (14.08.2026)
|
||||||
|
|
||||||
|
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
|
||||||
|
Host-Header gegen die registrierten Domains geprüft.
|
||||||
|
|
||||||
|
### Listenansicht (14.08.2026)
|
||||||
|
|
||||||
|
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
|
||||||
|
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
|
||||||
|
|
||||||
|
### Phase C — die letzten sechs Typen (14.08.2026)
|
||||||
|
|
||||||
|
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
|
||||||
|
|
||||||
|
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
|
||||||
|
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
|
||||||
|
- Feedback mit anonymer Erhebung und Auswertung
|
||||||
|
|
||||||
|
Damit sind **alle 15 vorgesehenen Typen** gebaut.
|
||||||
|
|
||||||
|
### Single Sign-on (15.08.2026)
|
||||||
|
|
||||||
|
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
|
||||||
|
ID-Token.
|
||||||
|
|
||||||
|
### Dokumentation (15.08.2026)
|
||||||
|
|
||||||
|
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
||||||
|
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Als Nächstes
|
||||||
|
|
||||||
|
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||||
|
Person, die den Code kennt.
|
||||||
|
|
||||||
|
### 1. QR-Typen je Gruppe freigeben · mittel
|
||||||
|
|
||||||
|
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
|
||||||
|
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
|
||||||
|
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
|
||||||
|
|
||||||
|
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
|
||||||
|
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
|
||||||
|
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
|
||||||
|
entscheidet die gewählte Gruppe.
|
||||||
|
|
||||||
|
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
|
||||||
|
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
|
||||||
|
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
|
||||||
|
wahrscheinlich die richtige.
|
||||||
|
|
||||||
|
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
|
||||||
|
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
|
||||||
|
eine Gruppenauswahl bekommt.
|
||||||
|
|
||||||
|
### 2. Statistik über die Zeit · mittel
|
||||||
|
|
||||||
|
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
||||||
|
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
||||||
|
eingekehrt?
|
||||||
|
|
||||||
|
Geplant: eine Tabelle mit einer Zeile je Code und Tag, dazu ein Diagramm in
|
||||||
|
der Codeansicht und die Zahlen der letzten 7, 30 und 365 Tage.
|
||||||
|
|
||||||
|
Bewusst **ohne** Herkunft, Gerät oder Verweisseite. Ein reiner Tageszähler
|
||||||
|
sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
|
||||||
|
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
||||||
|
andere Entscheidung als „ein Diagramm wäre schön“.
|
||||||
|
|
||||||
|
### 3. Papierkorb statt Löschen · klein
|
||||||
|
|
||||||
|
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
||||||
|
samt seiner Dateien und Rückmeldungen.
|
||||||
|
|
||||||
|
Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
|
||||||
|
„Meine Codes“ ein Reiter „Papierkorb“ mit Wiederherstellen. Solange ein Code
|
||||||
|
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
||||||
|
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
||||||
|
|
||||||
|
### 4. Logo im QR-Code · mittel
|
||||||
|
|
||||||
|
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
|
||||||
|
|
||||||
|
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
|
||||||
|
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
|
||||||
|
|
||||||
|
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
|
||||||
|
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
|
||||||
|
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
|
||||||
|
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
|
||||||
|
die verdeckte Fläche.
|
||||||
|
|
||||||
|
### 5. Ablaufdatum und Zeitsteuerung · mittel
|
||||||
|
|
||||||
|
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
||||||
|
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
||||||
|
|
||||||
|
Geplant, in zwei Stufen:
|
||||||
|
|
||||||
|
- **Ablauf:** ab einem Zeitpunkt zeigt der Code eine erklärende Seite statt
|
||||||
|
des Ziels. Wichtig ist der Text — wer davorsteht, soll erfahren, dass die
|
||||||
|
Aktion vorbei ist, nicht dass „etwas nicht gefunden“ wurde.
|
||||||
|
- **Zeitplan:** mehrere Ziele mit Gültigkeitsfenstern, damit derselbe
|
||||||
|
Aufsteller montags auf die Mittagskarte und samstags auf die Veranstaltung
|
||||||
|
zeigt.
|
||||||
|
|
||||||
|
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
||||||
|
Fenstern geschieht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Später denkbar
|
||||||
|
|
||||||
|
**Das Fenster der Ersteinrichtung schließen** · klein
|
||||||
|
Heute wird das erste Konto ohne jede Legitimation angelegt und ist gleich
|
||||||
|
Administrator. Wer die Anwendung öffentlich stellt, bevor er sein Konto
|
||||||
|
angelegt hat, kann sie an einen Fremden verlieren. Vorschlag: ein einmaliges
|
||||||
|
Token, das beim ersten Start ins Protokoll geschrieben wird und für die
|
||||||
|
Ersteinrichtung nötig ist. Klein im Aufwand — und das Erste, was zu tun ist,
|
||||||
|
falls die Anwendung je aus dem eigenen Netz herausgeht. Siehe
|
||||||
|
[sicherheit.md](sicherheit.md).
|
||||||
|
|
||||||
|
**Codes exportieren** · klein
|
||||||
|
Eine CSV aller Codes einer Gruppe mit Name, Kurz-URL, Ziel und Scans — für
|
||||||
|
die Übergabe an eine Druckerei oder die Ablage.
|
||||||
|
|
||||||
|
**Codes duplizieren** · klein
|
||||||
|
„Wie dieser, aber mit anderem Ziel“ — bei vielen ähnlichen Projekten spart
|
||||||
|
das jedes Mal ein paar Minuten.
|
||||||
|
|
||||||
|
**Stapelverarbeitung** · mittel
|
||||||
|
Eine CSV hochladen und daraus 200 Codes erzeugen, als ZIP zurück. Sinnvoll,
|
||||||
|
sobald jedes Produkt oder jeder Tisch einen eigenen Code bekommt.
|
||||||
|
|
||||||
|
**Gruppen aus dem Verzeichnis übernehmen** · mittel
|
||||||
|
Die OIDC-Anbindung übernimmt heute bewusst nur die Identität. Eine Zuordnung
|
||||||
|
von Verzeichnisgruppen zu Gruppen dieser Anwendung ist möglich, braucht aber
|
||||||
|
eine klare Regel dafür, was mit von Hand vergebenen Rollen geschieht — sonst
|
||||||
|
verliert jemand beim nächsten Anmelden stillschweigend seine Rechte.
|
||||||
|
|
||||||
|
**Passwortgeschützte Landingpages** · mittel
|
||||||
|
Für Unterlagen, die nicht jeder sehen soll, der den Aushang fotografiert.
|
||||||
|
Heute ist die Empfehlung klar: Vertrauliches gehört nicht hinter einen
|
||||||
|
offenen QR-Code.
|
||||||
|
|
||||||
|
**Dateien außerhalb der Datenbank** · mittel
|
||||||
|
Heute liegen Uploads in PostgreSQL, damit es bei einem Volumen bleibt. Wer
|
||||||
|
sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem
|
||||||
|
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
|
||||||
|
die Auslieferungsroute — aber auch die Sicherungsstrategie.
|
||||||
|
|
||||||
|
**Eigene Gestaltung der Landingpages** · groß
|
||||||
|
Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma
|
||||||
|
aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es
|
||||||
|
braucht Grenzen, damit nichts Unlesbares entsteht.
|
||||||
|
|
||||||
|
**Mehrsprachige Oberfläche** · groß
|
||||||
|
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
|
||||||
|
Landingpages. Eine zweite Sprache berührt jede Datei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wie ein neuer Typ dazukommt
|
||||||
|
|
||||||
|
Falls doch noch einer fehlt: Er besteht aus einem Modul in
|
||||||
|
`src/lib/qr/types/`, einer Formular-Komponente, je einem Registry-Eintrag und
|
||||||
|
— wenn er eine eigene Seite braucht — einer Landingpage. Am übrigen Code
|
||||||
|
ändert sich nichts. Näheres im [README](../README.md), Abschnitt „Einen
|
||||||
|
weiteren Typ ergänzen“.
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt die Bildschirmfotos für docs/anleitung.md.
|
||||||
|
*
|
||||||
|
* Warum als Skript und nicht von Hand: Bilder in einer Anleitung veralten
|
||||||
|
* beim ersten Umbau. So lassen sie sich in einer Minute neu erzeugen.
|
||||||
|
*
|
||||||
|
* Voraussetzungen:
|
||||||
|
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
||||||
|
* Codes im Bild landen), erreichbar unter BASE
|
||||||
|
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
|
||||||
|
* Regelbetrieb
|
||||||
|
* - Playwright: npm i -D playwright && npx playwright install chromium
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* node docs/screenshots.mjs http://localhost:3000
|
||||||
|
*/
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
||||||
|
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
||||||
|
await mkdir(ZIEL, { recursive: true });
|
||||||
|
|
||||||
|
const ADMIN = { email: "demo@example.com", password: "nur-fuer-die-bilder-1" };
|
||||||
|
const DESIGN = { fgColor: "#312E81", bgColor: "#FFFFFF", size: 512, ecLevel: "M", margin: 4 };
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Anmelden */
|
||||||
|
|
||||||
|
let cookie = null;
|
||||||
|
|
||||||
|
async function api(pfad, init = {}) {
|
||||||
|
const headers = new Headers(init.headers ?? {});
|
||||||
|
if (init.json !== undefined) headers.set("Content-Type", "application/json");
|
||||||
|
if (cookie) headers.set("Cookie", cookie);
|
||||||
|
const response = await fetch(BASE + pfad, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const gesetzt = response.headers.get("set-cookie");
|
||||||
|
if (gesetzt) cookie = gesetzt.split(";")[0];
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
/* leerer Rumpf */
|
||||||
|
}
|
||||||
|
return { status: response.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
const zustand = await api("/api/auth");
|
||||||
|
if (zustand.body?.authEnabled) {
|
||||||
|
const aktion = zustand.body.setupOpen ? "setup" : "login";
|
||||||
|
const angemeldet = await api("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: aktion, ...ADMIN, name: "Demo Betreiber" },
|
||||||
|
});
|
||||||
|
if (angemeldet.status >= 400) {
|
||||||
|
console.error("Anmeldung fehlgeschlagen:", angemeldet.body);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Beispieldaten */
|
||||||
|
|
||||||
|
const angelegt = [];
|
||||||
|
|
||||||
|
async function code(type, data, label) {
|
||||||
|
const erstellt = await api("/api/codes", {
|
||||||
|
method: "POST",
|
||||||
|
json: { type, data, design: DESIGN, label },
|
||||||
|
});
|
||||||
|
if (erstellt.status !== 201) {
|
||||||
|
console.error(`Beispielcode „${label}“ ging nicht:`, erstellt.body);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
angelegt.push(erstellt.body.code);
|
||||||
|
return erstellt.body.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
await code("website", { url: "https://example.com/nordwind" }, "Projekt Nordwind");
|
||||||
|
await code("website", { url: "https://example.com/messe" }, "Messestand Halle 3");
|
||||||
|
|
||||||
|
const links = await code(
|
||||||
|
"links",
|
||||||
|
{
|
||||||
|
title: "Projekt Nordwind",
|
||||||
|
description: "Alle Unterlagen auf einen Blick.",
|
||||||
|
links: [
|
||||||
|
{ label: "Preisliste als PDF", url: "https://example.com/preise.pdf" },
|
||||||
|
{ label: "Anfahrt zum Werk", url: "https://example.com/anfahrt" },
|
||||||
|
{ label: "Ansprechpartner", url: "https://example.com/kontakt" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Nordwind Linkseite",
|
||||||
|
);
|
||||||
|
|
||||||
|
const social = await code(
|
||||||
|
"social",
|
||||||
|
{
|
||||||
|
title: "Bäckerei Sonnenschein",
|
||||||
|
description: "Folge uns für Neues aus der Backstube.",
|
||||||
|
profiles: [
|
||||||
|
{ network: "instagram", url: "@sonnenschein" },
|
||||||
|
{ network: "facebook", url: "sonnenschein.backt" },
|
||||||
|
{ network: "youtube", url: "@sonnenschein" },
|
||||||
|
{ network: "website", url: "https://sonnenschein.example.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Sonnenschein Profile",
|
||||||
|
);
|
||||||
|
|
||||||
|
/* --- Eine Datei zum Vorzeigen: klein, aber gültig ------------------------ */
|
||||||
|
|
||||||
|
async function hochladen(kind, name, inhalt, typ) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("kind", kind);
|
||||||
|
form.set("file", new File([inhalt], name, { type: typ }));
|
||||||
|
const antwort = await api("/api/uploads", { method: "POST", body: form });
|
||||||
|
if (antwort.status !== 201) {
|
||||||
|
console.error(`Upload „${name}“ ging nicht:`, antwort.body);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return antwort.body.upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfDatei = await hochladen(
|
||||||
|
"pdf",
|
||||||
|
"preisliste-2026.pdf",
|
||||||
|
Buffer.from(
|
||||||
|
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||||
|
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||||
|
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n" +
|
||||||
|
"trailer<</Root 1 0 R>>\n%%EOF\n",
|
||||||
|
"latin1",
|
||||||
|
),
|
||||||
|
"application/pdf",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pdf = await code(
|
||||||
|
"pdf",
|
||||||
|
{
|
||||||
|
fileId: pdfDatei.id,
|
||||||
|
filename: pdfDatei.filename,
|
||||||
|
size: pdfDatei.size,
|
||||||
|
title: "Preisliste 2026",
|
||||||
|
description: "Gültig ab Januar, alle Preise ohne Mehrwertsteuer.",
|
||||||
|
},
|
||||||
|
"Preisliste",
|
||||||
|
);
|
||||||
|
|
||||||
|
const feedback = await code(
|
||||||
|
"feedback",
|
||||||
|
{
|
||||||
|
title: "Wie war dein Besuch?",
|
||||||
|
question: "Wie zufrieden bist du?",
|
||||||
|
scale: "stars",
|
||||||
|
allowComment: true,
|
||||||
|
commentLabel: "Möchtest du uns noch etwas sagen?",
|
||||||
|
thankYou: "Danke, das hilft uns weiter!",
|
||||||
|
},
|
||||||
|
"Gastraum",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein paar Rückmeldungen, damit die Auswertung nicht leer ist.
|
||||||
|
for (const [note, text] of [
|
||||||
|
[5, "Sehr freundlich, gerne wieder."],
|
||||||
|
[4, ""],
|
||||||
|
[5, "Der Kuchen war hervorragend."],
|
||||||
|
[3, "Etwas lange Wartezeit."],
|
||||||
|
]) {
|
||||||
|
await api("/api/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
json: { codeId: feedback.id, rating: note, comment: text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Aufnehmen */
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const desktop = await browser.newContext({
|
||||||
|
viewport: { width: 1200, height: 820 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: "de-DE",
|
||||||
|
});
|
||||||
|
if (cookie) {
|
||||||
|
const [name, ...rest] = cookie.split("=");
|
||||||
|
await desktop.addCookies([{ name, value: rest.join("="), url: BASE }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handy = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 800 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: "de-DE",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||||
|
if (vorher) await vorher(page);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
|
||||||
|
await page.close();
|
||||||
|
console.log(" ✓", datei);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Bilder werden erzeugt:");
|
||||||
|
|
||||||
|
await schuss(desktop, "/", "01-startseite.png");
|
||||||
|
|
||||||
|
await schuss(desktop, "/erstellen/website", "02-website-formular.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await schuss(desktop, "/erstellen/website", "03-dynamisch.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator("#website-url").fill("https://example.com/nordwind");
|
||||||
|
await page.locator('button[data-mode="dynamic"]').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await schuss(desktop, "/meine-codes", "04-meine-codes-liste.png");
|
||||||
|
|
||||||
|
await schuss(desktop, "/meine-codes", "05-meine-codes-karten.png", {
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator('button[data-view="cards"]').click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
|
||||||
|
ganzeSeite: false,
|
||||||
|
vorher: async (page) => {
|
||||||
|
await page.locator('button[data-view="list"]').click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
|
||||||
|
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
|
||||||
|
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await schuss(handy, `/s/${links.slug}`, "07-landing-links.png");
|
||||||
|
await schuss(handy, `/s/${social.slug}`, "08-landing-social.png");
|
||||||
|
await schuss(handy, `/s/${pdf.slug}`, "09-landing-pdf.png");
|
||||||
|
await schuss(handy, `/s/${feedback.slug}`, "09b-landing-feedback.png");
|
||||||
|
await schuss(desktop, `/meine-codes/${feedback.id}/feedback`, "10-feedback-auswertung.png");
|
||||||
|
|
||||||
|
await schuss(desktop, "/verwaltung", "11-verwaltung.png");
|
||||||
|
await schuss(desktop, "/verwaltung/typen", "12-verwaltung-typen.png");
|
||||||
|
await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
|
||||||
|
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
|
||||||
|
await schuss(desktop, "/konto", "15-konto.png");
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Aufräumen */
|
||||||
|
|
||||||
|
for (const eintrag of angelegt) {
|
||||||
|
await api(`/api/codes/${eintrag.id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nFertig — ${angelegt.length} Beispielcodes wieder entfernt.`);
|
||||||
|
console.log(`Die Bilder liegen in ${ZIEL}.`);
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
||||||
|
|
||||||
|
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
||||||
|
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
|
||||||
|
|
||||||
|
Geprüft gegen:
|
||||||
|
|
||||||
|
- [Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
||||||
|
- [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||||||
|
- [Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
|
||||||
|
- [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||||
|
|
||||||
|
> **Wozu das gehört.** Die Anwendung ist ein selbst gehostetes Werkzeug für
|
||||||
|
> ein Team, nicht ein öffentlicher Dienst mit Zehntausenden Konten. Wo eine
|
||||||
|
> Empfehlung erkennbar auf den zweiten Fall gemünzt ist, steht unten, warum
|
||||||
|
> hier etwas anderes gilt — und wie man es strenger einstellt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was behoben wurde
|
||||||
|
|
||||||
|
### 1. scrypt lag unter der empfohlenen Stärke
|
||||||
|
|
||||||
|
**Befund:** Die Passwörter wurden mit scrypt bei `N = 2^15` gehasht. Das
|
||||||
|
Merkblatt nennt `N = 2^17 (128 MiB), r = 8, p = 1`.
|
||||||
|
|
||||||
|
**Behoben:** `N = 2^17`. Alte Hashes bleiben gültig — die Parameter stehen im
|
||||||
|
Hash. Beim nächsten erfolgreichen Anmelden wird das Passwort still neu
|
||||||
|
gehasht (`needsRehash()`); das ist der einzige Moment, in dem das
|
||||||
|
Klartextpasswort vorliegt.
|
||||||
|
|
||||||
|
**Nebenwirkung:** Ein Anmeldevorgang kostet jetzt spürbar mehr Rechenzeit und
|
||||||
|
rund 128 MiB Arbeitsspeicher. Genau das ist der Zweck.
|
||||||
|
|
||||||
|
### 2. Passwörter hatten keine Obergrenze
|
||||||
|
|
||||||
|
**Befund:** Die Eingabe war nach oben offen. Ein „Passwort“ von mehreren
|
||||||
|
Megabyte hätte scrypt beschäftigt — an einem Endpunkt, der **ohne Anmeldung**
|
||||||
|
erreichbar ist. Ein Denial-of-Service mit wenigen Anfragen.
|
||||||
|
|
||||||
|
**Behoben:** 128 Zeichen, geprüft vor dem Hashen, in allen drei Formularen
|
||||||
|
(Anmelden, Ersteinrichtung, Passwortwechsel) sowie in der Benutzerverwaltung.
|
||||||
|
|
||||||
|
### 3. Kein CSRF-Schutz außer `SameSite`
|
||||||
|
|
||||||
|
**Befund:** Der Sitzungs-Cookie war `SameSite=Lax`, mehr nicht. Das Merkblatt
|
||||||
|
sagt ausdrücklich, dass das allein nicht genügt: `SameSite` wirkt auf der
|
||||||
|
Ebene der registrierbaren Domain, nicht des Ursprungs. Besonders heikel war
|
||||||
|
`POST /api/uploads` — es nimmt `multipart/form-data` entgegen, und das kann
|
||||||
|
ein Formular auf einer fremden Seite abschicken.
|
||||||
|
|
||||||
|
**Behoben:** Jeder schreibende Zugriff (POST, PUT, PATCH, DELETE) wird gegen
|
||||||
|
den `Origin`-Header geprüft, verglichen mit dem Host der Anfrage — nicht mit
|
||||||
|
`BASE_URL`, weil die Anwendung absichtlich unter mehreren Domains läuft.
|
||||||
|
Fehlt der Header ganz, wird durchgelassen: Browser setzen ihn bei fremden
|
||||||
|
Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
|
||||||
|
|
||||||
|
### 4. Keine Schutzheader
|
||||||
|
|
||||||
|
**Befund:** Die Anwendung setzte keinen einzigen: kein `X-Frame-Options`,
|
||||||
|
keine `Content-Security-Policy`, kein `Referrer-Policy`,
|
||||||
|
kein `X-Content-Type-Options`. Die Verwaltung ließ sich also in eine fremde
|
||||||
|
Seite einbetten und per Clickjacking fernsteuern. Dazu verriet
|
||||||
|
`X-Powered-By: Next.js`, wonach jemand Schwachstellen suchen müsste.
|
||||||
|
|
||||||
|
**Behoben** in `src/proxy.ts`:
|
||||||
|
|
||||||
|
| Header | Wert |
|
||||||
|
| --- | --- |
|
||||||
|
| `Content-Security-Policy` | `frame-ancestors 'none'` |
|
||||||
|
| `X-Frame-Options` | `DENY` |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` |
|
||||||
|
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||||
|
| `Permissions-Policy` | Kamera, Mikrofon, Standort, Bezahlen aus |
|
||||||
|
|
||||||
|
`X-Powered-By` ist abgeschaltet.
|
||||||
|
|
||||||
|
**Bewusst nicht gesetzt** ist eine vollständige `Content-Security-Policy` für
|
||||||
|
Skripte. Next.js bringt eingebettete Skripte mit; dafür bräuchte es Nonces
|
||||||
|
durch die ganze Anwendung. Eine Policy mit `unsafe-inline` sähe nach Schutz
|
||||||
|
aus, ohne einer zu sein. Siehe [Offen](#offen).
|
||||||
|
|
||||||
|
### 5. Sitzungen ohne absolute Obergrenze
|
||||||
|
|
||||||
|
**Befund:** Die Sitzung lief 30 Tage nach der letzten Nutzung ab — und wurde
|
||||||
|
bei jeder Nutzung verlängert. Eine einmal entwendete Sitzung hätte durch
|
||||||
|
regelmäßige Nutzung **unbegrenzt** weitergelebt.
|
||||||
|
|
||||||
|
**Behoben:** Zusätzlich eine harte Grenze ab dem Anmelden. Über die
|
||||||
|
Verlängerung hinaus wird nie verlängert.
|
||||||
|
|
||||||
|
| Einstellung | Voreinstellung | Empfehlung des Merkblatts |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SESSION_IDLE_DAYS` | 30 Tage | 15–30 **Minuten** bei geringem Risiko |
|
||||||
|
| `SESSION_MAX_DAYS` | 90 Tage | 4–8 **Stunden** absolut |
|
||||||
|
|
||||||
|
Die Voreinstellungen liegen bewusst weit darüber. Die Zahlen des Merkblatts
|
||||||
|
zielen auf öffentlich erreichbare Anwendungen; bei einem internen Werkzeug,
|
||||||
|
an dem man täglich arbeitet, wäre eine halbstündige Abmeldung eine Zumutung
|
||||||
|
und würde nur dazu führen, dass jemand den Browser dauerhaft offen lässt. Wer
|
||||||
|
strenger fahren will, setzt beide Werte in der `.env` — dafür sind sie
|
||||||
|
einstellbar.
|
||||||
|
|
||||||
|
### 6. `Secure` fehlte hinter einem TLS-Proxy
|
||||||
|
|
||||||
|
**Befund:** Das `Secure`-Flag am Cookie hing allein an `BASE_URL`. Wer die
|
||||||
|
Anwendung hinter einem Proxy mit HTTPS betreibt, aber `BASE_URL` auf `http`
|
||||||
|
stehen lässt, bekam den Cookie ohne `Secure` — er wäre über eine
|
||||||
|
unverschlüsselte Verbindung mitgegangen.
|
||||||
|
|
||||||
|
**Behoben:** Zusätzlich wird `X-Forwarded-Proto` ausgewertet.
|
||||||
|
|
||||||
|
### 7. Die Anmeldung verriet, welche Konten es gibt
|
||||||
|
|
||||||
|
**Befund:** Bei falschem Passwort kam immer dieselbe Meldung — richtig. Aber
|
||||||
|
drei Fälle fielen aus der Reihe und bestätigten damit die Existenz eines
|
||||||
|
Kontos:
|
||||||
|
|
||||||
|
- ein deaktiviertes Konto („Dieses Konto ist deaktiviert“),
|
||||||
|
- ein Konto aus dem Verzeichnis („meldet sich über den externen Anbieter an“),
|
||||||
|
- ein gesperrtes Konto (429 mit Wartezeit).
|
||||||
|
|
||||||
|
**Behoben** für die ersten beiden:
|
||||||
|
|
||||||
|
- Die Prüfung auf „deaktiviert“ steht jetzt **nach** der Passwortprüfung. Wer
|
||||||
|
bis dahin kommt, hat das Konto nachweislich in der Hand — ihm darf man
|
||||||
|
sagen, warum es nicht weitergeht.
|
||||||
|
- Verzeichnis-Konten bekommen die allgemeine Meldung. Der Knopf für den
|
||||||
|
Anbieter steht daneben.
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** die Sperrmeldung. Sie muss vor der Passwortprüfung
|
||||||
|
kommen — das ist ihr Zweck. Wer sie versteckt, lässt einen rechtmäßigen
|
||||||
|
Benutzer 15 Minuten im Dunkeln stehen. Bei einem Werkzeug, in dem die
|
||||||
|
E-Mail-Adressen der Kollegen ohnehin bekannt sind, wiegt die Verständlichkeit
|
||||||
|
schwerer als ein Hinweis, den ein Angreifer auch anders bekommt.
|
||||||
|
|
||||||
|
### 8. Kontosperre schützte nicht gegen breites Durchprobieren
|
||||||
|
|
||||||
|
**Befund:** Nach 8 Fehlversuchen wurde ein **Konto** 15 Minuten gesperrt. Wer
|
||||||
|
stattdessen ein Passwort gegen viele Konten probiert, löst diese Sperre nie
|
||||||
|
aus.
|
||||||
|
|
||||||
|
**Behoben:** zusätzlich eine Bremse je Absender — 20 Versuche in 5 Minuten,
|
||||||
|
danach 429. Im Arbeitsspeicher und damit pro Container; die Adresse dient nur
|
||||||
|
als Schlüssel und wird nirgends gespeichert.
|
||||||
|
|
||||||
|
### 9. Ersteinrichtung mit Wettlauf
|
||||||
|
|
||||||
|
**Befund:** Zwischen „gibt es schon einen Benutzer?“ und dem Anlegen lag eine
|
||||||
|
Lücke. Zwei gleichzeitige Aufrufe auf einer frischen Installation hätten
|
||||||
|
**zwei** Administratoren erzeugt — einer davon ungebeten.
|
||||||
|
|
||||||
|
**Behoben:** Prüfung und Anlegen laufen in einer serialisierbaren
|
||||||
|
Transaktion. Ein Test schickt drei Anfragen gleichzeitig; genau eine kommt
|
||||||
|
durch.
|
||||||
|
|
||||||
|
### 10. Abgelaufene Sitzungen blieben liegen
|
||||||
|
|
||||||
|
**Befund:** `pruneExpiredSessions()` existierte, wurde aber nirgends
|
||||||
|
aufgerufen. Kein Sicherheitsproblem — abgelaufene Sitzungen werden bei der
|
||||||
|
Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
|
||||||
|
|
||||||
|
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
||||||
|
wird beiläufig aufgeräumt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was schon vorher stimmte
|
||||||
|
|
||||||
|
| Anforderung | Umsetzung |
|
||||||
|
| --- | --- |
|
||||||
|
| Sitzungs-Token aus einem CSPRNG, ≥ 128 Bit | 32 Byte aus `randomBytes` |
|
||||||
|
| Token nicht im Klartext gespeichert | in der Datenbank nur der SHA-256 |
|
||||||
|
| `HttpOnly` | gesetzt |
|
||||||
|
| `SameSite` | `Lax` |
|
||||||
|
| Pfad so eng wie möglich | `/` — die Anwendung liegt an der Wurzel |
|
||||||
|
| Neue Sitzung beim Anmelden | immer ein frisches Token; keine Sitzungsfixierung möglich |
|
||||||
|
| Alle Sitzungen beenden beim Passwortwechsel | ja, danach sofort eine neue |
|
||||||
|
| Sitzung entwerten beim Deaktivieren | bei jedem Zugriff wird `isActive` geprüft |
|
||||||
|
| Passwortvergleich in konstanter Zeit | `timingSafeEqual` |
|
||||||
|
| Gleiche Antwortzeit für unbekannte Adressen | Vergleich gegen einen Attrappen-Hash mit denselben Parametern |
|
||||||
|
| Berechtigungen an einer Stelle | `src/lib/auth/policy.ts`; jede Route fragt sie |
|
||||||
|
| Serverseitige Prüfung, nicht nur ausgeblendete Knöpfe | ja |
|
||||||
|
|
||||||
|
Zur **Anmeldung über OIDC** (siehe [authentik.md](authentik.md)): PKCE mit
|
||||||
|
S256, `state` gegen CSRF, `nonce` gegen wiedereingereichte Token, Prüfung des
|
||||||
|
ID-Tokens gegen den JWKS des Anbieters samt Aussteller, Empfänger und Ablauf.
|
||||||
|
Die Rückkehradresse ist fest verdrahtet — es gibt keinen Parameter, über den
|
||||||
|
sich eine offene Weiterleitung bauen ließe. Verknüpft wird ein bestehendes
|
||||||
|
Konto nur bei **bestätigter** E-Mail-Adresse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
Bewusst nicht umgesetzt, mit Begründung.
|
||||||
|
|
||||||
|
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
||||||
|
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
||||||
|
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
||||||
|
Administrator an, der die Adresse selbst einträgt. Eine Bestätigungsmail
|
||||||
|
würde also belegen, dass eine Adresse existiert, aber niemanden hereinlassen
|
||||||
|
und niemanden fernhalten.
|
||||||
|
|
||||||
|
Dazu kommt: Es gibt **kein „Passwort vergessen" per E-Mail**. Zurücksetzen
|
||||||
|
kann nur ein Administrator unter Verwaltung → Benutzer. Damit ist die
|
||||||
|
unbestätigte Adresse auch kein Übernahmeweg — bei einem Reset per Mail wäre
|
||||||
|
sie einer. Die Adresse ist hier schlicht ein Anmeldename.
|
||||||
|
|
||||||
|
Nötig würde die Verifizierung in dem Moment, in dem sich Leute selbst
|
||||||
|
registrieren dürfen. Dann allerdings zwingend, zusammen mit einem
|
||||||
|
SMTP-Versand, Ablauffristen für die Bestätigungslinks und einem Weg für
|
||||||
|
nicht zugestellte Mails.
|
||||||
|
|
||||||
|
Zwei Stellen bleiben davon ohnehin unberührt, weil dort nicht der
|
||||||
|
Administrator die Adresse einträgt:
|
||||||
|
|
||||||
|
- **Die Ersteinrichtung.** Wer zuerst kommt, wird Administrator. Dagegen hilft
|
||||||
|
keine Verifizierung, sondern nur, das Fenster zu schließen — siehe unten.
|
||||||
|
- **Die Anmeldung über einen Anbieter.** Dort bürgt der Anbieter für die
|
||||||
|
Adresse, und genau das prüft die Anwendung: Ein bestehendes Konto wird nur
|
||||||
|
bei `email_verified` verknüpft.
|
||||||
|
|
||||||
|
**Das Zeitfenster der Ersteinrichtung.** Solange es keinen Benutzer gibt und
|
||||||
|
die Anmeldung eingeschaltet ist, wird das erste Konto ohne jede Legitimation
|
||||||
|
angelegt — und ist Administrator der Installation. Steht die Anwendung schon
|
||||||
|
öffentlich, bevor der Betreiber sein Konto anlegt, kann ein Fremder sie
|
||||||
|
übernehmen.
|
||||||
|
|
||||||
|
Heute ist das eine Frage der Reihenfolge: erst starten, sofort das erste
|
||||||
|
Konto anlegen, dann erreichbar machen. Sauberer wäre ein einmaliges Token,
|
||||||
|
das beim ersten Start ins Protokoll geschrieben wird und für die
|
||||||
|
Ersteinrichtung nötig ist. Steht als Vorschlag in der
|
||||||
|
[Roadmap](roadmap.md).
|
||||||
|
|
||||||
|
**Vollständige Content-Security-Policy für Skripte.** Bräuchte Nonces durch
|
||||||
|
die ganze Anwendung, weil Next.js eingebettete Skripte ausliefert. Der Nutzen
|
||||||
|
wäre echt — es ist die zweite Verteidigungslinie gegen XSS —, aber halb
|
||||||
|
umgesetzt ist sie wertlos. Gehört als eigenes Vorhaben auf die
|
||||||
|
[Roadmap](roadmap.md), nicht nebenbei erledigt.
|
||||||
|
|
||||||
|
**`__Host-` als Präfix des Cookie-Namens.** Das Merkblatt nennt es die
|
||||||
|
stärkste Variante. Es setzt `Secure` voraus — der Name müsste sich also je
|
||||||
|
nach Betriebsart ändern, und beim Umstellen von HTTP auf HTTPS würden alle
|
||||||
|
Sitzungen still ungültig. Für eine Anwendung, die auch im LAN über HTTP läuft,
|
||||||
|
ist das mehr Verwirrung als Gewinn. Unter reinem HTTPS wäre es eine sinnvolle
|
||||||
|
Ergänzung.
|
||||||
|
|
||||||
|
**Abgleich gegen bekannt gewordene Passwörter.** Eine Prüfung gegen die
|
||||||
|
Have-I-Been-Pwned-Liste (k-Anonymität, ohne das Passwort zu übertragen) wäre
|
||||||
|
der wirksamste einzelne Schritt für die Passwortqualität. Sie setzt aber
|
||||||
|
voraus, dass die Anwendung ins Internet telefonieren darf — bei einer selbst
|
||||||
|
gehosteten Installation nicht selbstverständlich. Sinnvoll als abschaltbare
|
||||||
|
Option.
|
||||||
|
|
||||||
|
**Zweiter Faktor.** Nicht vorhanden. Wer ihn braucht, bekommt ihn heute über
|
||||||
|
den Umweg Authentik: Dort lässt sich TOTP oder WebAuthn erzwingen, und diese
|
||||||
|
Anwendung übernimmt nur das Ergebnis. Das ist die bessere Arbeitsteilung als
|
||||||
|
eine eigene halbgare Umsetzung.
|
||||||
|
|
||||||
|
**Protokollierung sicherheitsrelevanter Ereignisse.** Fehlversuche, Sperren
|
||||||
|
und Rechteänderungen werden nicht eigens protokolliert. Für den Betrieb im
|
||||||
|
Team verzichtbar; bei mehr Konten wäre es das Erste, was fehlt.
|
||||||
|
|
||||||
|
**Verteilte Bremse.** Die Zähler für Anmeldeversuche und Rückmeldungen liegen
|
||||||
|
im Arbeitsspeicher und gelten damit je Container. Bei mehreren Instanzen
|
||||||
|
hinter einem Lastverteiler bräuchte es einen gemeinsamen Speicher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachgeprüft
|
||||||
|
|
||||||
|
Die Härtungen sind durch Tests belegt (21 Prüfungen), unter anderem:
|
||||||
|
|
||||||
|
- Alle fünf Schutzheader sind an jeder Antwort vorhanden, `X-Powered-By` ist
|
||||||
|
weg.
|
||||||
|
- Ein schreibender Zugriff mit fremdem `Origin` wird abgewiesen — auch beim
|
||||||
|
Upload; mit eigenem oder ohne `Origin` geht er durch.
|
||||||
|
- Hochgeladene Dateien behalten ihre eigene, schärfere Sandbox-Regel.
|
||||||
|
- Ein 5000 Zeichen langes Passwort wird abgewiesen, bevor gerechnet wird.
|
||||||
|
- Drei gleichzeitige Ersteinrichtungen erzeugen genau einen Administrator.
|
||||||
|
- Eine unbekannte Adresse ist an der Antwort nicht von einem falschen
|
||||||
|
Passwort zu unterscheiden — auch nicht bei einem Verzeichnis-Konto.
|
||||||
|
- Der Sitzungs-Cookie trägt `HttpOnly`, `SameSite=Lax`, `Path=/` und ein
|
||||||
|
ausreichend langes Token; `Secure` erscheint nur unter HTTPS.
|
||||||
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
|
|||||||
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
|
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
|
||||||
// sollen nicht vom Bundler verarbeitet werden.
|
// sollen nicht vom Bundler verarbeitet werden.
|
||||||
serverExternalPackages: ["qrcode", "pg"],
|
serverExternalPackages: ["qrcode", "pg"],
|
||||||
|
|
||||||
|
// „X-Powered-By: Next.js" verrät nur, wonach jemand Schwachstellen suchen
|
||||||
|
// müsste. Die Schutzheader setzt src/middleware.ts.
|
||||||
|
poweredByHeader: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"jose": "^6.2.8",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -5024,6 +5026,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -5950,6 +5967,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
|
||||||
|
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -7085,6 +7111,38 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"jose": "^6.2.8",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -44,4 +46,3 @@
|
|||||||
"unrs-resolver": true
|
"unrs-resolver": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Anmeldung über einen externen Anbieter (OpenID Connect).
|
||||||
|
--
|
||||||
|
-- `oidcSubject` ist die stabile Kennung beim Anbieter. Sie und nicht die
|
||||||
|
-- E-Mail-Adresse entscheidet, wer wiedererkannt wird — Adressen ändern sich.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "oidcSubject" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject");
|
||||||
|
|
||||||
|
-- Konten, die sich nur über den Anbieter anmelden, haben kein Passwort.
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Harte Obergrenze für Sitzungen, unabhängig von der Aktivität.
|
||||||
|
--
|
||||||
|
-- Bestehende Sitzungen bekommen 90 Tage ab jetzt — sie sollen durch die
|
||||||
|
-- Änderung nicht plötzlich alle ungültig werden.
|
||||||
|
ALTER TABLE "Session"
|
||||||
|
ADD COLUMN "absoluteExpiresAt" TIMESTAMP(3) NOT NULL
|
||||||
|
DEFAULT (CURRENT_TIMESTAMP + INTERVAL '90 days');
|
||||||
|
|
||||||
|
CREATE INDEX "Session_absoluteExpiresAt_idx" ON "Session"("absoluteExpiresAt");
|
||||||
@@ -29,7 +29,12 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
||||||
passwordHash String
|
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
|
||||||
|
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
|
||||||
|
passwordHash String?
|
||||||
|
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
|
||||||
|
/// sich das Konto nur mit Passwort anmeldet.
|
||||||
|
oidcSubject String? @unique
|
||||||
/// Darf Gruppen, Benutzer und Domains verwalten.
|
/// Darf Gruppen, Benutzer und Domains verwalten.
|
||||||
isInstanceAdmin Boolean @default(false)
|
isInstanceAdmin Boolean @default(false)
|
||||||
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
||||||
@@ -58,12 +63,17 @@ model Session {
|
|||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
|
||||||
|
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
|
||||||
|
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
|
||||||
lastSeenAt DateTime @default(now())
|
lastSeenAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
|
@@index([absoluteExpiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { LoginForm, SetupForm } from "@/components/auth-forms";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Notice } from "@/components/ui";
|
import { Notice } from "@/components/ui";
|
||||||
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||||
|
import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc";
|
||||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Anmelden" };
|
export const metadata: Metadata = { title: "Anmelden" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SignInPage() {
|
type PageProps = { searchParams: Promise<{ fehler?: string }> };
|
||||||
|
|
||||||
|
export default async function SignInPage({ searchParams }: PageProps) {
|
||||||
// Ohne Anmeldung gibt es hier nichts zu tun.
|
// Ohne Anmeldung gibt es hier nichts zu tun.
|
||||||
if (!isAuthEnabled()) redirect("/");
|
if (!isAuthEnabled()) redirect("/");
|
||||||
|
|
||||||
@@ -18,30 +21,85 @@ export default async function SignInPage() {
|
|||||||
if (actor.kind === "user") redirect("/");
|
if (actor.kind === "user") redirect("/");
|
||||||
|
|
||||||
const setup = await isSetupOpen();
|
const setup = await isSetupOpen();
|
||||||
|
const { fehler } = await searchParams;
|
||||||
|
|
||||||
|
// Eine unvollständige OIDC-Konfiguration soll die Anmeldeseite nicht
|
||||||
|
// unbenutzbar machen — dann bleibt wenigstens das Passwortfeld.
|
||||||
|
let oidc: { label: string; nurOidc: boolean } | null = null;
|
||||||
|
let oidcFehler: string | null = null;
|
||||||
|
if (isOidcEnabled()) {
|
||||||
|
try {
|
||||||
|
const config = getOidcConfig();
|
||||||
|
oidc = { label: config.buttonLabel, nurOidc: !config.allowLocalLogin };
|
||||||
|
} catch (error) {
|
||||||
|
oidcFehler =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Die Anmeldung über den externen Anbieter ist nicht eingerichtet.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei reiner Anbieter-Anmeldung gibt es keine Ersteinrichtung mit Passwort:
|
||||||
|
// Der erste, der sich über den Anbieter anmeldet, wird Administrator.
|
||||||
|
const zeigeFormular = !oidc?.nurOidc;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
|
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
|
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
|
||||||
<Icon name={setup ? "lock" : "qr"} className="size-6" />
|
<Icon name={setup && zeigeFormular ? "lock" : "qr"} className="size-6" />
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
||||||
{setup ? "Ersteinrichtung" : "Anmelden"}
|
{setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
{setup
|
{setup && zeigeFormular
|
||||||
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
|
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
|
||||||
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
|
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
|
{fehler ? <Notice tone="warning">{fehler}</Notice> : null}
|
||||||
|
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
|
||||||
|
|
||||||
{setup ? (
|
{oidc ? (
|
||||||
|
<a
|
||||||
|
href="/api/auth/oidc/start"
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<Icon name="lock" className="size-4" />
|
||||||
|
{oidc.label}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{oidc && zeigeFormular ? (
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400 uppercase">
|
||||||
|
<span className="h-px flex-1 bg-slate-200" />
|
||||||
|
oder mit Passwort
|
||||||
|
<span className="h-px flex-1 bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{zeigeFormular ? (
|
||||||
|
setup ? (
|
||||||
|
<SetupForm minLength={MIN_PASSWORD_LENGTH} />
|
||||||
|
) : (
|
||||||
|
<LoginForm />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{setup && zeigeFormular ? (
|
||||||
<Notice tone="info">
|
<Notice tone="info">
|
||||||
Der erste Benutzer wird Administrator der Installation und darf
|
Der erste Benutzer wird Administrator der Installation und darf
|
||||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
) : oidc?.nurOidc ? (
|
||||||
|
<p className="text-center text-sm text-slate-500">
|
||||||
|
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
|
||||||
|
{setup
|
||||||
|
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-sm text-slate-500">
|
<p className="text-center text-sm text-slate-500">
|
||||||
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isAuthEnabled } from "@/lib/auth/actor";
|
|||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||||
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
|
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Konto" };
|
export const metadata: Metadata = { title: "Konto" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,6 +15,15 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function AccountPage() {
|
export default async function AccountPage() {
|
||||||
const actor = await requirePageActor();
|
const actor = await requirePageActor();
|
||||||
|
|
||||||
|
// Konten aus dem externen Verzeichnis haben hier kein Passwort.
|
||||||
|
const ausVerzeichnis =
|
||||||
|
actor.kind === "user"
|
||||||
|
? ((await prisma.user.findUnique({
|
||||||
|
where: { id: actor.user.id },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
}))?.passwordHash ?? null) === null
|
||||||
|
: false;
|
||||||
|
|
||||||
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
|
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
|
||||||
// wie sich das umstellen lässt, statt einfach leer zu sein.
|
// wie sich das umstellen lässt, statt einfach leer zu sein.
|
||||||
if (actor.kind !== "user") {
|
if (actor.kind !== "user") {
|
||||||
@@ -77,7 +87,7 @@ export default async function AccountPage() {
|
|||||||
<dt className="text-slate-500">Rolle:</dt>
|
<dt className="text-slate-500">Rolle:</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{user.isInstanceAdmin ? (
|
{user.isInstanceAdmin ? (
|
||||||
<Badge tone="popular">Administrator der Installation</Badge>
|
<Badge tone="accent">Administrator der Installation</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-900">Benutzer</span>
|
<span className="text-slate-900">Benutzer</span>
|
||||||
)}
|
)}
|
||||||
@@ -123,7 +133,17 @@ export default async function AccountPage() {
|
|||||||
|
|
||||||
{isAuthEnabled() ? (
|
{isAuthEnabled() ? (
|
||||||
<>
|
<>
|
||||||
|
{ausVerzeichnis ? (
|
||||||
|
<Card>
|
||||||
|
<CardTitle>Passwort</CardTitle>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Dieses Konto meldet sich über den externen Anbieter an. Ein
|
||||||
|
Passwort gibt es hier nicht — geändert wird es beim Anbieter.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
||||||
|
)}
|
||||||
<SignOutCard />
|
<SignOutCard />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -67,11 +67,16 @@ export default async function HomePage() {
|
|||||||
>
|
>
|
||||||
{qrType.title}
|
{qrType.title}
|
||||||
</span>
|
</span>
|
||||||
{usable ? (
|
{/* Kein Abzeichen „Beliebt“ mehr: Es stand an jedem
|
||||||
<Badge tone="popular">Beliebt</Badge>
|
nutzbaren Typ und sagte damit nichts. Hier steht nur
|
||||||
) : (
|
noch, was die Wahl beeinflusst — nämlich wenn ein Typ
|
||||||
|
zwingend eine Kurz-URL braucht und deshalb nicht ohne
|
||||||
|
diesen Server funktioniert. */}
|
||||||
|
{!usable ? (
|
||||||
<Badge tone="soon">Bald verfügbar</Badge>
|
<Badge tone="soon">Bald verfügbar</Badge>
|
||||||
)}
|
) : !qrType.supportsStatic ? (
|
||||||
|
<Badge tone="neutral">nur dynamisch</Badge>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
|
|||||||
import {
|
import {
|
||||||
describePasswordRules,
|
describePasswordRules,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
MIN_PASSWORD_LENGTH,
|
MIN_PASSWORD_LENGTH,
|
||||||
} from "@/lib/auth/password";
|
} from "@/lib/auth/password";
|
||||||
import { requirePermission } from "@/lib/auth/policy";
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
@@ -26,6 +27,9 @@ const updateSchema = z.object({
|
|||||||
.min(MIN_PASSWORD_LENGTH, {
|
.min(MIN_PASSWORD_LENGTH, {
|
||||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||||
})
|
})
|
||||||
|
.max(MAX_PASSWORD_LENGTH, {
|
||||||
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
/** Sperre nach Fehlversuchen aufheben. */
|
/** Sperre nach Fehlversuchen aufheben. */
|
||||||
unlock: z.boolean().optional(),
|
unlock: z.boolean().optional(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
|
|||||||
import {
|
import {
|
||||||
describePasswordRules,
|
describePasswordRules,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
MIN_PASSWORD_LENGTH,
|
MIN_PASSWORD_LENGTH,
|
||||||
} from "@/lib/auth/password";
|
} from "@/lib/auth/password";
|
||||||
import { requirePermission } from "@/lib/auth/policy";
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
@@ -25,8 +26,11 @@ const createSchema = z.object({
|
|||||||
error: "Das ist keine gültige E-Mail-Adresse.",
|
error: "Das ist keine gültige E-Mail-Adresse.",
|
||||||
}),
|
}),
|
||||||
name: z.string().trim().max(120).optional(),
|
name: z.string().trim().max(120).optional(),
|
||||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
password: z
|
||||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
.string()
|
||||||
|
.min(MIN_PASSWORD_LENGTH, { error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, })
|
||||||
|
.max(MAX_PASSWORD_LENGTH, {
|
||||||
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||||
}),
|
}),
|
||||||
isInstanceAdmin: z.boolean().default(false),
|
isInstanceAdmin: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||||
|
import {
|
||||||
|
exchangeCode,
|
||||||
|
getOidcConfig,
|
||||||
|
isOidcEnabled,
|
||||||
|
OIDC_FLOW_COOKIE,
|
||||||
|
type OidcConfig,
|
||||||
|
type OidcIdentity,
|
||||||
|
} from "@/lib/auth/oidc";
|
||||||
|
import { createSession } from "@/lib/auth/session";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getBaseUrl } from "@/lib/env";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */
|
||||||
|
function zurueckMitFehler(meldung: string): Response {
|
||||||
|
const ziel = new URL("/anmelden", getBaseUrl());
|
||||||
|
ziel.searchParams.set("fehler", meldung);
|
||||||
|
return Response.redirect(ziel.toString(), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlowState = { state: string; nonce: string; codeVerifier: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts`
|
||||||
|
* über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in
|
||||||
|
* mehreren Bündeln und hätten dort verschiedene Identitäten.
|
||||||
|
*/
|
||||||
|
function istBenutzerfehler(error: unknown): error is Error {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error as Error & { userFacing?: unknown }).userFacing === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFlow(raw: string | undefined): FlowState | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const wert = JSON.parse(raw) as Partial<FlowState>;
|
||||||
|
if (
|
||||||
|
typeof wert.state === "string" &&
|
||||||
|
typeof wert.nonce === "string" &&
|
||||||
|
typeof wert.codeVerifier === "string"
|
||||||
|
) {
|
||||||
|
return wert as FlowState;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unbrauchbarer Cookie — behandeln wie keiner.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt das Konto an oder findet es wieder.
|
||||||
|
*
|
||||||
|
* Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die
|
||||||
|
* E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes
|
||||||
|
* Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem
|
||||||
|
* lokal angelegten Administrator nicht plötzlich ein zweites Konto wird.
|
||||||
|
*/
|
||||||
|
async function findeOderLegeAn(
|
||||||
|
identity: OidcIdentity,
|
||||||
|
config: OidcConfig,
|
||||||
|
): Promise<{ id: string; isActive: boolean } | { fehler: string }> {
|
||||||
|
const nachSubject = await prisma.user.findUnique({
|
||||||
|
where: { oidcSubject: identity.subject },
|
||||||
|
});
|
||||||
|
|
||||||
|
const istAdmin = config.adminGroup
|
||||||
|
? identity.groups.includes(config.adminGroup)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (nachSubject) {
|
||||||
|
const aktualisiert = await prisma.user.update({
|
||||||
|
where: { id: nachSubject.id },
|
||||||
|
data: {
|
||||||
|
email: identity.email,
|
||||||
|
name: identity.name ?? nachSubject.name,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
failedLogins: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
// Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der
|
||||||
|
// Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist.
|
||||||
|
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: aktualisiert.id, isActive: aktualisiert.isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachEmail = await prisma.user.findUnique({
|
||||||
|
where: { email: identity.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nachEmail) {
|
||||||
|
// Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter
|
||||||
|
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||||
|
// übernehmen.
|
||||||
|
if (!identity.emailVerified) {
|
||||||
|
return {
|
||||||
|
fehler:
|
||||||
|
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const verknuepft = await prisma.user.update({
|
||||||
|
where: { id: nachEmail.id },
|
||||||
|
data: {
|
||||||
|
oidcSubject: identity.subject,
|
||||||
|
name: identity.name ?? nachEmail.name,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
failedLogins: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: verknuepft.id, isActive: verknuepft.isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter.
|
||||||
|
const standardgruppe = await prisma.group.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
||||||
|
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
||||||
|
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
||||||
|
const istErster = (await prisma.user.count()) === 0;
|
||||||
|
|
||||||
|
const angelegt = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: identity.email,
|
||||||
|
name: identity.name,
|
||||||
|
passwordHash: null,
|
||||||
|
oidcSubject: identity.subject,
|
||||||
|
isInstanceAdmin: istAdmin ?? istErster,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
memberships: standardgruppe
|
||||||
|
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
const store = await cookies();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isAuthEnabled() || !isOidcEnabled()) {
|
||||||
|
return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe.
|
||||||
|
const fehler = params.get("error");
|
||||||
|
if (fehler) {
|
||||||
|
const beschreibung = params.get("error_description");
|
||||||
|
console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung);
|
||||||
|
return zurueckMitFehler(
|
||||||
|
fehler === "access_denied"
|
||||||
|
? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?"
|
||||||
|
: "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value);
|
||||||
|
if (!flow) {
|
||||||
|
return zurueckMitFehler(
|
||||||
|
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return zurueckMitFehler("Die Antwort des Anbieters war unvollständig.");
|
||||||
|
}
|
||||||
|
if (state !== flow.state) {
|
||||||
|
// Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf.
|
||||||
|
return zurueckMitFehler(
|
||||||
|
"Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getOidcConfig();
|
||||||
|
const identity = await exchangeCode({
|
||||||
|
config,
|
||||||
|
code,
|
||||||
|
codeVerifier: flow.codeVerifier,
|
||||||
|
nonce: flow.nonce,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = await findeOderLegeAn(identity, config);
|
||||||
|
if ("fehler" in ergebnis) {
|
||||||
|
return zurueckMitFehler(ergebnis.fehler);
|
||||||
|
}
|
||||||
|
if (!ergebnis.isActive) {
|
||||||
|
return zurueckMitFehler(
|
||||||
|
"Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(ergebnis.id);
|
||||||
|
|
||||||
|
const ziel = new URL("/", getBaseUrl());
|
||||||
|
return Response.redirect(ziel.toString(), 302);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error);
|
||||||
|
// Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind —
|
||||||
|
// alles andere könnte Interna verraten.
|
||||||
|
const meldung = istBenutzerfehler(error)
|
||||||
|
? error.message
|
||||||
|
: "Die Anmeldung ist fehlgeschlagen.";
|
||||||
|
return zurueckMitFehler(meldung);
|
||||||
|
} finally {
|
||||||
|
// In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut.
|
||||||
|
store.delete(OIDC_FLOW_COOKIE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||||
|
import {
|
||||||
|
discover,
|
||||||
|
getOidcConfig,
|
||||||
|
isOidcEnabled,
|
||||||
|
OIDC_FLOW_COOKIE,
|
||||||
|
OIDC_FLOW_MINUTES,
|
||||||
|
redirectUri,
|
||||||
|
} from "@/lib/auth/oidc";
|
||||||
|
import { errorResponse } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function base64url(buffer: Buffer): string {
|
||||||
|
return buffer.toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt den Browser zum Anbieter.
|
||||||
|
*
|
||||||
|
* Mit PKCE (RFC 7636): Der Code allein nützt niemandem, weil beim Einlösen
|
||||||
|
* 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<Response> {
|
||||||
|
try {
|
||||||
|
if (!isAuthEnabled()) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung ist ausgeschaltet (AUTH_ENABLED=false).",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isOidcEnabled()) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung über einen externen Anbieter ist ausgeschaltet (OIDC_ENABLED=false).",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getOidcConfig();
|
||||||
|
const meta = await discover(config.issuer);
|
||||||
|
|
||||||
|
const state = base64url(randomBytes(32));
|
||||||
|
const nonce = base64url(randomBytes(32));
|
||||||
|
const codeVerifier = base64url(randomBytes(32));
|
||||||
|
const codeChallenge = base64url(
|
||||||
|
createHash("sha256").update(codeVerifier).digest(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(OIDC_FLOW_COOKIE, JSON.stringify({ state, nonce, codeVerifier }), {
|
||||||
|
httpOnly: true,
|
||||||
|
// `lax` genügt: Der Anbieter schickt per GET zurück, dabei wird der
|
||||||
|
// Cookie mitgeschickt.
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
||||||
|
maxAge: OIDC_FLOW_MINUTES * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ziel = new URL(meta.authorization_endpoint);
|
||||||
|
ziel.searchParams.set("response_type", "code");
|
||||||
|
ziel.searchParams.set("client_id", config.clientId);
|
||||||
|
ziel.searchParams.set("redirect_uri", redirectUri());
|
||||||
|
ziel.searchParams.set("scope", config.scopes);
|
||||||
|
ziel.searchParams.set("state", state);
|
||||||
|
ziel.searchParams.set("nonce", nonce);
|
||||||
|
ziel.searchParams.set("code_challenge", codeChallenge);
|
||||||
|
ziel.searchParams.set("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
return Response.redirect(ziel.toString(), 302);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
|||||||
import {
|
import {
|
||||||
describePasswordRules,
|
describePasswordRules,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
MIN_PASSWORD_LENGTH,
|
MIN_PASSWORD_LENGTH,
|
||||||
|
needsRehash,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from "@/lib/auth/password";
|
} from "@/lib/auth/password";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +15,11 @@ import {
|
|||||||
destroyAllSessions,
|
destroyAllSessions,
|
||||||
destroySession,
|
destroySession,
|
||||||
} from "@/lib/auth/session";
|
} from "@/lib/auth/session";
|
||||||
|
import {
|
||||||
|
clearAttempts,
|
||||||
|
requestOrigin,
|
||||||
|
tooManyAttempts,
|
||||||
|
} from "@/lib/auth/throttle";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
@@ -23,6 +30,13 @@ export const dynamic = "force-dynamic";
|
|||||||
const MAX_ATTEMPTS = 8;
|
const MAX_ATTEMPTS = 8;
|
||||||
const LOCK_MINUTES = 15;
|
const LOCK_MINUTES = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
|
||||||
|
* Antwortzeit nicht verrät, ob es die Adresse gibt. Die Parameter entsprechen
|
||||||
|
* denen echter Hashes — sonst wäre die Rechnung zu schnell.
|
||||||
|
*/
|
||||||
|
const DUMMY_HASH = "scrypt$131072$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA";
|
||||||
|
|
||||||
const emailSchema = z
|
const emailSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -36,15 +50,23 @@ const emailSchema = z
|
|||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
action: z.literal("login"),
|
action: z.literal("login"),
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, { error: "Bitte gib dein Passwort ein." })
|
||||||
|
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupSchema = z.object({
|
const setupSchema = z.object({
|
||||||
action: z.literal("setup"),
|
action: z.literal("setup"),
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
name: z.string().trim().max(120).optional(),
|
name: z.string().trim().max(120).optional(),
|
||||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(MIN_PASSWORD_LENGTH, {
|
||||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||||
|
})
|
||||||
|
.max(MAX_PASSWORD_LENGTH, {
|
||||||
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,11 +74,17 @@ const logoutSchema = z.object({ action: z.literal("logout") });
|
|||||||
|
|
||||||
const changePasswordSchema = z.object({
|
const changePasswordSchema = z.object({
|
||||||
action: z.literal("change-password"),
|
action: z.literal("change-password"),
|
||||||
currentPassword: z.string().min(1, {
|
currentPassword: z
|
||||||
error: "Bitte gib dein bisheriges Passwort ein.",
|
.string()
|
||||||
}),
|
.min(1, { error: "Bitte gib dein bisheriges Passwort ein." })
|
||||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH, {
|
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(MIN_PASSWORD_LENGTH, {
|
||||||
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
|
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||||
|
})
|
||||||
|
.max(MAX_PASSWORD_LENGTH, {
|
||||||
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +145,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
if (parsed.data.action === "setup") {
|
if (parsed.data.action === "setup") {
|
||||||
return await handleSetup(parsed.data);
|
return await handleSetup(parsed.data);
|
||||||
}
|
}
|
||||||
return await handleLogin(parsed.data);
|
return await handleLogin(parsed.data, requestOrigin(request.headers));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -140,6 +168,15 @@ async function handleChangePassword(
|
|||||||
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konten aus dem externen Verzeichnis haben hier kein Passwort — das
|
||||||
|
// wird beim Anbieter gewechselt, nicht hier.
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
||||||
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
||||||
}
|
}
|
||||||
@@ -179,14 +216,31 @@ async function handleSetup(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
// Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt.
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
|
||||||
|
// Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und
|
||||||
|
// dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann
|
||||||
|
// gäbe es zwei Administratoren, von denen einer ungebeten ist.
|
||||||
|
const user = await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
if ((await tx.user.count()) > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: input.email,
|
email: input.email,
|
||||||
name: input.name && input.name.length > 0 ? input.name : null,
|
name: input.name && input.name.length > 0 ? input.name : null,
|
||||||
passwordHash: await hashPassword(input.password),
|
passwordHash,
|
||||||
isInstanceAdmin: true,
|
isInstanceAdmin: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: "Serializable" },
|
||||||
|
);
|
||||||
|
|
||||||
await createSession(user.id);
|
await createSession(user.id);
|
||||||
return Response.json({ ok: true, email: user.email }, { status: 201 });
|
return Response.json({ ok: true, email: user.email }, { status: 201 });
|
||||||
@@ -194,7 +248,18 @@ async function handleSetup(
|
|||||||
|
|
||||||
async function handleLogin(
|
async function handleLogin(
|
||||||
input: z.infer<typeof loginSchema>,
|
input: z.infer<typeof loginSchema>,
|
||||||
|
absender: string,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
|
||||||
|
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
|
||||||
|
const wartezeit = tooManyAttempts(`login:${absender}`);
|
||||||
|
if (wartezeit !== null) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { email: input.email } });
|
const user = await prisma.user.findUnique({ where: { email: input.email } });
|
||||||
|
|
||||||
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
|
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
|
||||||
@@ -206,17 +271,10 @@ async function handleLogin(
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
|
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
|
||||||
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA");
|
await verifyPassword(input.password, DUMMY_HASH);
|
||||||
throw invalid;
|
throw invalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isActive) {
|
|
||||||
throw new QrRequestError(
|
|
||||||
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||||
const minutes = Math.ceil(
|
const minutes = Math.ceil(
|
||||||
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
||||||
@@ -227,6 +285,14 @@ async function handleLogin(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konten aus dem Verzeichnis haben hier kein Passwort. Bewusst dieselbe
|
||||||
|
// Meldung wie bei falschen Zugangsdaten: Ein eigener Text würde verraten,
|
||||||
|
// dass es diese Adresse gibt. Der Knopf für den Anbieter steht daneben.
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
await verifyPassword(input.password, DUMMY_HASH);
|
||||||
|
throw invalid;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||||
const failedLogins = user.failedLogins + 1;
|
const failedLogins = user.failedLogins + 1;
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
@@ -242,11 +308,32 @@ async function handleLogin(
|
|||||||
throw invalid;
|
throw invalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erst nach geprüftem Passwort: Wer bis hierher kommt, hat das Konto
|
||||||
|
// nachweislich in der Hand — ihm darf man sagen, warum es nicht weitergeht.
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Hash stammt womöglich aus einer Zeit mit schwächeren Parametern.
|
||||||
|
// Jetzt ist der einzige Moment, in dem das Klartextpasswort vorliegt.
|
||||||
|
const passwordHash = needsRehash(user.passwordHash)
|
||||||
|
? await hashPassword(input.password)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
|
data: {
|
||||||
|
failedLogins: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
...(passwordHash ? { passwordHash } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearAttempts(`login:${absender}`);
|
||||||
await createSession(user.id);
|
await createSession(user.id);
|
||||||
return Response.json({ ok: true, email: user.email });
|
return Response.json({ ok: true, email: user.email });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export async function GET(
|
|||||||
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
|
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
|
||||||
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
|
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
|
||||||
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
|
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
|
||||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
"Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
|
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
|
||||||
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
|
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
|
|||||||
import { getActor } from "@/lib/auth/actor";
|
import { getActor } from "@/lib/auth/actor";
|
||||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||||
import { requirePermission } from "@/lib/auth/policy";
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { errorResponse, jsonError } from "@/lib/http";
|
import { assertSameOrigin, errorResponse, jsonError } from "@/lib/http";
|
||||||
import {
|
import {
|
||||||
cleanupOrphanedUploads,
|
cleanupOrphanedUploads,
|
||||||
isUploadKind,
|
isUploadKind,
|
||||||
@@ -22,6 +22,10 @@ export const dynamic = "force-dynamic";
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest): Promise<Response> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
// Diese Route liegt außerhalb des Proxys (siehe src/proxy.ts) und prüft
|
||||||
|
// die Herkunft deshalb selbst.
|
||||||
|
assertSameOrigin(request.headers);
|
||||||
|
|
||||||
const actor = await getActor();
|
const actor = await getActor();
|
||||||
|
|
||||||
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
|
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
{domain.hostname}
|
{domain.hostname}
|
||||||
</h2>
|
</h2>
|
||||||
{domain.hostname === baseUrlHostname ? (
|
{domain.hostname === baseUrlHostname ? (
|
||||||
<Badge tone="popular">aus BASE_URL</Badge>
|
<Badge tone="accent">aus BASE_URL</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{!domain.isActive ? (
|
{!domain.isActive ? (
|
||||||
<Badge tone="soon">abgeschaltet</Badge>
|
<Badge tone="soon">abgeschaltet</Badge>
|
||||||
|
|||||||
@@ -236,12 +236,14 @@ export function Badge({
|
|||||||
tone = "neutral",
|
tone = "neutral",
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
tone?: "neutral" | "popular" | "soon" | "success";
|
tone?: "neutral" | "accent" | "soon" | "success";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const tones: Record<string, string> = {
|
const tones: Record<string, string> = {
|
||||||
neutral: "bg-slate-100 text-slate-600",
|
neutral: "bg-slate-100 text-slate-600",
|
||||||
popular: "bg-indigo-100 text-indigo-700",
|
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
|
||||||
|
// „Beliebt" auf der Startseite gedacht war.
|
||||||
|
accent: "bg-indigo-100 text-indigo-700",
|
||||||
soon: "bg-slate-100 text-slate-500",
|
soon: "bg-slate-100 text-slate-500",
|
||||||
success: "bg-emerald-100 text-emerald-700",
|
success: "bg-emerald-100 text-emerald-700",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function UserAdmin({
|
|||||||
{user.name?.trim() || user.email}
|
{user.name?.trim() || user.email}
|
||||||
</span>
|
</span>
|
||||||
{user.isInstanceAdmin ? (
|
{user.isInstanceAdmin ? (
|
||||||
<Badge tone="popular">Administrator</Badge>
|
<Badge tone="accent">Administrator</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{!user.isActive ? <Badge tone="soon">deaktiviert</Badge> : null}
|
{!user.isActive ? <Badge tone="soon">deaktiviert</Badge> : null}
|
||||||
{user.isLocked ? (
|
{user.isLocked ? (
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
|
||||||
|
import { getBaseUrl } from "@/lib/env";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung über einen externen Anbieter (OpenID Connect) — gedacht und
|
||||||
|
* getestet für Authentik, aber nicht darauf festgelegt: Alles Nötige kommt
|
||||||
|
* aus dem Discovery-Dokument des Anbieters.
|
||||||
|
*
|
||||||
|
* Bewusst begrenzt auf die Frage „wer bist du?“. Wer in welcher Gruppe welche
|
||||||
|
* Rolle hat, bleibt Sache der Verwaltung dieser Anwendung — die Rollen hier
|
||||||
|
* bedeuten etwas anderes als die Gruppen im Verzeichnis, und eine automatische
|
||||||
|
* Übersetzung wäre eine Quelle stiller Überraschungen. Einzige Ausnahme ist
|
||||||
|
* die Instanz-Administration, die sich über `OIDC_ADMIN_GROUP` an eine Gruppe
|
||||||
|
* beim Anbieter hängen lässt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OidcConfig = {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
/** Anzeigename des Knopfs auf der Anmeldeseite. */
|
||||||
|
buttonLabel: string;
|
||||||
|
/** Gruppe beim Anbieter, die zur Instanz-Administration berechtigt. */
|
||||||
|
adminGroup: string | null;
|
||||||
|
/** Name des Anspruchs, in dem die Gruppen stehen. */
|
||||||
|
groupsClaim: string;
|
||||||
|
/** Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen. */
|
||||||
|
defaultRole: "ADMIN" | "EDITOR" | "VIEWER";
|
||||||
|
/** Ist die Anmeldung mit Passwort daneben noch erlaubt? */
|
||||||
|
allowLocalLogin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function env(name: string): string {
|
||||||
|
return process.env[name]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie, der State, Nonce und PKCE-Geheimnis über die Weiterleitung zum
|
||||||
|
* Anbieter trägt. Lebt nur für die Dauer des Anmeldevorgangs.
|
||||||
|
*/
|
||||||
|
export const OIDC_FLOW_COOKIE = "qrgen_oidc_flow";
|
||||||
|
export const OIDC_FLOW_MINUTES = 10;
|
||||||
|
|
||||||
|
export function isOidcEnabled(): boolean {
|
||||||
|
return env("OIDC_ENABLED").toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Konfiguration. Wirft mit einer Meldung, die sagt, was fehlt —
|
||||||
|
* eine halb eingerichtete Anmeldung soll nicht als „irgendwas ging schief“
|
||||||
|
* enden.
|
||||||
|
*/
|
||||||
|
export function getOidcConfig(): OidcConfig {
|
||||||
|
const issuer = env("OIDC_ISSUER").replace(/\/+$/, "");
|
||||||
|
const clientId = env("OIDC_CLIENT_ID");
|
||||||
|
const clientSecret = env("OIDC_CLIENT_SECRET");
|
||||||
|
|
||||||
|
const fehlend = [
|
||||||
|
!issuer && "OIDC_ISSUER",
|
||||||
|
!clientId && "OIDC_CLIENT_ID",
|
||||||
|
!clientSecret && "OIDC_CLIENT_SECRET",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (fehlend.length > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet. Es fehlt: ${fehlend.join(", ")}.`,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = env("OIDC_DEFAULT_ROLE").toUpperCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
scopes: env("OIDC_SCOPES") || "openid email profile",
|
||||||
|
buttonLabel: env("OIDC_BUTTON_LABEL") || "Mit Authentik anmelden",
|
||||||
|
adminGroup: env("OIDC_ADMIN_GROUP") || null,
|
||||||
|
groupsClaim: env("OIDC_GROUPS_CLAIM") || "groups",
|
||||||
|
defaultRole:
|
||||||
|
rolle === "ADMIN" || rolle === "EDITOR" || rolle === "VIEWER"
|
||||||
|
? rolle
|
||||||
|
: "VIEWER",
|
||||||
|
allowLocalLogin: env("OIDC_ONLY").toLowerCase() !== "true",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse, an die der Anbieter nach der Anmeldung zurückschickt. */
|
||||||
|
export function redirectUri(): string {
|
||||||
|
return `${getBaseUrl()}/api/auth/oidc/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Discovery = {
|
||||||
|
issuer: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
jwks_uri: string;
|
||||||
|
userinfo_endpoint?: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Discovery-Dokument ändert sich praktisch nie, der Abruf kostet aber bei
|
||||||
|
* jeder Anmeldung eine Runde. Deshalb im Speicher halten — eine Stunde ist
|
||||||
|
* kurz genug, um einen Schlüsselwechsel mitzubekommen.
|
||||||
|
*/
|
||||||
|
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
|
||||||
|
let discoveryCache: { url: string; bis: number; wert: Discovery } | null = null;
|
||||||
|
|
||||||
|
export async function discover(issuer: string): Promise<Discovery> {
|
||||||
|
const url = `${issuer}/.well-known/openid-configuration`;
|
||||||
|
const jetzt = Date.now();
|
||||||
|
|
||||||
|
if (discoveryCache && discoveryCache.url === url && discoveryCache.bis > jetzt) {
|
||||||
|
return discoveryCache.wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der Anmelde-Anbieter ist nicht erreichbar (${issuer}). Läuft er, und stimmt OIDC_ISSUER?`,
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der Anmelde-Anbieter liefert unter ${url} den Status ${response.status}. Stimmt OIDC_ISSUER?`,
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wert = (await response.json()) as Discovery;
|
||||||
|
if (!wert.authorization_endpoint || !wert.token_endpoint || !wert.jwks_uri) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Anmelde-Anbieter liefert ein unvollständiges Discovery-Dokument.",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryCache = { url, bis: jetzt + DISCOVERY_TTL_MS, wert };
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JWKS je Adresse zwischenspeichern — `jose` erneuert bei Bedarf selbst. */
|
||||||
|
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||||
|
|
||||||
|
function jwks(uri: string) {
|
||||||
|
const vorhanden = jwksCache.get(uri);
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
const neu = createRemoteJWKSet(new URL(uri));
|
||||||
|
jwksCache.set(uri, neu);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OidcIdentity = {
|
||||||
|
subject: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
name: string | null;
|
||||||
|
groups: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function readGroups(payload: JWTPayload, claim: string): string[] {
|
||||||
|
const wert = payload[claim];
|
||||||
|
if (Array.isArray(wert)) {
|
||||||
|
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
|
||||||
|
}
|
||||||
|
// Manche Anbieter liefern eine durch Leerzeichen getrennte Liste.
|
||||||
|
if (typeof wert === "string") return wert.split(/\s+/).filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tauscht den Code gegen Token und prüft das ID-Token.
|
||||||
|
*
|
||||||
|
* Geprüft wird die Signatur gegen den JWKS des Anbieters sowie Aussteller,
|
||||||
|
* Empfänger und die Nonce. Letztere schließt aus, dass ein anderswo
|
||||||
|
* erbeutetes Token hier eingereicht wird.
|
||||||
|
*/
|
||||||
|
export async function exchangeCode(options: {
|
||||||
|
config: OidcConfig;
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
nonce: string;
|
||||||
|
}): Promise<OidcIdentity> {
|
||||||
|
const { config, code, codeVerifier, nonce } = options;
|
||||||
|
const meta = await discover(config.issuer);
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri(),
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(meta.token_endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Der Text des Anbieters ist für Betreiber oft hilfreich, gehört aber
|
||||||
|
// nicht ungefiltert in den Browser.
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
console.error("OIDC: Token-Tausch fehlgeschlagen:", response.status, text);
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung beim Anbieter ist fehlgeschlagen. Stimmen Client-ID und Geheimnis?",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = (await response.json()) as { id_token?: string };
|
||||||
|
if (!token.id_token) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Anbieter hat kein ID-Token geliefert. Ist der Scope „openid“ erlaubt?",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: JWTPayload;
|
||||||
|
try {
|
||||||
|
({ payload } = await jwtVerify(token.id_token, jwks(meta.jwks_uri), {
|
||||||
|
issuer: meta.issuer || config.issuer,
|
||||||
|
audience: config.clientId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OIDC: ID-Token nicht gültig:", error);
|
||||||
|
throw new QrRequestError("Das Anmelde-Token des Anbieters ist ungültig.", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.nonce !== nonce) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung passt nicht zu dieser Sitzung. Bitte versuche es erneut.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = typeof payload.sub === "string" ? payload.sub : "";
|
||||||
|
const email =
|
||||||
|
typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
throw new QrRequestError("Der Anbieter hat keine Kennung geliefert.", 502);
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Anbieter hat keine E-Mail-Adresse geliefert. Bitte den Scope „email“ freigeben und das Attribut mitgeben.",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name =
|
||||||
|
typeof payload.name === "string" && payload.name.trim().length > 0
|
||||||
|
? payload.name.trim()
|
||||||
|
: typeof payload.preferred_username === "string"
|
||||||
|
? payload.preferred_username.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
email,
|
||||||
|
emailVerified: payload.email_verified !== false,
|
||||||
|
name,
|
||||||
|
groups: readGroups(payload, config.groupsClaim),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,9 +34,16 @@ function scryptAsync(
|
|||||||
*
|
*
|
||||||
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
|
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
|
||||||
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
|
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
|
||||||
* entwerten.
|
* entwerten — `needsRehash()` erkennt dann veraltete Hashes.
|
||||||
|
*
|
||||||
|
* N = 2^17, r = 8, p = 1 entspricht der Empfehlung des OWASP Password Storage
|
||||||
|
* Cheat Sheet (Stand 2026). Das kostet rund 128 MiB Speicher je Berechnung;
|
||||||
|
* `maxmem` muss darüber liegen, sonst weigert sich Node.
|
||||||
*/
|
*/
|
||||||
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
|
const PARAMS = { N: 131072, r: 8, p: 1, keyLength: 64, saltLength: 16 };
|
||||||
|
|
||||||
|
/** 128 · N · r plus Reserve. */
|
||||||
|
const MAXMEM = 192 * 1024 * 1024;
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
const salt = randomBytes(PARAMS.saltLength);
|
const salt = randomBytes(PARAMS.saltLength);
|
||||||
@@ -48,8 +55,8 @@ export async function hashPassword(password: string): Promise<string> {
|
|||||||
N: PARAMS.N,
|
N: PARAMS.N,
|
||||||
r: PARAMS.r,
|
r: PARAMS.r,
|
||||||
p: PARAMS.p,
|
p: PARAMS.p,
|
||||||
// Der Standardwert reicht für N = 32768 nicht aus.
|
// Der Standardwert reicht für dieses N nicht aus.
|
||||||
maxmem: 256 * 1024 * 1024,
|
maxmem: MAXMEM,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -92,7 +99,7 @@ export async function verifyPassword(
|
|||||||
N,
|
N,
|
||||||
r,
|
r,
|
||||||
p,
|
p,
|
||||||
maxmem: 256 * 1024 * 1024,
|
maxmem: MAXMEM,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -103,9 +110,33 @@ export async function verifyPassword(
|
|||||||
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Hashes, die mit schwächeren Parametern erzeugt wurden.
|
||||||
|
*
|
||||||
|
* Beim nächsten erfolgreichen Anmelden wird das Passwort dann neu gehasht —
|
||||||
|
* anders kommt man nicht an das Klartextpasswort, um den Hash zu erneuern.
|
||||||
|
*/
|
||||||
|
export function needsRehash(stored: string): boolean {
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
|
||||||
|
return (
|
||||||
|
Number(parts[1]) < PARAMS.N ||
|
||||||
|
Number(parts[2]) < PARAMS.r ||
|
||||||
|
Number(parts[3]) < PARAMS.p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Mindestanforderungen an ein Passwort. */
|
/** Mindestanforderungen an ein Passwort. */
|
||||||
export const MIN_PASSWORD_LENGTH = 10;
|
export const MIN_PASSWORD_LENGTH = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obergrenze — nicht aus Sicherheitsgründen, sondern gegen Überlastung:
|
||||||
|
* scrypt über ein mehrere Megabyte langes „Passwort" würde den Server
|
||||||
|
* beschäftigen, und das an einer Stelle, die ohne Anmeldung erreichbar ist.
|
||||||
|
* 128 Zeichen sind weit jenseits jeder sinnvollen Passphrase.
|
||||||
|
*/
|
||||||
|
export const MAX_PASSWORD_LENGTH = 128;
|
||||||
|
|
||||||
export function describePasswordRules(): string {
|
export function describePasswordRules(): string {
|
||||||
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
|
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export const SESSION_COOKIE = "qrgen_session";
|
export const SESSION_COOKIE = "qrgen_session";
|
||||||
|
|
||||||
/** Wie lange eine Sitzung ohne Aktivität gültig bleibt. */
|
/**
|
||||||
const SESSION_DAYS = 30;
|
* Wie lange eine Sitzung ohne Aktivität gültig bleibt.
|
||||||
|
*
|
||||||
|
* Das OWASP Session Management Cheat Sheet nennt für Anwendungen mit
|
||||||
|
* geringem Risiko 15–30 Minuten Leerlauf und 4–8 Stunden absolut. Das ist auf
|
||||||
|
* öffentlich erreichbare Anwendungen mit vielen Nutzern gemünzt; für ein
|
||||||
|
* internes Werkzeug, an dem man täglich arbeitet, wäre es eine Zumutung.
|
||||||
|
* Deshalb großzügigere Vorgaben — aber beide über die Umgebung einstellbar,
|
||||||
|
* damit strengere Werte eine Änderung in der `.env` sind und kein Umbau.
|
||||||
|
*/
|
||||||
|
function tage(name: string, voreinstellung: number): number {
|
||||||
|
const roh = Number.parseInt(process.env[name] ?? "", 10);
|
||||||
|
return Number.isFinite(roh) && roh > 0 && roh <= 365 ? roh : voreinstellung;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PRO_TAG = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Ohne Aktivität läuft die Sitzung nach so vielen Tagen ab. */
|
||||||
|
function idleDays(): number {
|
||||||
|
return tage("SESSION_IDLE_DAYS", 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Und spätestens nach so vielen Tagen ab dem Anmelden — unabhängig davon,
|
||||||
|
* wie oft sie benutzt wurde. Ohne diese Grenze könnte eine einmal gestohlene
|
||||||
|
* Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
|
||||||
|
*/
|
||||||
|
function absoluteDays(): number {
|
||||||
|
return tage("SESSION_MAX_DAYS", 90);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
|
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
|
||||||
@@ -19,7 +47,25 @@ function hashToken(token: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expiryFromNow(): Date {
|
function expiryFromNow(): Date {
|
||||||
return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
return new Date(Date.now() + idleDays() * MS_PRO_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Läuft die Verbindung über HTTPS?
|
||||||
|
*
|
||||||
|
* Neben der `BASE_URL` zählt auch `X-Forwarded-Proto`: Hinter einem Proxy,
|
||||||
|
* der TLS beendet, spricht die Anwendung selbst nur HTTP — der Cookie muss
|
||||||
|
* trotzdem als `Secure` gesetzt werden.
|
||||||
|
*/
|
||||||
|
async function isHttps(): Promise<boolean> {
|
||||||
|
if ((process.env.BASE_URL ?? "").startsWith("https://")) return true;
|
||||||
|
try {
|
||||||
|
const kopf = await headers();
|
||||||
|
return (kopf.get("x-forwarded-proto") ?? "").split(",")[0].trim() === "https";
|
||||||
|
} catch {
|
||||||
|
// Außerhalb einer Anfrage — dann bleibt nur die BASE_URL.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionUser = {
|
export type SessionUser = {
|
||||||
@@ -32,10 +78,18 @@ export type SessionUser = {
|
|||||||
|
|
||||||
/** Legt eine Sitzung an und setzt den Cookie. */
|
/** Legt eine Sitzung an und setzt den Cookie. */
|
||||||
export async function createSession(userId: string): Promise<void> {
|
export async function createSession(userId: string): Promise<void> {
|
||||||
|
// 32 Byte aus dem CSPRNG — weit über den 128 Bit, die OWASP mindestens
|
||||||
|
// verlangt.
|
||||||
const token = randomBytes(32).toString("base64url");
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
const jetzt = Date.now();
|
||||||
|
|
||||||
await prisma.session.create({
|
await prisma.session.create({
|
||||||
data: { tokenHash: hashToken(token), userId, expiresAt: expiryFromNow() },
|
data: {
|
||||||
|
tokenHash: hashToken(token),
|
||||||
|
userId,
|
||||||
|
expiresAt: expiryFromNow(),
|
||||||
|
absoluteExpiresAt: new Date(jetzt + absoluteDays() * MS_PRO_TAG),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = await cookies();
|
const store = await cookies();
|
||||||
@@ -45,7 +99,7 @@ export async function createSession(userId: string): Promise<void> {
|
|||||||
path: "/",
|
path: "/",
|
||||||
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
|
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
|
||||||
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
|
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
|
||||||
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
secure: await isHttps(),
|
||||||
expires: expiryFromNow(),
|
expires: expiryFromNow(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,7 +143,11 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
|
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
if (session.expiresAt.getTime() < Date.now()) {
|
// Abgelaufen — durch Leerlauf oder weil die absolute Grenze erreicht ist.
|
||||||
|
if (
|
||||||
|
session.expiresAt.getTime() < Date.now() ||
|
||||||
|
session.absoluteExpiresAt.getTime() < Date.now()
|
||||||
|
) {
|
||||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -100,12 +158,26 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
|
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
|
||||||
const anHourAgo = Date.now() - 60 * 60 * 1000;
|
const anHourAgo = Date.now() - 60 * 60 * 1000;
|
||||||
if (session.lastSeenAt.getTime() < anHourAgo) {
|
if (session.lastSeenAt.getTime() < anHourAgo) {
|
||||||
|
// Nie über die absolute Grenze hinaus verlängern.
|
||||||
|
const verlaengert = expiryFromNow();
|
||||||
await prisma.session
|
await prisma.session
|
||||||
.update({
|
.update({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: { lastSeenAt: new Date(), expiresAt: expiryFromNow() },
|
data: {
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
expiresAt:
|
||||||
|
verlaengert < session.absoluteExpiresAt
|
||||||
|
? verlaengert
|
||||||
|
: session.absoluteExpiresAt,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
// Beiläufig aufräumen — abgelaufene Sitzungen sind harmlos, sollen sich
|
||||||
|
// aber nicht endlos ansammeln.
|
||||||
|
void prisma.session
|
||||||
|
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grobe Bremse gegen automatisiertes Durchprobieren.
|
||||||
|
*
|
||||||
|
* Ergänzt die Kontosperre, ersetzt sie aber nicht: Die Sperre schützt ein
|
||||||
|
* einzelnes Konto, diese Bremse den Server vor jemandem, der viele Konten
|
||||||
|
* gleichzeitig durchprobiert — dabei löst die Sperre nämlich nie aus.
|
||||||
|
*
|
||||||
|
* Im Arbeitsspeicher und damit pro Container. Bei einer selbst gehosteten
|
||||||
|
* Anwendung dieser Größe ist das angemessen; ein verteilter Zähler bräuchte
|
||||||
|
* einen weiteren Dienst. Die Adresse dient nur als Schlüssel und wird
|
||||||
|
* nirgends gespeichert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Eintrag = { anzahl: number; bis: number };
|
||||||
|
|
||||||
|
const zaehler = new Map<string, Eintrag>();
|
||||||
|
|
||||||
|
/** Ab wie vielen Versuchen je Zeitfenster abgeblockt wird. */
|
||||||
|
export const LOGIN_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const FENSTER_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt einen Versuch und meldet, ob das Fenster überschritten ist.
|
||||||
|
* Gibt die verbleibenden Sekunden zurück, sonst `null`.
|
||||||
|
*/
|
||||||
|
export function tooManyAttempts(schluessel: string): number | null {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
const eintrag = zaehler.get(schluessel);
|
||||||
|
|
||||||
|
if (!eintrag || eintrag.bis < jetzt) {
|
||||||
|
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
|
||||||
|
|
||||||
|
// Gelegentlich ausmisten, damit die Tabelle nicht unbegrenzt wächst.
|
||||||
|
if (zaehler.size > 5000) {
|
||||||
|
for (const [key, wert] of zaehler) {
|
||||||
|
if (wert.bis < jetzt) zaehler.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eintrag.anzahl += 1;
|
||||||
|
if (eintrag.anzahl > LOGIN_ATTEMPTS_PER_WINDOW) {
|
||||||
|
return Math.ceil((eintrag.bis - jetzt) / 1000);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach erfolgreicher Anmeldung ist der Zähler gegenstandslos. */
|
||||||
|
export function clearAttempts(schluessel: string): void {
|
||||||
|
zaehler.delete(schluessel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absender einer Anfrage, so gut es hinter einem Proxy geht.
|
||||||
|
*
|
||||||
|
* `X-Forwarded-For` ist fälschbar, wenn kein Proxy davorsteht — deshalb ist
|
||||||
|
* das hier eine Bremse und keine Sperre: Wer den Wert fälscht, umgeht sie,
|
||||||
|
* aber die Kontosperre greift weiterhin.
|
||||||
|
*/
|
||||||
|
export function requestOrigin(headers: Headers): string {
|
||||||
|
return (
|
||||||
|
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
headers.get("x-real-ip") ||
|
||||||
|
"unbekannt"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,44 @@
|
|||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommt diese Anfrage von der eigenen Oberfläche?
|
||||||
|
*
|
||||||
|
* Zweite Verteidigungslinie neben `SameSite=Lax` am Sitzungs-Cookie. Das
|
||||||
|
* OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht
|
||||||
|
* genügt — es wirkt auf der Ebene der registrierbaren Domain, nicht des
|
||||||
|
* Ursprungs.
|
||||||
|
*
|
||||||
|
* Verglichen wird gegen den Host der Anfrage, nicht gegen `BASE_URL`: Die
|
||||||
|
* Anwendung ist absichtlich unter mehreren Domains erreichbar.
|
||||||
|
*
|
||||||
|
* Fehlt der `Origin`-Header, gilt die Anfrage als eigen: Browser setzen ihn
|
||||||
|
* bei fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar
|
||||||
|
* nicht. Ein Abweisen träfe nur die Falschen.
|
||||||
|
*/
|
||||||
|
export function isSameOrigin(headers: Headers): boolean {
|
||||||
|
const origin = headers.get("origin");
|
||||||
|
if (!origin) return true;
|
||||||
|
|
||||||
|
let herkunft: URL;
|
||||||
|
try {
|
||||||
|
herkunft = new URL(origin);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||||
|
return Boolean(host) && herkunft.host === host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSameOrigin(headers: Headers): void {
|
||||||
|
if (!isSameOrigin(headers)) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function jsonError(message: string, status = 400): Response {
|
export function jsonError(message: string, status = 400): Response {
|
||||||
return Response.json({ error: message }, { status });
|
return Response.json({ error: message }, { status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { isSameOrigin } from "@/lib/http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Läuft vor jeder Anfrage: setzt Schutzheader und weist schreibende Zugriffe
|
||||||
|
* von fremden Seiten ab.
|
||||||
|
*
|
||||||
|
* Heißt `proxy.ts`, weil Next.js die Datei `middleware.ts` in Version 16
|
||||||
|
* umbenannt hat — siehe node_modules/next/dist/docs/01-app/03-api-reference/
|
||||||
|
* 03-file-conventions/proxy.md.
|
||||||
|
*
|
||||||
|
* ## Warum eine Origin-Prüfung, obwohl der Cookie `SameSite=Lax` ist
|
||||||
|
*
|
||||||
|
* Das OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht
|
||||||
|
* genügt: Es wirkt auf der Ebene der registrierbaren Domain, nicht des
|
||||||
|
* Ursprungs, und bei `Lax` gehen Cookies bei einfachen Navigationen weiterhin
|
||||||
|
* mit. Der Upload-Endpunkt nimmt zudem `multipart/form-data` entgegen — das
|
||||||
|
* kann ein HTML-Formular von einer fremden Seite abschicken.
|
||||||
|
*
|
||||||
|
* Fehlt der `Origin`-Header ganz, wird durchgelassen: Browser setzen ihn bei
|
||||||
|
* fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
|
||||||
|
* Ein Abweisen träfe also nur die Falschen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCHREIBEND = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schutzheader.
|
||||||
|
*
|
||||||
|
* Bewusst ohne vollständige `Content-Security-Policy` für Skripte: Next.js
|
||||||
|
* bringt eingebettete Skripte mit, dafür bräuchte es Nonces durch die ganze
|
||||||
|
* Anwendung. Eine halbherzige Policy mit `unsafe-inline` sähe nur nach
|
||||||
|
* Schutz aus. Was hier steht, wirkt dagegen sofort.
|
||||||
|
*/
|
||||||
|
const HEADER: Record<string, string> = {
|
||||||
|
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
|
||||||
|
// einer harmlos aussehenden Seite verstecken und fernsteuern.
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
// Der Browser soll den Inhaltstyp nicht raten.
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
// Keine vollständigen Adressen an fremde Seiten weitergeben — die
|
||||||
|
// Kurz-URLs stünden sonst in fremden Protokollen.
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
// Nichts davon braucht die Anwendung.
|
||||||
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
if (SCHREIBEND.has(request.method) && !isSameOrigin(request.headers)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next();
|
||||||
|
for (const [name, wert] of Object.entries(HEADER)) {
|
||||||
|
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
|
||||||
|
// Dateien —, sollen sie behalten.
|
||||||
|
if (!response.headers.has(name)) response.headers.set(name, wert);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
/*
|
||||||
|
* Ausgenommen sind statische Dateien und `/api/uploads` — letzteres aus
|
||||||
|
* zwei Gründen:
|
||||||
|
*
|
||||||
|
* 1. Next.js puffert den Anfragekörper, damit ihn Proxy und Route beide
|
||||||
|
* lesen können; jenseits von 10 MB wird abgeschnitten. Eine hochgeladene
|
||||||
|
* Datei an der Größengrenze käme dann unvollständig an.
|
||||||
|
* 2. Die Auslieferung setzt eigene Kopfzeilen — eine Sandbox-Policy, die
|
||||||
|
* schärfer ist als die hier.
|
||||||
|
*
|
||||||
|
* Die Herkunftsprüfung entfällt dadurch nicht: Die Upload-Route ruft
|
||||||
|
* `assertSameOrigin` selbst auf.
|
||||||
|
*/
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/uploads).*)"],
|
||||||
|
};
|
||||||