Feat #6: Anmeldung per Passkey (WebAuthn) #12

Merged
florian.wilde merged 1 commits from feat/6-passkey-anmeldung into main 2026-09-02 06:59:28 +00:00
17 changed files with 1381 additions and 6 deletions
+7
View File
@@ -71,6 +71,13 @@ SMTP_PASSWORD=
SMTP_SECURE=false SMTP_SECURE=false
SMTP_FROM=Flimmer <flimmer@example.com> 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) --------------------------------------------------- # --- OIDC / SSO (optional) ---------------------------------------------------
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert. # Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback # Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
+25
View File
@@ -178,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten:
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen | | `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
| `SMTP_*` | E-Mail-Versand | | `SMTP_*` | E-Mail-Versand |
| `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) | | `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 ## Entwicklung
+298 -2
View File
@@ -1,16 +1,18 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.6.1", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flimmer", "name": "flimmer",
"version": "0.6.1", "version": "0.7.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",
@@ -536,6 +538,12 @@
"node": ">=18" "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": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1135,6 +1143,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@next/env": {
"version": "16.3.4", "version": "16.3.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
@@ -1281,6 +1295,207 @@
"node": ">= 10" "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": { "node_modules/@prisma/adapter-pg": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2585,6 +2825,20 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -4070,6 +4324,24 @@
], ],
"license": "MIT" "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": { "node_modules/rc9": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -4116,6 +4388,12 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/remeda": {
"version": "2.33.4", "version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "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": "^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": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+2
View File
@@ -22,6 +22,8 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.4.0", "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;
+32
View File
@@ -53,6 +53,7 @@ model User {
sessions Session[] sessions Session[]
apiKeys ApiKey[] apiKeys ApiKey[]
oidcAccounts OidcAccount[] oidcAccounts OidcAccount[]
passkeys Passkey[]
entries LibraryEntry[] entries LibraryEntry[]
watches Watch[] watches Watch[]
tags Tag[] tags Tag[]
@@ -109,6 +110,37 @@ model OidcAccount {
@@index([userId]) @@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 …) /// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
model AppSetting { model AppSetting {
key String @id key String @id
+9 -2
View File
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
import { getSettings, isRegistrationOpen } from "@/lib/settings"; import { getSettings, isRegistrationOpen } from "@/lib/settings";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { AuthSwitch, LoginForm } from "@/components/AuthForm"; import { AuthSwitch, LoginForm } from "@/components/AuthForm";
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
import { loginAction } from "../actions"; import { loginAction } from "../actions";
export const metadata: Metadata = { title: "Anmelden" }; export const metadata: Metadata = { title: "Anmelden" };
@@ -36,16 +37,22 @@ export default async function LoginPage({
<LoginForm action={loginAction} weiter={params.weiter} /> <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)]"> <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)]" /> <span className="h-px flex-1 bg-[var(--fl-border)]" />
oder oder
<span className="h-px flex-1 bg-[var(--fl-border)]" /> <span className="h-px flex-1 bg-[var(--fl-border)]" />
</div> </div>
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
<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} {env.oidc.buttonLabel}
</a> </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 });
}
}
+43
View File
@@ -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));
}
+28
View File
@@ -3,6 +3,9 @@ import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PasswordForm, ProfileForm } from "@/components/settings/Forms"; 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"; import pkg from "../../../package.json";
export const metadata: Metadata = { title: "Einstellungen" }; export const metadata: Metadata = { title: "Einstellungen" };
@@ -28,6 +31,8 @@ export default async function SettingsPage() {
const { passwordHash, ...profile } = user; const { passwordHash, ...profile } = user;
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<section className="fl-card p-5"> <section className="fl-card p-5">
@@ -42,6 +47,29 @@ export default async function SettingsPage() {
</section> </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)]"> <p className="text-xs text-[var(--fl-faint)]">
Flimmer {pkg.version} · Instanz {settings.instanceName} Flimmer {pkg.version} · Instanz {settings.instanceName}
</p> </p>
+278
View File
@@ -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>
);
}
+35
View File
@@ -81,6 +81,41 @@ export const env = {
}, },
}, },
passkeys: {
/**
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
* kosten nichts, solange niemand einen anlegt.
*/
enabled: bool("ENABLE_PASSKEYS", true),
/**
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
* plötzlich ungültig.
*/
get rpId(): string {
try {
return new URL(env.appUrl).hostname;
} catch {
return "localhost";
}
},
/**
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
*/
get isAvailable(): boolean {
if (!this.enabled) return false;
try {
const url = new URL(env.appUrl);
if (url.protocol === "https:") return true;
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
} catch {
return false;
}
},
},
oidc: { oidc: {
issuer: optional("OIDC_ISSUER"), issuer: optional("OIDC_ISSUER"),
clientId: optional("OIDC_CLIENT_ID"), clientId: optional("OIDC_CLIENT_ID"),
+333
View File
@@ -0,0 +1,333 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import { prisma } from "./db";
import { env } from "./env";
/**
* Anmeldung per Passkey (WebAuthn).
*
* Ein Passkey ist der dritte Weg in denselben Trichter: `finishLogin()` prüft
* die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über
* `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die
* Sitzungsschicht weiß nicht, wie jemand hereingekommen ist.
*
* Die Trennung ist Absicht: hier steht die WebAuthn-Prüfung, das Setzen von
* Cookie und Sitzungszeile gehört zur HTTP-Schicht.
*
* Zwei Dinge sind bei einer selbst gehosteten Instanz wichtiger als sonst:
*
* - Die **RP ID** kommt aus `APP_URL`, nicht aus dem `Host`-Header. Hinter
* einem Reverse Proxy hinge sie sonst am falschen Namen — und ein Passkey
* ist fest an diesen Namen gebunden.
* - Ein **Domainwechsel entwertet alle Passkeys.** Das steht in der README;
* hier lässt sich nichts dagegen tun, es ist Teil des Verfahrens.
*/
export class PasskeyError extends Error {}
const CHALLENGE_COOKIE = "flimmer_passkey_challenge";
/** Kurz genug, dass eine liegengebliebene Challenge nicht ewig gilt. */
const CHALLENGE_SECONDS = 300;
function rpId(): string {
return env.passkeys.rpId;
}
/** Der Ursprung, den der Browser in der Antwort mitschickt. */
function expectedOrigin(): string {
return new URL(env.appUrl).origin;
}
// ---------------------------------------------------------------------------
// Challenge — signiertes Cookie statt Tabelle
// ---------------------------------------------------------------------------
/**
* Die Challenge muss den Weg vom ersten zum zweiten Schritt überstehen.
*
* Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn
* jemand den Dialog abbricht. Ein signiertes, kurzlebiges Cookie kommt ohne
* beides aus: Fälschen geht nicht, und nach fünf Minuten ist es ohnehin weg.
*/
function sign(payload: string): string {
return createHmac("sha256", env.authSecret).update(payload).digest("base64url");
}
async function storeChallenge(challenge: string, scope: "register" | "login") {
const payload = `${scope}.${challenge}.${Date.now() + CHALLENGE_SECONDS * 1000}`;
const cookieStore = await cookies();
cookieStore.set(CHALLENGE_COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: CHALLENGE_SECONDS,
});
}
async function takeChallenge(scope: "register" | "login"): Promise<string> {
const cookieStore = await cookies();
const raw = cookieStore.get(CHALLENGE_COOKIE)?.value;
// Einmalgebrauch: gleich löschen, egal ob die Prüfung gelingt.
cookieStore.delete(CHALLENGE_COOKIE);
if (!raw) throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
const teile = raw.split(".");
if (teile.length !== 4) throw new PasskeyError("Die Anfrage ist ungültig.");
const [gespeicherterScope, challenge, ablauf, signatur] = teile as [string, string, string, string];
const payload = `${gespeicherterScope}.${challenge}.${ablauf}`;
const erwartet = Buffer.from(sign(payload));
const gegeben = Buffer.from(signatur);
if (erwartet.length !== gegeben.length || !timingSafeEqual(erwartet, gegeben)) {
throw new PasskeyError("Die Anfrage ist ungültig.");
}
if (gespeicherterScope !== scope) throw new PasskeyError("Die Anfrage passt nicht.");
if (Number(ablauf) < Date.now()) {
throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
}
return challenge;
}
function requireAvailable() {
if (!env.passkeys.enabled) throw new PasskeyError("Passkeys sind auf dieser Instanz abgeschaltet.");
if (!env.passkeys.isAvailable) {
throw new PasskeyError(
"Passkeys brauchen HTTPS (oder localhost). Diese Instanz läuft über eine unverschlüsselte Verbindung.",
);
}
}
// ---------------------------------------------------------------------------
// Anlegen
// ---------------------------------------------------------------------------
export async function startRegistration(userId: string) {
requireAvailable();
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { id: true, email: true, name: true },
});
const vorhandene = await prisma.passkey.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: env.appName,
rpID: rpId(),
userName: user.email,
userDisplayName: user.name,
// Die Kennung muss über Umbenennungen hinweg stabil bleiben — deshalb die
// interne ID und nicht die E-Mail-Adresse.
userID: new TextEncoder().encode(user.id),
attestationType: "none",
// Schon vorhandene Passkeys ausschließen, damit dasselbe Gerät nicht
// zweimal registriert wird.
excludeCredentials: vorhandene.map((eintrag) => ({
id: eintrag.credentialId,
transports: eintrag.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
// Auffindbar, damit die Anmeldung ohne vorherige E-Mail-Eingabe geht.
residentKey: "required",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "register");
return options;
}
export async function finishRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string,
) {
requireAvailable();
const challenge = await takeChallenge("register");
const geprueft = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
});
if (!geprueft.verified || !geprueft.registrationInfo) {
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = geprueft.registrationInfo;
const schonVergeben = await prisma.passkey.findUnique({
where: { credentialId: credential.id },
select: { userId: true },
});
if (schonVergeben) {
throw new PasskeyError("Dieser Passkey ist bereits hinterlegt.");
}
return prisma.passkey.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: BigInt(credential.counter),
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name.trim().slice(0, 60) || "Passkey",
},
select: { id: true, name: true, deviceType: true, backedUp: true },
});
}
// ---------------------------------------------------------------------------
// Anmelden
// ---------------------------------------------------------------------------
export async function startLogin() {
requireAvailable();
// Ohne `allowCredentials`: der Browser sucht selbst einen passenden Passkey.
// Genau das erlaubt die Anmeldung ohne vorherige E-Mail-Eingabe — und
// verrät nebenbei nicht, welche Konten es auf dieser Instanz gibt.
const options = await generateAuthenticationOptions({
rpID: rpId(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "login");
return options;
}
export async function finishLogin(response: AuthenticationResponseJSON) {
requireAvailable();
const challenge = await takeChallenge("login");
const passkey = await prisma.passkey.findUnique({
where: { credentialId: response.id },
include: { user: { select: { id: true, isActive: true } } },
});
if (!passkey) throw new PasskeyError("Dieser Passkey gehört zu keinem Konto hier.");
if (!passkey.user.isActive) throw new PasskeyError("Dieses Konto ist gesperrt.");
// Die Bibliothek prüft Signatur, Ursprung, RP ID **und** den Zähler. Sie
// wirft dabei gewöhnliche Error — hier übersetzt, damit eine erwartete
// Ablehnung nicht als unerwarteter Serverfehler im Log landet.
let geprueft;
try {
geprueft = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: Number(passkey.counter),
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch (error) {
const text = error instanceof Error ? error.message : "";
if (/counter/i.test(text)) {
throw new PasskeyError(
"Dieser Passkey wurde abgelehnt — der Zähler passt nicht. Melde dich anders an und lege ihn neu an.",
);
}
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
if (!geprueft.verified) throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
// Zusätzliche Sicherung. Die Bibliothek prüft den Zähler nur, wenn beide
// Werte über null liegen; ein Authenticator, der von einem gezählten auf
// einen ungezählten Stand zurückfällt, käme dort durch.
const neuerZaehler = geprueft.authenticationInfo.newCounter;
const alterZaehler = Number(passkey.counter);
if (alterZaehler > 0 && neuerZaehler <= alterZaehler) {
throw new PasskeyError("Dieser Passkey wurde abgelehnt. Bitte melde dich anders an.");
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(neuerZaehler), lastUsedAt: new Date() },
});
return { userId: passkey.user.id };
}
// ---------------------------------------------------------------------------
// Verwalten
// ---------------------------------------------------------------------------
export async function listPasskeys(userId: string) {
return prisma.passkey.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
});
}
export async function renamePasskey(userId: string, passkeyId: string, name: string) {
const treffer = await prisma.passkey.updateMany({
where: { id: passkeyId, userId },
data: { name: name.trim().slice(0, 60) || "Passkey" },
});
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
/**
* Entfernt einen Passkey — aber nie den letzten Anmeldeweg.
*
* Wer ein Konto ohne Passwort hat (etwa über OIDC angelegt) und seinen einzigen
* Passkey löscht, käme sonst nicht mehr herein.
*/
export async function deletePasskey(userId: string, passkeyId: string) {
const [user, anzahl] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true, _count: { select: { oidcAccounts: true } } },
}),
prisma.passkey.count({ where: { userId } }),
]);
const andereWege = Boolean(user.passwordHash) || user._count.oidcAccounts > 0;
if (!andereWege && anzahl <= 1) {
throw new PasskeyError(
"Das ist dein letzter Anmeldeweg. Lege zuerst ein Passwort oder einen weiteren Passkey an.",
);
}
const treffer = await prisma.passkey.deleteMany({ where: { id: passkeyId, userId } });
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
+138
View File
@@ -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);
});