Anmeldung per Passkey (WebAuthn)
CI / pruefen (pull_request) Canceled after 0s

Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die
Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber
createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die
Sitzungsschicht bleibt unangetastet.

Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht
nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht.

Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly-
Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles,
was liegen bleibt, wenn jemand den Dialog abbricht.

Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse
Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen
Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem
Hinweis statt einen kryptischen Browserfehler zu zeigen.

Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein
Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey.

Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche
Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als
unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als
zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst.

Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen,
abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt,
Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich
hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt
stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre.

Refs #6
This commit is contained in:
Florian Wilde
2026-09-02 07:04:45 +02:00
parent 58a1d177df
commit c0579c6f61
17 changed files with 1381 additions and 6 deletions
+35
View File
@@ -81,6 +81,41 @@ export const env = {
},
},
passkeys: {
/**
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
* kosten nichts, solange niemand einen anlegt.
*/
enabled: bool("ENABLE_PASSKEYS", true),
/**
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
* plötzlich ungültig.
*/
get rpId(): string {
try {
return new URL(env.appUrl).hostname;
} catch {
return "localhost";
}
},
/**
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
*/
get isAvailable(): boolean {
if (!this.enabled) return false;
try {
const url = new URL(env.appUrl);
if (url.protocol === "https:") return true;
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
} catch {
return false;
}
},
},
oidc: {
issuer: optional("OIDC_ISSUER"),
clientId: optional("OIDC_CLIENT_ID"),
+333
View File
@@ -0,0 +1,333 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import { prisma } from "./db";
import { env } from "./env";
/**
* Anmeldung per Passkey (WebAuthn).
*
* Ein Passkey ist der dritte Weg in denselben Trichter: `finishLogin()` prüft
* die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über
* `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die
* Sitzungsschicht weiß nicht, wie jemand hereingekommen ist.
*
* Die Trennung ist Absicht: hier steht die WebAuthn-Prüfung, das Setzen von
* Cookie und Sitzungszeile gehört zur HTTP-Schicht.
*
* Zwei Dinge sind bei einer selbst gehosteten Instanz wichtiger als sonst:
*
* - Die **RP ID** kommt aus `APP_URL`, nicht aus dem `Host`-Header. Hinter
* einem Reverse Proxy hinge sie sonst am falschen Namen — und ein Passkey
* ist fest an diesen Namen gebunden.
* - Ein **Domainwechsel entwertet alle Passkeys.** Das steht in der README;
* hier lässt sich nichts dagegen tun, es ist Teil des Verfahrens.
*/
export class PasskeyError extends Error {}
const CHALLENGE_COOKIE = "flimmer_passkey_challenge";
/** Kurz genug, dass eine liegengebliebene Challenge nicht ewig gilt. */
const CHALLENGE_SECONDS = 300;
function rpId(): string {
return env.passkeys.rpId;
}
/** Der Ursprung, den der Browser in der Antwort mitschickt. */
function expectedOrigin(): string {
return new URL(env.appUrl).origin;
}
// ---------------------------------------------------------------------------
// Challenge — signiertes Cookie statt Tabelle
// ---------------------------------------------------------------------------
/**
* Die Challenge muss den Weg vom ersten zum zweiten Schritt überstehen.
*
* Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn
* jemand den Dialog abbricht. Ein signiertes, kurzlebiges Cookie kommt ohne
* beides aus: Fälschen geht nicht, und nach fünf Minuten ist es ohnehin weg.
*/
function sign(payload: string): string {
return createHmac("sha256", env.authSecret).update(payload).digest("base64url");
}
async function storeChallenge(challenge: string, scope: "register" | "login") {
const payload = `${scope}.${challenge}.${Date.now() + CHALLENGE_SECONDS * 1000}`;
const cookieStore = await cookies();
cookieStore.set(CHALLENGE_COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: CHALLENGE_SECONDS,
});
}
async function takeChallenge(scope: "register" | "login"): Promise<string> {
const cookieStore = await cookies();
const raw = cookieStore.get(CHALLENGE_COOKIE)?.value;
// Einmalgebrauch: gleich löschen, egal ob die Prüfung gelingt.
cookieStore.delete(CHALLENGE_COOKIE);
if (!raw) throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
const teile = raw.split(".");
if (teile.length !== 4) throw new PasskeyError("Die Anfrage ist ungültig.");
const [gespeicherterScope, challenge, ablauf, signatur] = teile as [string, string, string, string];
const payload = `${gespeicherterScope}.${challenge}.${ablauf}`;
const erwartet = Buffer.from(sign(payload));
const gegeben = Buffer.from(signatur);
if (erwartet.length !== gegeben.length || !timingSafeEqual(erwartet, gegeben)) {
throw new PasskeyError("Die Anfrage ist ungültig.");
}
if (gespeicherterScope !== scope) throw new PasskeyError("Die Anfrage passt nicht.");
if (Number(ablauf) < Date.now()) {
throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
}
return challenge;
}
function requireAvailable() {
if (!env.passkeys.enabled) throw new PasskeyError("Passkeys sind auf dieser Instanz abgeschaltet.");
if (!env.passkeys.isAvailable) {
throw new PasskeyError(
"Passkeys brauchen HTTPS (oder localhost). Diese Instanz läuft über eine unverschlüsselte Verbindung.",
);
}
}
// ---------------------------------------------------------------------------
// Anlegen
// ---------------------------------------------------------------------------
export async function startRegistration(userId: string) {
requireAvailable();
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { id: true, email: true, name: true },
});
const vorhandene = await prisma.passkey.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: env.appName,
rpID: rpId(),
userName: user.email,
userDisplayName: user.name,
// Die Kennung muss über Umbenennungen hinweg stabil bleiben — deshalb die
// interne ID und nicht die E-Mail-Adresse.
userID: new TextEncoder().encode(user.id),
attestationType: "none",
// Schon vorhandene Passkeys ausschließen, damit dasselbe Gerät nicht
// zweimal registriert wird.
excludeCredentials: vorhandene.map((eintrag) => ({
id: eintrag.credentialId,
transports: eintrag.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
// Auffindbar, damit die Anmeldung ohne vorherige E-Mail-Eingabe geht.
residentKey: "required",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "register");
return options;
}
export async function finishRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string,
) {
requireAvailable();
const challenge = await takeChallenge("register");
const geprueft = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
});
if (!geprueft.verified || !geprueft.registrationInfo) {
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = geprueft.registrationInfo;
const schonVergeben = await prisma.passkey.findUnique({
where: { credentialId: credential.id },
select: { userId: true },
});
if (schonVergeben) {
throw new PasskeyError("Dieser Passkey ist bereits hinterlegt.");
}
return prisma.passkey.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: BigInt(credential.counter),
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name.trim().slice(0, 60) || "Passkey",
},
select: { id: true, name: true, deviceType: true, backedUp: true },
});
}
// ---------------------------------------------------------------------------
// Anmelden
// ---------------------------------------------------------------------------
export async function startLogin() {
requireAvailable();
// Ohne `allowCredentials`: der Browser sucht selbst einen passenden Passkey.
// Genau das erlaubt die Anmeldung ohne vorherige E-Mail-Eingabe — und
// verrät nebenbei nicht, welche Konten es auf dieser Instanz gibt.
const options = await generateAuthenticationOptions({
rpID: rpId(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "login");
return options;
}
export async function finishLogin(response: AuthenticationResponseJSON) {
requireAvailable();
const challenge = await takeChallenge("login");
const passkey = await prisma.passkey.findUnique({
where: { credentialId: response.id },
include: { user: { select: { id: true, isActive: true } } },
});
if (!passkey) throw new PasskeyError("Dieser Passkey gehört zu keinem Konto hier.");
if (!passkey.user.isActive) throw new PasskeyError("Dieses Konto ist gesperrt.");
// Die Bibliothek prüft Signatur, Ursprung, RP ID **und** den Zähler. Sie
// wirft dabei gewöhnliche Error — hier übersetzt, damit eine erwartete
// Ablehnung nicht als unerwarteter Serverfehler im Log landet.
let geprueft;
try {
geprueft = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: Number(passkey.counter),
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch (error) {
const text = error instanceof Error ? error.message : "";
if (/counter/i.test(text)) {
throw new PasskeyError(
"Dieser Passkey wurde abgelehnt — der Zähler passt nicht. Melde dich anders an und lege ihn neu an.",
);
}
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
if (!geprueft.verified) throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
// Zusätzliche Sicherung. Die Bibliothek prüft den Zähler nur, wenn beide
// Werte über null liegen; ein Authenticator, der von einem gezählten auf
// einen ungezählten Stand zurückfällt, käme dort durch.
const neuerZaehler = geprueft.authenticationInfo.newCounter;
const alterZaehler = Number(passkey.counter);
if (alterZaehler > 0 && neuerZaehler <= alterZaehler) {
throw new PasskeyError("Dieser Passkey wurde abgelehnt. Bitte melde dich anders an.");
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(neuerZaehler), lastUsedAt: new Date() },
});
return { userId: passkey.user.id };
}
// ---------------------------------------------------------------------------
// Verwalten
// ---------------------------------------------------------------------------
export async function listPasskeys(userId: string) {
return prisma.passkey.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
});
}
export async function renamePasskey(userId: string, passkeyId: string, name: string) {
const treffer = await prisma.passkey.updateMany({
where: { id: passkeyId, userId },
data: { name: name.trim().slice(0, 60) || "Passkey" },
});
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
/**
* Entfernt einen Passkey — aber nie den letzten Anmeldeweg.
*
* Wer ein Konto ohne Passwort hat (etwa über OIDC angelegt) und seinen einzigen
* Passkey löscht, käme sonst nicht mehr herein.
*/
export async function deletePasskey(userId: string, passkeyId: string) {
const [user, anzahl] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true, _count: { select: { oidcAccounts: true } } },
}),
prisma.passkey.count({ where: { userId } }),
]);
const andereWege = Boolean(user.passwordHash) || user._count.oidcAccounts > 0;
if (!andereWege && anzahl <= 1) {
throw new PasskeyError(
"Das ist dein letzter Anmeldeweg. Lege zuerst ein Passwort oder einen weiteren Passkey an.",
);
}
const treffer = await prisma.passkey.deleteMany({ where: { id: passkeyId, userId } });
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}