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:
2026-07-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent 01d42662ca
commit 13bc5aa0b8
11 changed files with 1183 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import type { Credential, User } from "@prisma/client";
import { APP_NAME, WEBAUTHN_AVAILABLE, WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Passkeys (WebAuthn).
*
* Die Challenge liegt in der Datenbank statt in einem Cookie: so laesst sie
* sich nach einmaligem Gebrauch sicher loeschen und kann nicht erneut
* eingespielt werden. Ausserdem funktioniert das ueber mehrere Prozesse.
*
* rpID und Origin kommen aus APP_URL. Passkeys sind an die rpID gebunden –
* wird APP_URL auf einen anderen Hostnamen geaendert, muessen die Passkeys
* neu angelegt werden.
*/
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export class WebAuthnUnavailableError extends Error {
constructor() {
super(
"Passkeys benoetigen einen sicheren Kontext. Bitte APP_URL auf eine " +
"https-Adresse (oder http://localhost) setzen.",
);
this.name = "WebAuthnUnavailableError";
}
}
function assertAvailable() {
if (!WEBAUTHN_AVAILABLE) throw new WebAuthnUnavailableError();
}
async function storeChallenge(
challenge: string,
purpose: "registration" | "authentication",
userId: string | null,
) {
// Abgelaufene Challenges bei der Gelegenheit entfernen.
await prisma.webAuthnChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
await prisma.webAuthnChallenge.create({
data: {
challenge,
purpose,
userId,
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
}
/** Holt die Challenge und loescht sie sofort – jede gilt genau einmal. */
async function consumeChallenge(
challenge: string,
purpose: "registration" | "authentication",
): Promise<{ userId: string | null } | null> {
const stored = await prisma.webAuthnChallenge.findUnique({ where: { challenge } });
if (!stored) return null;
await prisma.webAuthnChallenge.delete({ where: { id: stored.id } }).catch(() => undefined);
if (stored.purpose !== purpose) return null;
if (stored.expiresAt.getTime() <= Date.now()) return null;
return { userId: stored.userId };
}
// ── Registrierung eines neuen Passkeys ───────────────────────
export async function buildRegistrationOptions(
user: Pick<User, "id" | "username" | "displayName">,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
assertAvailable();
const existing = await prisma.credential.findMany({
where: { userId: user.id },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: APP_NAME,
rpID: WEBAUTHN_RP_ID,
userID: new TextEncoder().encode(user.id),
userName: user.username,
userDisplayName: user.displayName ?? user.username,
attestationType: "none",
// Bereits registrierte Passkeys ausschliessen, damit derselbe
// Authenticator nicht doppelt hinterlegt wird.
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId,
transports: credential.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "registration", user.id);
return options;
}
export async function confirmRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string | null,
): Promise<Credential> {
assertAvailable();
const expectedChallenge = response.response.clientDataJSON
? decodeChallenge(response.response.clientDataJSON)
: null;
if (!expectedChallenge) {
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
}
const stored = await consumeChallenge(expectedChallenge, "registration");
if (!stored) {
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
}
if (stored.userId !== userId) {
throw new Error("Die Challenge gehoert zu einem anderen Konto.");
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: WEBAUTHN_ORIGIN,
expectedRPID: WEBAUTHN_RP_ID,
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
return prisma.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name?.trim() || null,
},
});
}
// ── Anmeldung per Passkey ────────────────────────────────────
/**
* Ohne allowCredentials: der Browser bietet alle passenden, auffindbaren
* Passkeys an. Der Benutzername muss also nicht eingegeben werden.
*/
export async function buildAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
assertAvailable();
const options = await generateAuthenticationOptions({
rpID: WEBAUTHN_RP_ID,
userVerification: "preferred",
});
await storeChallenge(options.challenge, "authentication", null);
return options;
}
export interface PasskeyLoginResult {
userId: string;
credentialId: string;
}
export async function confirmAuthentication(
response: AuthenticationResponseJSON,
): Promise<PasskeyLoginResult> {
assertAvailable();
const expectedChallenge = decodeChallenge(response.response.clientDataJSON);
if (!expectedChallenge) {
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
}
const stored = await consumeChallenge(expectedChallenge, "authentication");
if (!stored) {
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
}
const credential = await prisma.credential.findUnique({
where: { credentialId: response.id },
include: { user: true },
});
if (!credential) {
throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
}
if (!credential.user.isActive) {
throw new Error("Das Konto ist deaktiviert.");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: WEBAUTHN_ORIGIN,
expectedRPID: WEBAUTHN_RP_ID,
requireUserVerification: false,
credential: {
id: credential.credentialId,
publicKey: new Uint8Array(credential.publicKey),
counter: credential.counter,
transports: credential.transports as AuthenticatorTransportFuture[],
},
});
if (!verification.verified) {
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
}
// Zaehler fortschreiben – ein Ruecksprung deutet auf einen Klon hin,
// SimpleWebAuthn wirft in dem Fall bereits oben.
await prisma.credential.update({
where: { id: credential.id },
data: {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
},
});
return { userId: credential.userId, credentialId: credential.credentialId };
}
/**
* Liest die Challenge aus clientDataJSON. Die Bibliothek prueft sie zwar
* selbst, wir brauchen sie aber vorher, um den passenden Datenbankeintrag
* zu finden und zu entwerten.
*/
function decodeChallenge(clientDataJSON: string): string | null {
try {
const json = JSON.parse(Buffer.from(clientDataJSON, "base64url").toString("utf8")) as {
challenge?: string;
};
return typeof json.challenge === "string" ? json.challenge : null;
} catch {
return null;
}
}