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>
259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
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;
|
||
}
|
||
}
|