diff --git a/.env.example b/.env.example index ec90fa9..0e4d825 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,13 @@ SMTP_PASSWORD= SMTP_SECURE=false SMTP_FROM=Flimmer +# --- Passkeys (WebAuthn) ----------------------------------------------------- +# Anmeldung per Fingerabdruck, Gesicht oder Sicherheitsschlüssel. Standardmäßig an. +# Passkeys brauchen HTTPS — Ausnahme ist localhost. Sie sind fest an den +# Hostnamen aus APP_URL gebunden: nach einem Domainwechsel müssen alle +# Passkeys neu angelegt werden. +ENABLE_PASSKEYS=true + # --- OIDC / SSO (optional) --------------------------------------------------- # Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert. # Redirect-URI beim Anbieter eintragen: /api/auth/oidc/callback diff --git a/README.md b/README.md index 473d1d0..4533abe 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten: | `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen | | `SMTP_*` | E-Mail-Versand | | `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) | +| `ENABLE_PASSKEYS` | Anmeldung per Passkey (Standard `true`) | + +### Passkeys + +Statt Passwort geht auch ein Passkey — Windows Hello, Face ID, Touch ID oder ein +Sicherheitsschlüssel. Anlegen unter **Einstellungen → Passkeys**, anmelden über +„Mit Passkey anmelden" ohne vorherige E-Mail-Eingabe. Das Passwort bleibt +daneben bestehen; den letzten verbleibenden Anmeldeweg lässt Flimmer nicht +entfernen. + +Drei Dinge, die beim Selbsthosten zählen: + +- **HTTPS ist Pflicht.** Der Browser gibt Passkeys nur über eine verschlüsselte + Verbindung frei; einzige Ausnahme ist `localhost`. Läuft deine Instanz über + einfaches HTTP, verschwindet die Passkey-Verwaltung mit einem Hinweis, statt + einen kryptischen Browserfehler zu zeigen. +- **Der Hostname kommt aus `APP_URL`**, nicht aus dem `Host`-Header. Hinter + einem Reverse Proxy muss `APP_URL` also stimmen, sonst hängen die Passkeys am + falschen Namen. +- **Ein Domainwechsel entwertet alle Passkeys.** Wer von `flimmer.fritz.box` auf + `flimmer.example.com` umzieht, muss sie neu anlegen — sie sind fest an den + Hostnamen gebunden. Halte vorher einen zweiten Anmeldeweg bereit. + +Ein Passkey, der nur auf einem Gerät liegt (nicht synchronisiert), ist mit dem +Gerät verloren. Die Übersicht in den Einstellungen sagt dazu, was Sache ist. ## Entwicklung diff --git a/package-lock.json b/package-lock.json index 45b6677..6f4def4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "flimmer", - "version": "0.6.1", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flimmer", - "version": "0.6.1", + "version": "0.7.1", "license": "MIT", "dependencies": { "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "7.10.0", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.3", "bcryptjs": "3.0.3", "clsx": "2.1.1", "date-fns": "4.4.0", @@ -536,6 +538,12 @@ "node": ">=18" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1135,6 +1143,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "16.3.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", @@ -1281,6 +1295,207 @@ "node": ">= 10" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", @@ -1645,6 +1860,31 @@ } } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz", + "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2585,6 +2825,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -4070,6 +4324,24 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/rc9": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", @@ -4116,6 +4388,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -4429,6 +4707,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 1838ddc..920ce02 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "dependencies": { "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "7.10.0", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.3", "bcryptjs": "3.0.3", "clsx": "2.1.1", "date-fns": "4.4.0", diff --git a/prisma/migrations/20260902044634_passkeys/migration.sql b/prisma/migrations/20260902044634_passkeys/migration.sql new file mode 100644 index 0000000..70c756c --- /dev/null +++ b/prisma/migrations/20260902044634_passkeys/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30b3d16..5776ee7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(auth)/anmelden/page.tsx b/src/app/(auth)/anmelden/page.tsx index 01a8bfd..ad1715e 100644 --- a/src/app/(auth)/anmelden/page.tsx +++ b/src/app/(auth)/anmelden/page.tsx @@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth"; import { getSettings, isRegistrationOpen } from "@/lib/settings"; import { env } from "@/lib/env"; import { AuthSwitch, LoginForm } from "@/components/AuthForm"; +import { PasskeyLoginButton } from "@/components/settings/Passkeys"; import { loginAction } from "../actions"; export const metadata: Metadata = { title: "Anmelden" }; @@ -36,16 +37,22 @@ export default async function LoginPage({ - {env.oidc.isConfigured && ( + {(env.passkeys.isAvailable || env.oidc.isConfigured) && ( <>
oder
- - {env.oidc.buttonLabel} - + +
+ {env.passkeys.isAvailable && } + {env.oidc.isConfigured && ( + + {env.oidc.buttonLabel} + + )} +
)} diff --git a/src/app/api/auth/passkey/anmeldung/abschluss/route.ts b/src/app/api/auth/passkey/anmeldung/abschluss/route.ts new file mode 100644 index 0000000..b010d32 --- /dev/null +++ b/src/app/api/auth/passkey/anmeldung/abschluss/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createSession } from "@/lib/auth"; +import { finishLogin, PasskeyError } from "@/lib/passkeys"; + +export const dynamic = "force-dynamic"; + +const schema = z.looseObject({ + id: z.string().min(1), + rawId: z.string().min(1), + type: z.literal("public-key"), + clientExtensionResults: z.looseObject({}), + response: z.looseObject({}), +}); + + +/** Prüft die Antwort und legt bei Erfolg die Sitzung an. */ +export async function POST(request: Request) { + const body = schema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 }); + } + + try { + // Die äußere Form ist geprüft; alles Weitere prüft die Bibliothek. + const { userId } = await finishLogin(body.data as unknown as Parameters[0]); + // Dieselbe Sitzung wie nach einer Passwortanmeldung — gleiche Tabelle, + // gleiches Cookie. + await createSession(userId); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof PasskeyError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Passkey-Anmeldung fehlgeschlagen:", error); + return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 }); + } +} diff --git a/src/app/api/auth/passkey/anmeldung/start/route.ts b/src/app/api/auth/passkey/anmeldung/start/route.ts new file mode 100644 index 0000000..12d0b70 --- /dev/null +++ b/src/app/api/auth/passkey/anmeldung/start/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { PasskeyError, startLogin } from "@/lib/passkeys"; + +export const dynamic = "force-dynamic"; + +/** + * Optionen für die Anmeldung — bewusst ohne Anmeldung erreichbar und ohne + * Angabe, welche Passkeys es gibt. Der Browser sucht selbst einen passenden. + */ +export async function POST() { + try { + return NextResponse.json(await startLogin()); + } catch (error) { + if (error instanceof PasskeyError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Passkey-Anmeldung konnte nicht beginnen:", error); + return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 }); + } +} diff --git a/src/app/api/auth/passkey/registrierung/abschluss/route.ts b/src/app/api/auth/passkey/registrierung/abschluss/route.ts new file mode 100644 index 0000000..6e501c2 --- /dev/null +++ b/src/app/api/auth/passkey/registrierung/abschluss/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getSessionUser } from "@/lib/auth"; +import { finishRegistration, PasskeyError } from "@/lib/passkeys"; + +export const dynamic = "force-dynamic"; + +const antwortSchema = z.looseObject({ + id: z.string().min(1), + rawId: z.string().min(1), + type: z.literal("public-key"), + clientExtensionResults: z.looseObject({}), + response: z.looseObject({}), +}); + +const schema = z.object({ + name: z.string().trim().min(1).max(60), + response: antwortSchema, +}); + +/** Prüft die Antwort des Authenticators und legt den Passkey an. */ +export async function POST(request: Request) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); + + const body = schema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 }); + } + + try { + // Die äußere Form ist geprüft; die Feinheiten der eingebetteten + // Base64-Felder prüft die Bibliothek beim Verifizieren. + const passkey = await finishRegistration( + user.id, + body.data.response as unknown as Parameters[1], + body.data.name, + ); + return NextResponse.json({ ok: true, passkey }); + } catch (error) { + if (error instanceof PasskeyError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Passkey konnte nicht angelegt werden:", error); + return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 }); + } +} diff --git a/src/app/api/auth/passkey/registrierung/start/route.ts b/src/app/api/auth/passkey/registrierung/start/route.ts new file mode 100644 index 0000000..9edebd4 --- /dev/null +++ b/src/app/api/auth/passkey/registrierung/start/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/auth"; +import { PasskeyError, startRegistration } from "@/lib/passkeys"; + +export const dynamic = "force-dynamic"; + +/** Optionen zum Anlegen eines Passkeys — nur für angemeldete Nutzer. */ +export async function POST() { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); + + try { + return NextResponse.json(await startRegistration(user.id)); + } catch (error) { + if (error instanceof PasskeyError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Passkey-Registrierung konnte nicht beginnen:", error); + return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 }); + } +} diff --git a/src/app/api/passkeys/[id]/route.ts b/src/app/api/passkeys/[id]/route.ts new file mode 100644 index 0000000..b92ca63 --- /dev/null +++ b/src/app/api/passkeys/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getSessionUser } from "@/lib/auth"; +import { deletePasskey, PasskeyError, renamePasskey } from "@/lib/passkeys"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +async function withUser(context: Context, fn: (userId: string, id: string) => Promise) { + const user = await getSessionUser(); + if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); + + const { id } = await context.params; + if (!id) return NextResponse.json({ error: "Es fehlt die Kennung." }, { status: 400 }); + + try { + await fn(user.id, id); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof PasskeyError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Passkey-Verwaltung fehlgeschlagen:", error); + return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 }); + } +} + +/** Entfernt einen Passkey — nie den letzten Anmeldeweg. */ +export async function DELETE(_request: Request, context: Context) { + return withUser(context, (userId, id) => deletePasskey(userId, id)); +} + +const umbenennen = z.object({ name: z.string().trim().min(1).max(60) }); + +/** Benennt einen Passkey um. */ +export async function PATCH(request: Request, context: Context) { + const body = umbenennen.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: "Es fehlt ein Name." }, { status: 422 }); + } + return withUser(context, (userId, id) => renamePasskey(userId, id, body.data.name)); +} diff --git a/src/app/einstellungen/page.tsx b/src/app/einstellungen/page.tsx index 33d40e9..01bdae7 100644 --- a/src/app/einstellungen/page.tsx +++ b/src/app/einstellungen/page.tsx @@ -3,6 +3,9 @@ import { requireUser } from "@/lib/auth"; import { getSettings } from "@/lib/settings"; import { prisma } from "@/lib/db"; import { PasswordForm, ProfileForm } from "@/components/settings/Forms"; +import { PasskeyList } from "@/components/settings/Passkeys"; +import { listPasskeys } from "@/lib/passkeys"; +import { env } from "@/lib/env"; import pkg from "../../../package.json"; export const metadata: Metadata = { title: "Einstellungen" }; @@ -28,6 +31,8 @@ export default async function SettingsPage() { const { passwordHash, ...profile } = user; + const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : []; + return (
@@ -42,6 +47,29 @@ export default async function SettingsPage() {
)} + {env.passkeys.enabled && ( +
+

Passkeys

+

+ Anmelden ohne Passwort — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel. +

+ ({ + ...passkey, + createdAt: passkey.createdAt.toISOString(), + lastUsedAt: passkey.lastUsedAt?.toISOString() ?? null, + }))} + available={env.passkeys.isAvailable} + hinweis={ + env.passkeys.isAvailable + ? null + : "Passkeys brauchen eine verschlüsselte Verbindung. Diese Instanz läuft über " + + "einfaches HTTP — richte HTTPS ein, dann erscheint hier die Verwaltung." + } + /> +
+ )} +

