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,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Passkey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"credentialId" TEXT NOT NULL,
|
||||
"publicKey" BYTEA NOT NULL,
|
||||
"counter" BIGINT NOT NULL DEFAULT 0,
|
||||
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"deviceType" TEXT NOT NULL DEFAULT 'singleDevice',
|
||||
"backedUp" BOOLEAN NOT NULL DEFAULT false,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Passkey_userId_idx" ON "Passkey"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -53,6 +53,7 @@ model User {
|
||||
sessions Session[]
|
||||
apiKeys ApiKey[]
|
||||
oidcAccounts OidcAccount[]
|
||||
passkeys Passkey[]
|
||||
entries LibraryEntry[]
|
||||
watches Watch[]
|
||||
tags Tag[]
|
||||
@@ -109,6 +110,37 @@ model OidcAccount {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Ein Passkey (WebAuthn) als dritter Anmeldeweg neben Passwort und OIDC.
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Kennung des Authenticators, base64url — die Gegenstelle nennt sie beim Anmelden.
|
||||
credentialId String @unique
|
||||
/// Öffentlicher Schlüssel im COSE-Format.
|
||||
publicKey Bytes
|
||||
|
||||
/// Signaturzähler. Zählt er nicht hoch, wurde die Antwort wiedereingespielt.
|
||||
counter BigInt @default(0)
|
||||
|
||||
/// usb, nfc, ble, internal, hybrid — hilft dem Browser bei der Auswahl.
|
||||
transports String[] @default([])
|
||||
|
||||
/// singleDevice oder multiDevice. Ein nicht gesicherter Passkey ist mit dem
|
||||
/// Gerät verloren — davor warnt die Oberfläche.
|
||||
deviceType String @default("singleDevice")
|
||||
backedUp Boolean @default(false)
|
||||
|
||||
/// Vom Nutzer vergeben: „iPhone", „Yubikey am Schlüsselbund".
|
||||
name String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
|
||||
model AppSetting {
|
||||
key String @id
|
||||
|
||||
Reference in New Issue
Block a user