feat(auth): Anmeldung mit Passwort und Passkey

Kern der Benutzerverwaltung.

Passwoerter:
- scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart
  und ohne zusaetzliches natives Modul
- Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben
  lassen
- fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe
  Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten
  existieren

Passkeys ueber SimpleWebAuthn 13:
- Anmeldung ohne Benutzernamen ueber auffindbare Credentials
- Challenges liegen in der Datenbank und werden beim Einloesen geloescht,
  koennen also nicht erneut eingespielt werden
- rpID und Origin kommen aus APP_URL

Sitzungen:
- Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash
- Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen
  wird - anders als bei einem selbsttragenden Token

Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin,
Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und
strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich
keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank.
Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig,
in jeder Seite und jedem Endpunkt.

config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber
NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben;
so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer
anderen Adresse betreiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-07-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent 1e9f7bd45a
commit 37573962a8
11 changed files with 1183 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import type { AppSettings } from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Einstellungen der Instanz. Es gibt genau eine Zeile mit der id "singleton";
* sie wird beim ersten Zugriff angelegt.
*/
export async function getAppSettings(): Promise<AppSettings> {
return prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", rpName: APP_NAME },
update: {},
});
}
export interface InstanceState {
settings: AppSettings;
userCount: number;
/** Kein Konto vorhanden – die Einrichtung steht noch aus. */
needsSetup: boolean;
/** Darf sich jemand selbst registrieren? */
registrationOpen: boolean;
multiUser: boolean;
}
export async function getInstanceState(): Promise<InstanceState> {
const [settings, userCount] = await Promise.all([
getAppSettings(),
prisma.user.count(),
]);
const multiUser = settings.mode === "MULTI_USER";
return {
settings,
userCount,
needsSetup: userCount === 0,
// Im Einzelbetrieb gibt es grundsaetzlich keine Selbstregistrierung.
registrationOpen: multiUser && settings.allowRegistration,
multiUser,
};
}