Flimmer {pkg.version} · Instanz {settings.instanceName}

diff --git a/src/components/settings/Passkeys.tsx b/src/components/settings/Passkeys.tsx new file mode 100644 index 0000000..23ab80f --- /dev/null +++ b/src/components/settings/Passkeys.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + startAuthentication, + startRegistration, + WebAuthnError, +} from "@simplewebauthn/browser"; +import { useToasts } from "@/components/Toasts"; +import { formatDate } from "@/lib/labels"; + +/** + * Passkeys anlegen und verwalten. + * + * Der Browser übernimmt den heiklen Teil; hier laufen nur zwei Anfragen — + * Optionen holen, Antwort einreichen. Fehler des Authenticators tragen + * kryptische Namen, deshalb werden die häufigen übersetzt. + */ + +export type PasskeyEintrag = { + id: string; + name: string; + deviceType: string; + backedUp: boolean; + createdAt: string; + lastUsedAt: string | null; +}; + +/** Übersetzt die Fehler des Browsers in Sätze, mit denen man etwas anfangen kann. */ +export function passkeyFehlertext(error: unknown): string | null { + if (error instanceof WebAuthnError) { + switch (error.name) { + case "InvalidStateError": + return "Dieses Gerät ist hier schon hinterlegt."; + case "NotAllowedError": + // Auch der Abbruch durch den Nutzer landet hier — dann ist Schweigen + // die richtige Antwort. + return null; + case "SecurityError": + return "Passkeys brauchen HTTPS oder localhost. Über diese Adresse geht es nicht."; + default: + return "Der Passkey konnte nicht verwendet werden."; + } + } + if (error instanceof DOMException && error.name === "NotAllowedError") return null; + return "Das hat nicht geklappt."; +} + +export function PasskeyList({ + passkeys, + available, + hinweis, +}: { + passkeys: PasskeyEintrag[]; + available: boolean; + hinweis: string | null; +}) { + const router = useRouter(); + const { show } = useToasts(); + const [pending, startTransition] = useTransition(); + const [name, setName] = useState(""); + + async function anlegen() { + try { + const antwort = await fetch("/api/auth/passkey/registrierung/start", { method: "POST" }); + const optionen = await antwort.json(); + if (!antwort.ok) { + show(optionen.error ?? "Das hat nicht geklappt.", { tone: "error" }); + return; + } + + const ergebnis = await startRegistration({ optionsJSON: optionen }); + + const fertig = await fetch("/api/auth/passkey/registrierung/abschluss", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: name.trim() || geraeteVorschlag(), response: ergebnis }), + }); + const daten = await fertig.json(); + if (!fertig.ok) { + show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" }); + return; + } + + setName(""); + show("Passkey angelegt"); + startTransition(() => router.refresh()); + } catch (error) { + const text = passkeyFehlertext(error); + if (text) show(text, { tone: "error" }); + } + } + + async function umbenennen(id: string, alt: string, neu: string) { + const name = neu.trim(); + if (!name || name === alt) return; + + const antwort = await fetch(`/api/passkeys/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!antwort.ok) { + const daten = await antwort.json().catch(() => ({})); + show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" }); + return; + } + show("Umbenannt"); + startTransition(() => router.refresh()); + } + + async function entfernen(id: string, wie: string) { + const antwort = await fetch(`/api/passkeys/${id}`, { method: "DELETE" }); + const daten = await antwort.json().catch(() => ({})); + if (!antwort.ok) { + show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" }); + return; + } + show(`„${wie}" entfernt`); + startTransition(() => router.refresh()); + } + + if (!available) { + return ( +

+ {hinweis} +

+ ); + } + + return ( +
+ {passkeys.length > 0 ? ( +
    + {passkeys.map((passkey) => ( +
  • +
    + {/* Der Name ist direkt überschreibbar — ein eigener + Bearbeiten-Modus wäre für ein einzelnes Feld zu viel. */} + umbenennen(passkey.id, passkey.name, event.target.value)} + className="w-full truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 font-medium transition-colors hover:border-[var(--fl-border)] focus:border-[var(--fl-border-strong)] focus:outline-none" + /> +

    + {passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"} + {" · angelegt "} + {formatDate(passkey.createdAt)} + {passkey.lastUsedAt + ? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}` + : " · noch nicht benutzt"} +

    +
    + +
  • + ))} +
+ ) : ( +

+ Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per + Fingerabdruck, Gesicht oder Sicherheitsschlüssel. +

+ )} + +
+ setName(event.target.value)} + placeholder={geraeteVorschlag()} + aria-label="Name für den neuen Passkey" + maxLength={60} + className="fl-input w-56 py-1.5 text-sm" + /> + +
+ + {passkeys.some((passkey) => !passkey.backedUp) && ( +

+ Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs + Erste einen zweiten Anmeldeweg bereit. +

+ )} +
+ ); +} + +/** Ein brauchbarer Vorschlag, damit niemand „Passkey 1" tippen muss. */ +function geraeteVorschlag(): string { + if (typeof navigator === "undefined") return "Mein Gerät"; + const ua = navigator.userAgent; + if (/iPhone/.test(ua)) return "iPhone"; + if (/iPad/.test(ua)) return "iPad"; + if (/Android/.test(ua)) return "Android-Gerät"; + if (/Mac OS X/.test(ua)) return "Mac"; + if (/Windows/.test(ua)) return "Windows-PC"; + if (/Linux/.test(ua)) return "Linux-Rechner"; + return "Mein Gerät"; +} + +/** Knopf auf der Anmeldeseite. */ +export function PasskeyLoginButton({ label }: { label: string }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [fehler, setFehler] = useState(null); + + async function anmelden() { + setFehler(null); + setPending(true); + try { + const antwort = await fetch("/api/auth/passkey/anmeldung/start", { method: "POST" }); + const optionen = await antwort.json(); + if (!antwort.ok) { + setFehler(optionen.error ?? "Das hat nicht geklappt."); + return; + } + + const ergebnis = await startAuthentication({ optionsJSON: optionen }); + + const fertig = await fetch("/api/auth/passkey/anmeldung/abschluss", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(ergebnis), + }); + const daten = await fertig.json(); + if (!fertig.ok) { + setFehler(daten.error ?? "Das hat nicht geklappt."); + return; + } + + router.replace("/"); + router.refresh(); + } catch (error) { + setFehler(passkeyFehlertext(error)); + } finally { + setPending(false); + } + } + + return ( +
+ + {fehler && ( +

+ {fehler} +

+ )} +
+ ); +} + +function KeyIcon() { + return ( + + + + + ); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index e6ddba9..291d3c7 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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"), diff --git a/src/lib/passkeys.ts b/src/lib/passkeys.ts new file mode 100644 index 0000000..b8936f3 --- /dev/null +++ b/src/lib/passkeys.ts @@ -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 { + 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."); +} diff --git a/tests/passkeys.test.ts b/tests/passkeys.test.ts new file mode 100644 index 0000000..53c8355 --- /dev/null +++ b/tests/passkeys.test.ts @@ -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); +});