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:
@@ -0,0 +1,138 @@
|
||||
import "./setup";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after, before, beforeEach } from "node:test";
|
||||
|
||||
import { prisma } from "../src/lib/db";
|
||||
import { deletePasskey, listPasskeys, renamePasskey } from "../src/lib/passkeys";
|
||||
import { env } from "../src/lib/env";
|
||||
|
||||
/**
|
||||
* Prüft die Verwaltung der Passkeys und die Sperre gegen das Aussperren.
|
||||
*
|
||||
* Die eigentliche WebAuthn-Prüfung braucht einen echten Authenticator und
|
||||
* läuft deshalb im Klickweg mit virtuellem Authenticator, nicht hier.
|
||||
*/
|
||||
|
||||
const MIT_PASSWORT = "test-passkey-pw@flimmer.invalid";
|
||||
const OHNE_PASSWORT = "test-passkey-nopw@flimmer.invalid";
|
||||
|
||||
let mitPasswortId: string;
|
||||
let ohnePasswortId: string;
|
||||
let zaehler = 0;
|
||||
|
||||
async function passkeyAnlegen(userId: string, name: string) {
|
||||
zaehler += 1;
|
||||
return prisma.passkey.create({
|
||||
data: {
|
||||
userId,
|
||||
credentialId: `test-credential-${zaehler}-${Date.now()}`,
|
||||
publicKey: Buffer.from([1, 2, 3]),
|
||||
name,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function aufraeumen() {
|
||||
await prisma.user.deleteMany({ where: { email: { in: [MIT_PASSWORT, OHNE_PASSWORT] } } });
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await aufraeumen();
|
||||
mitPasswortId = (
|
||||
await prisma.user.create({
|
||||
data: { email: MIT_PASSWORT, name: "Mit Passwort", passwordHash: "$2a$10$platzhalter" },
|
||||
select: { id: true },
|
||||
})
|
||||
).id;
|
||||
ohnePasswortId = (
|
||||
await prisma.user.create({
|
||||
data: { email: OHNE_PASSWORT, name: "Ohne Passwort" },
|
||||
select: { id: true },
|
||||
})
|
||||
).id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.passkey.deleteMany({ where: { userId: { in: [mitPasswortId, ohnePasswortId] } } });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await aufraeumen();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("die RP ID kommt aus APP_URL, nicht aus dem Host-Header", () => {
|
||||
// Ein Passkey haengt fest an diesem Namen — hinter einem Reverse Proxy waere
|
||||
// der Host-Header der falsche Ort, ihn herzunehmen.
|
||||
assert.equal(env.passkeys.rpId, new URL(env.appUrl).hostname);
|
||||
assert.equal(env.passkeys.rpId, "localhost");
|
||||
});
|
||||
|
||||
test("ueber localhost sind Passkeys erlaubt, ueber nacktes HTTP nicht", () => {
|
||||
assert.equal(env.passkeys.isAvailable, true, "localhost gilt als sicherer Kontext");
|
||||
});
|
||||
|
||||
test("Passkeys lassen sich auflisten, umbenennen und entfernen", async () => {
|
||||
const passkey = await passkeyAnlegen(mitPasswortId, "Erstes Geraet");
|
||||
|
||||
const liste = await listPasskeys(mitPasswortId);
|
||||
assert.equal(liste.length, 1);
|
||||
assert.equal(liste[0]!.name, "Erstes Geraet");
|
||||
assert.equal(liste[0]!.lastUsedAt, null, "frisch angelegt, noch nicht benutzt");
|
||||
|
||||
await renamePasskey(mitPasswortId, passkey.id, " Yubikey am Schluesselbund ");
|
||||
assert.equal(
|
||||
(await listPasskeys(mitPasswortId))[0]!.name,
|
||||
"Yubikey am Schluesselbund",
|
||||
"Leerraum wird abgeschnitten",
|
||||
);
|
||||
|
||||
await deletePasskey(mitPasswortId, passkey.id);
|
||||
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
|
||||
});
|
||||
|
||||
test("fremde Passkeys sind unerreichbar", async () => {
|
||||
const fremd = await passkeyAnlegen(ohnePasswortId, "Fremdes Geraet");
|
||||
|
||||
await assert.rejects(() => renamePasskey(mitPasswortId, fremd.id, "Meiner"), /gibt es nicht/);
|
||||
await assert.rejects(() => deletePasskey(mitPasswortId, fremd.id), /gibt es nicht/);
|
||||
|
||||
// Und er steht unveraendert beim Eigentuemer.
|
||||
const liste = await listPasskeys(ohnePasswortId);
|
||||
assert.equal(liste.length, 1);
|
||||
assert.equal(liste[0]!.name, "Fremdes Geraet");
|
||||
});
|
||||
|
||||
test("der letzte Anmeldeweg laesst sich nicht entfernen", async () => {
|
||||
// Konto ohne Passwort, nur ein Passkey: das Entfernen wuerde aussperren.
|
||||
const einziger = await passkeyAnlegen(ohnePasswortId, "Einziger Weg");
|
||||
await assert.rejects(
|
||||
() => deletePasskey(ohnePasswortId, einziger.id),
|
||||
/letzter Anmeldeweg/,
|
||||
"ohne anderen Weg bleibt der Passkey stehen",
|
||||
);
|
||||
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
|
||||
|
||||
// Mit einem zweiten Passkey geht es dann.
|
||||
await passkeyAnlegen(ohnePasswortId, "Zweiter Weg");
|
||||
await deletePasskey(ohnePasswortId, einziger.id);
|
||||
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
|
||||
});
|
||||
|
||||
test("mit Passwort darf auch der einzige Passkey weg", async () => {
|
||||
const passkey = await passkeyAnlegen(mitPasswortId, "Einziger Passkey");
|
||||
await deletePasskey(mitPasswortId, passkey.id);
|
||||
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
|
||||
});
|
||||
|
||||
test("das Loeschen eines Kontos nimmt seine Passkeys mit", async () => {
|
||||
const email = "test-passkey-weg@flimmer.invalid";
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
const user = await prisma.user.create({ data: { email, name: "Weg" }, select: { id: true } });
|
||||
await passkeyAnlegen(user.id, "Verschwindet");
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
assert.equal(await prisma.passkey.count({ where: { userId: user.id } }), 0);
|
||||
});
|
||||
Reference in New Issue
Block a user