Files
nebula/src/lib/auth/webauthn.ts
T
florian.wildeandClaude Opus 5 13bc5aa0b8 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>
2026-07-25 21:30:12 +02:00

259 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}