Feat #6: Anmeldung per Passkey (WebAuthn) #12
@@ -71,6 +71,13 @@ SMTP_PASSWORD=
|
||||
SMTP_SECURE=false
|
||||
SMTP_FROM=Flimmer <flimmer@example.com>
|
||||
|
||||
# --- 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: <APP_URL>/api/auth/oidc/callback
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+298
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
<LoginForm action={loginAction} weiter={params.weiter} />
|
||||
|
||||
{env.oidc.isConfigured && (
|
||||
{(env.passkeys.isAvailable || env.oidc.isConfigured) && (
|
||||
<>
|
||||
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
</div>
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
|
||||
{env.oidc.buttonLabel}
|
||||
</a>
|
||||
|
||||
<div className="space-y-2">
|
||||
{env.passkeys.isAvailable && <PasskeyLoginButton label="Mit Passkey anmelden" />}
|
||||
{env.oidc.isConfigured && (
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full justify-center">
|
||||
{env.oidc.buttonLabel}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<typeof finishLogin>[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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<typeof finishRegistration>[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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>) {
|
||||
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));
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
@@ -42,6 +47,29 @@ export default async function SettingsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{env.passkeys.enabled && (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-1 text-lg font-semibold">Passkeys</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Anmelden ohne Passwort — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
|
||||
</p>
|
||||
<PasskeyList
|
||||
passkeys={passkeys.map((passkey) => ({
|
||||
...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."
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
|
||||
@@ -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 (
|
||||
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||
{hinweis}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{passkeys.length > 0 ? (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{passkeys.map((passkey) => (
|
||||
<li key={passkey.id} className="flex flex-wrap items-center gap-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Der Name ist direkt überschreibbar — ein eigener
|
||||
Bearbeiten-Modus wäre für ein einzelnes Feld zu viel. */}
|
||||
<input
|
||||
defaultValue={passkey.name}
|
||||
aria-label={`Name von „${passkey.name}"`}
|
||||
maxLength={60}
|
||||
onBlur={(event) => 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"
|
||||
/>
|
||||
<p className="px-1 text-xs text-[var(--fl-faint)]">
|
||||
{passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"}
|
||||
{" · angelegt "}
|
||||
{formatDate(passkey.createdAt)}
|
||||
{passkey.lastUsedAt
|
||||
? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}`
|
||||
: " · noch nicht benutzt"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => entfernen(passkey.id, passkey.name)}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per
|
||||
Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<button type="button" onClick={anlegen} disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Passkey anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{passkeys.some((passkey) => !passkey.backedUp) && (
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs
|
||||
Erste einen zweiten Anmeldeweg bereit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string | null>(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 (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={anmelden}
|
||||
disabled={pending}
|
||||
className="fl-btn fl-btn-ghost w-full justify-center gap-2"
|
||||
>
|
||||
<KeyIcon />
|
||||
{pending ? "Einen Moment …" : label}
|
||||
</button>
|
||||
{fehler && (
|
||||
<p className="text-sm text-[var(--fl-danger)]" role="alert">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<circle cx="8" cy="12" r="3.5" />
|
||||
<path d="M11.5 12H21m-3 0v3m-3-3v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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