feat(auth): WebAuthn (YubiKey/Passkeys) + Admin-2FA-Reset-Button (v1.8.1)
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
- WebAuthn/FIDO2 via @simplewebauthn v13: Registrierung (Session) + Login-Faktor (öffentlich),
zustandslose Challenge-Token (JWT), Zähler/Klon-Schutz, Credentials in webauthn_credentials
- Routen: /api/auth/mfa/webauthn/register/{options,verify}, DELETE /webauthn/[id],
/api/auth/mfa-login/webauthn/{options,verify}
- Settings: Sicherheitsschlüssel/Passkey hinzufügen + Liste + entfernen
- Login: "Mit Sicherheitsschlüssel/Passkey anmelden" (neben TOTP)
- Admin: 2FA-Zurücksetzen-Button in der Benutzerliste (nutzt bestehende mfa-reset-API)
- Sicherheitsseite + TOM-Doku: 2FA als vorhanden dokumentiert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,8 @@ Legende: ✅ vorhanden · ⚠️ teilweise / zu bestätigen · ❌ offen / nicht
|
||||
## Zugriff & Berechtigungen
|
||||
- ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`.
|
||||
- ✅ Rollenkonzept: SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER.
|
||||
- ⚠️ MFA für System-/Org-Admins: ❌ nicht vorhanden → **Empfehlung offen**.
|
||||
- ✅ MFA/Zwei-Faktor verfügbar: TOTP (Authenticator-App) + WebAuthn (YubiKey/Passkeys), freiwillig
|
||||
pro Benutzer; Admin kann MFA zurücksetzen. Backup-Codes als Fallback. (SMS bewusst nicht umgesetzt.)
|
||||
- ✅ Admin-APIs serverseitig mit `isAdmin`/`isServerAdmin` geschützt.
|
||||
|
||||
## Authentifizierung & Sessions
|
||||
|
||||
267
package-lock.json
generated
267
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.7.8",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lageplan",
|
||||
"version": "1.7.8",
|
||||
"version": "1.8.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
@@ -27,6 +27,8 @@
|
||||
"@radix-ui/react-toast": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -770,6 +772,12 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1337,6 +1345,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/@mapbox/geojson-rewind": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
||||
@@ -1685,6 +1699,174 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
|
||||
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
|
||||
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
|
||||
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
|
||||
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
|
||||
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.8.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||
"@peculiar/asn1-rsa": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
|
||||
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
|
||||
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.8.0",
|
||||
"@peculiar/asn1-pfx": "^2.8.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
|
||||
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
|
||||
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
|
||||
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
|
||||
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"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/client": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||
@@ -3433,6 +3615,31 @@
|
||||
"@daybrush/utils": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"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.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
|
||||
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
|
||||
"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/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -4579,6 +4786,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"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/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -9153,6 +9374,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"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.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -9520,6 +9759,12 @@
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"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/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10802,6 +11047,24 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"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/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -43,6 +43,8 @@
|
||||
"@radix-ui/react-toast": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
|
||||
37
src/app/api/auth/mfa-login/webauthn/options/route.ts
Normal file
37
src/app/api/auth/mfa-login/webauthn/options/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { verifyMfaToken } from '@/lib/auth'
|
||||
import { rpFromRequest, signChallenge } from '@/lib/webauthn'
|
||||
|
||||
// POST (öffentlich): Authentisierungs-Optionen für den zweiten Faktor per WebAuthn.
|
||||
// body: { mfaToken }
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const mfa = await verifyMfaToken(String(body.mfaToken || ''))
|
||||
if (!mfa) return NextResponse.json({ error: 'Sitzung abgelaufen.' }, { status: 401 })
|
||||
|
||||
const { rpID } = rpFromRequest(req)
|
||||
const creds = await (prisma as any).webAuthnCredential.findMany({
|
||||
where: { userId: mfa.userId },
|
||||
select: { credentialId: true, transports: true },
|
||||
})
|
||||
if (creds.length === 0) return NextResponse.json({ error: 'Kein Sicherheitsschlüssel registriert.' }, { status: 400 })
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials: creds.map((c: any) => ({
|
||||
id: c.credentialId,
|
||||
transports: c.transports ? JSON.parse(c.transports) : undefined,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
})
|
||||
|
||||
const challengeToken = await signChallenge(options.challenge, mfa.userId, 'wa-auth')
|
||||
return NextResponse.json({ options, challengeToken })
|
||||
} catch (error) {
|
||||
console.error('WebAuthn auth options error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
72
src/app/api/auth/mfa-login/webauthn/verify/route.ts
Normal file
72
src/app/api/auth/mfa-login/webauthn/verify/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { verifyMfaToken, buildSessionForUser, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
|
||||
import { rpFromRequest, verifyChallenge, b64urlToBytes } from '@/lib/webauthn'
|
||||
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
// POST (öffentlich): schliesst den zweiten Faktor per WebAuthn ab und setzt die Session.
|
||||
// body: { mfaToken, challengeToken, response }
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const rl = loginLimiter.check(getClientIp(req))
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const mfa = await verifyMfaToken(String(body.mfaToken || ''))
|
||||
if (!mfa) return NextResponse.json({ error: 'Sitzung abgelaufen.' }, { status: 401 })
|
||||
const ch = await verifyChallenge(String(body.challengeToken || ''), 'wa-auth')
|
||||
if (!ch || ch.userId !== mfa.userId) return NextResponse.json({ error: 'Sitzung abgelaufen.' }, { status: 401 })
|
||||
|
||||
const response = body.response
|
||||
const credId = String(response?.id || '')
|
||||
const cred = await (prisma as any).webAuthnCredential.findFirst({
|
||||
where: { credentialId: credId, userId: mfa.userId },
|
||||
})
|
||||
if (!cred) return NextResponse.json({ error: 'Unbekannter Sicherheitsschlüssel.' }, { status: 401 })
|
||||
|
||||
const { rpID, origin } = rpFromRequest(req)
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: ch.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: cred.credentialId,
|
||||
publicKey: b64urlToBytes(cred.publicKey),
|
||||
counter: cred.counter,
|
||||
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (!verification.verified) return NextResponse.json({ error: 'Verifikation fehlgeschlagen.' }, { status: 401 })
|
||||
|
||||
// Zähler aktualisieren (Klon-Schutz)
|
||||
await (prisma as any).webAuthnCredential.update({
|
||||
where: { id: cred.id },
|
||||
data: { counter: verification.authenticationInfo.newCounter, lastUsedAt: new Date() },
|
||||
})
|
||||
|
||||
const session = await buildSessionForUser(mfa.userId)
|
||||
if (!session.success || !session.user) {
|
||||
return NextResponse.json({ error: session.error || 'Anmeldung fehlgeschlagen' }, { status: 401 })
|
||||
}
|
||||
|
||||
try { await (prisma as any).user.update({ where: { id: mfa.userId }, data: { lastLoginAt: new Date() } }) } catch {}
|
||||
|
||||
const token = await createToken(session.user, mfa.rememberMe)
|
||||
;(await cookies()).set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: mfa.rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: session.user })
|
||||
} catch (error) {
|
||||
console.error('WebAuthn auth verify error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
src/app/api/auth/mfa/webauthn/[id]/route.ts
Normal file
32
src/app/api/auth/mfa/webauthn/[id]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
// DELETE (Session): entfernt einen registrierten Sicherheitsschlüssel/Passkey des Benutzers.
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
// Nur eigene Credentials löschen
|
||||
const cred = await (prisma as any).webAuthnCredential.findFirst({ where: { id, userId: session.id }, select: { id: true } })
|
||||
if (!cred) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 })
|
||||
|
||||
await (prisma as any).webAuthnCredential.delete({ where: { id } })
|
||||
|
||||
// Backup-Codes aufräumen, wenn kein Faktor mehr aktiv ist.
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { id: session.id },
|
||||
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
|
||||
})
|
||||
if (!user?.totpConfirmedAt && (user?._count?.webauthnCredentials ?? 0) === 0) {
|
||||
await (prisma as any).user.update({ where: { id: session.id }, data: { mfaBackupCodes: [] } })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('WebAuthn delete error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
39
src/app/api/auth/mfa/webauthn/register/options/route.ts
Normal file
39
src/app/api/auth/mfa/webauthn/register/options/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { rpFromRequest, signChallenge, b64urlToBytes } from '@/lib/webauthn'
|
||||
|
||||
// POST (Session): Optionen zum Registrieren eines Sicherheitsschlüssels/Passkeys.
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const { rpID, rpName } = rpFromRequest(req)
|
||||
const existing = await (prisma as any).webAuthnCredential.findMany({
|
||||
where: { userId: session.id },
|
||||
select: { credentialId: true, transports: true },
|
||||
})
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: session.email,
|
||||
userDisplayName: session.name,
|
||||
userID: new TextEncoder().encode(session.id),
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existing.map((c: any) => ({
|
||||
id: c.credentialId,
|
||||
transports: c.transports ? JSON.parse(c.transports) : undefined,
|
||||
})),
|
||||
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
||||
})
|
||||
|
||||
const challengeToken = await signChallenge(options.challenge, session.id, 'wa-reg')
|
||||
return NextResponse.json({ options, challengeToken })
|
||||
} catch (error) {
|
||||
console.error('WebAuthn register options error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
48
src/app/api/auth/mfa/webauthn/register/verify/route.ts
Normal file
48
src/app/api/auth/mfa/webauthn/register/verify/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { rpFromRequest, verifyChallenge, bytesToB64url } from '@/lib/webauthn'
|
||||
|
||||
// POST (Session): schliesst die Registrierung eines Sicherheitsschlüssels/Passkeys ab.
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const ch = await verifyChallenge(String(body.challengeToken || ''), 'wa-reg')
|
||||
if (!ch || ch.userId !== session.id) {
|
||||
return NextResponse.json({ error: 'Sitzung abgelaufen. Bitte erneut versuchen.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rpID, origin } = rpFromRequest(req)
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: ch.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return NextResponse.json({ error: 'Registrierung fehlgeschlagen.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cred = verification.registrationInfo.credential
|
||||
await (prisma as any).webAuthnCredential.create({
|
||||
data: {
|
||||
userId: session.id,
|
||||
credentialId: cred.id,
|
||||
publicKey: bytesToB64url(cred.publicKey),
|
||||
counter: cred.counter,
|
||||
transports: cred.transports ? JSON.stringify(cred.transports) : null,
|
||||
deviceName: (typeof body.deviceName === 'string' && body.deviceName.trim()) ? body.deviceName.trim().slice(0, 60) : 'Sicherheitsschlüssel',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('WebAuthn register verify error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ function LoginForm() {
|
||||
// MFA-Zwischenschritt
|
||||
const [mfaToken, setMfaToken] = useState<string | null>(null)
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const { login, verifyMfa } = useAuth()
|
||||
const [mfaMethods, setMfaMethods] = useState<{ totp: boolean; webauthn: boolean }>({ totp: false, webauthn: false })
|
||||
const { login, verifyMfa, verifyMfaWebAuthn } = useAuth()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
@@ -79,6 +80,7 @@ function LoginForm() {
|
||||
} else if (result.mfaRequired && result.mfaToken) {
|
||||
// Zweiter Faktor nötig — Code-Schritt anzeigen.
|
||||
setMfaToken(result.mfaToken)
|
||||
if (result.methods) setMfaMethods(result.methods)
|
||||
} else {
|
||||
toast({
|
||||
title: 'Anmeldung fehlgeschlagen',
|
||||
@@ -104,6 +106,19 @@ function LoginForm() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleWebAuthn = async () => {
|
||||
if (!mfaToken) return
|
||||
setIsLoading(true)
|
||||
const result = await verifyMfaWebAuthn(mfaToken)
|
||||
if (result.success) {
|
||||
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
|
||||
await doRedirect()
|
||||
} else if (result.error !== 'Abgebrochen') {
|
||||
toast({ title: 'Fehlgeschlagen', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' })
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
@@ -157,7 +172,21 @@ function LoginForm() {
|
||||
)}
|
||||
|
||||
{mfaToken && (
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">Zweiter Faktor erforderlich.</p>
|
||||
|
||||
{mfaMethods.webauthn && (
|
||||
<Button type="button" onClick={handleWebAuthn} disabled={isLoading} className="w-full bg-red-600 hover:bg-red-700">
|
||||
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Warten...</>) : 'Mit Sicherheitsschlüssel / Passkey anmelden'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mfaMethods.webauthn && mfaMethods.totp && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500"><span className="flex-1 h-px bg-gray-700" />oder Code<span className="flex-1 h-px bg-gray-700" /></div>
|
||||
)}
|
||||
|
||||
{mfaMethods.totp && (
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mfa-code">Bestätigungscode</Label>
|
||||
<Input
|
||||
@@ -175,10 +204,13 @@ function LoginForm() {
|
||||
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={isLoading || mfaCode.trim().length < 6}>
|
||||
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüfen...</>) : 'Bestätigen'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => { setMfaToken(null); setMfaCode('') }} className="w-full text-sm text-gray-400 hover:text-gray-200">
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className={`space-y-4 ${mfaToken ? 'hidden' : ''}`}>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function SicherheitPage() {
|
||||
<li><strong>Passwörter:</strong> werden mit einem geeigneten Einwegverfahren (bcrypt) gehasht, nie im Klartext gespeichert.</li>
|
||||
<li><strong>Übertragung:</strong> verschlüsselt über HTTPS/TLS; HSTS aktiviert.</li>
|
||||
<li><strong>Sitzungen:</strong> httpOnly-Session-Cookie, in Produktion mit <code>secure</code>-Flag.</li>
|
||||
<li><strong>Zwei-Faktor (2FA):</strong> optional pro Konto — Authenticator-App (TOTP) und Sicherheitsschlüssel/Passkeys (WebAuthn/YubiKey), mit Backup-Codes.</li>
|
||||
<li><strong>Web-Härtung:</strong> Sicherheits-Header (u.a. Content-Security-Policy, <code>X-Content-Type-Options</code>, Referrer-Policy, Schutz vor Clickjacking über <code>frame-ancestors</code>).</li>
|
||||
<li><strong>Missbrauchsschutz:</strong> Rate-Limiting für Login, Registrierung, Passwort-Reset, Kontaktformular und geteilte Ansichten.</li>
|
||||
<li><strong>Uploads:</strong> Validierung und Zugriffsschutz für hochgeladene Dateien.</li>
|
||||
|
||||
@@ -109,6 +109,15 @@ export function UsersTab() {
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}
|
||||
|
||||
const handleResetUserMfa = async (targetUser: UserRecord) => {
|
||||
if (!confirm(`Zwei-Faktor (2FA) von "${targetUser.name}" zurücksetzen? Der Benutzer meldet sich danach nur mit Passwort an, bis er 2FA neu einrichtet.`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${targetUser.id}/mfa-reset`, { method: 'POST' })
|
||||
if (res.ok) { toast({ title: '2FA zurückgesetzt' }) }
|
||||
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}
|
||||
|
||||
const handleToggleUserVerified = async (targetUser: UserRecord) => {
|
||||
try {
|
||||
const newVal = !targetUser.emailVerified
|
||||
@@ -251,6 +260,9 @@ export function UsersTab() {
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen" onClick={() => handleResetUserPassword(u)}>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Zwei-Faktor (2FA) zurücksetzen" onClick={() => handleResetUserMfa(u)}>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => openEditUser(u)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AuthContextType {
|
||||
loading: boolean
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean; mfaToken?: string; methods?: { totp: boolean; webauthn: boolean } }>
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<{ success: boolean; error?: string }>
|
||||
verifyMfaWebAuthn: (mfaToken: string) => Promise<{ success: boolean; error?: string }>
|
||||
logout: () => Promise<void>
|
||||
canEdit: () => boolean
|
||||
isAdmin: () => boolean
|
||||
@@ -107,6 +108,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const verifyMfaWebAuthn = async (mfaToken: string) => {
|
||||
try {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser')
|
||||
const optRes = await fetch('/api/auth/mfa-login/webauthn/options', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mfaToken }),
|
||||
})
|
||||
const opt = await optRes.json()
|
||||
if (!optRes.ok) return { success: false, error: opt.error || 'Fehler' }
|
||||
|
||||
const assertion = await startAuthentication({ optionsJSON: opt.options })
|
||||
|
||||
const verRes = await fetch('/api/auth/mfa-login/webauthn/verify', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mfaToken, challengeToken: opt.challengeToken, response: assertion }),
|
||||
})
|
||||
const data = await verRes.json()
|
||||
if (verRes.ok && data.user) { setUser(data.user); return { success: true } }
|
||||
return { success: false, error: data.error || 'Verifikation fehlgeschlagen' }
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'NotAllowedError' || e?.name === 'AbortError') return { success: false, error: 'Abgebrochen' }
|
||||
return { success: false, error: 'Sicherheitsschlüssel-Fehler' }
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
@@ -130,7 +155,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, tenant, loading, login, verifyMfa, logout, canEdit, isAdmin, isServerAdmin }}>
|
||||
<AuthContext.Provider value={{ user, tenant, loading, login, verifyMfa, verifyMfaWebAuthn, logout, canEdit, isAdmin, isServerAdmin }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { ShieldCheck, Loader2, KeyRound, Copy, Check } from 'lucide-react'
|
||||
import { ShieldCheck, Loader2, KeyRound, Copy, Check, Usb, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -68,6 +68,36 @@ export function MfaSettings() {
|
||||
try { await navigator.clipboard.writeText(backupCodes.join('\n')); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch { /* */ }
|
||||
}
|
||||
|
||||
const addWebAuthn = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser')
|
||||
const optRes = await fetch('/api/auth/mfa/webauthn/register/options', { method: 'POST' })
|
||||
const opt = await optRes.json()
|
||||
if (!optRes.ok) { toast({ title: 'Fehler', description: opt.error, variant: 'destructive' }); return }
|
||||
const att = await startRegistration({ optionsJSON: opt.options })
|
||||
const name = (typeof window !== 'undefined' && window.prompt('Name für diesen Schlüssel (z. B. „YubiKey blau", „iPhone"):', 'Sicherheitsschlüssel')) || 'Sicherheitsschlüssel'
|
||||
const res = await fetch('/api/auth/mfa/webauthn/register/verify', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ response: att, challengeToken: opt.challengeToken, deviceName: name }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (res.ok) { toast({ title: 'Schlüssel registriert' }); refresh() }
|
||||
else toast({ title: 'Fehler', description: d.error, variant: 'destructive' })
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'NotAllowedError' && e?.name !== 'AbortError') toast({ title: 'Abgebrochen oder nicht unterstützt', variant: 'destructive' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const removeWebAuthn = async (id: string) => {
|
||||
if (!confirm2('Diesen Sicherheitsschlüssel entfernen?')) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/auth/mfa/webauthn/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) { toast({ title: 'Entfernt' }); refresh() }
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border p-5">
|
||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2">
|
||||
@@ -125,6 +155,27 @@ export function MfaSettings() {
|
||||
<KeyRound className="w-4 h-4 mr-2" /> Authenticator-App einrichten
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Sicherheitsschlüssel / Passkeys (WebAuthn) */}
|
||||
{status && (
|
||||
<div className="mt-5 pt-5 border-t border-border">
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2"><Usb className="w-4 h-4 text-muted-foreground" /> Sicherheitsschlüssel & Passkeys</h3>
|
||||
{status.webauthn.length > 0 && (
|
||||
<ul className="mb-3 space-y-1.5">
|
||||
{status.webauthn.map(w => (
|
||||
<li key={w.id} className="flex items-center justify-between text-sm rounded-lg border border-border px-3 py-1.5">
|
||||
<span className="flex items-center gap-2"><KeyRound className="w-3.5 h-3.5 text-muted-foreground" /> {w.deviceName || 'Sicherheitsschlüssel'}</span>
|
||||
<button onClick={() => removeWebAuthn(w.id)} disabled={busy} className="text-destructive hover:opacity-70" title="Entfernen"><Trash2 className="w-4 h-4" /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={addWebAuthn} disabled={busy}>
|
||||
<Usb className="w-4 h-4 mr-2" /> Sicherheitsschlüssel / Passkey hinzufügen
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">YubiKey, Touch-ID/Windows Hello oder Passkey. Funktioniert nur über HTTPS.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/lib/webauthn.ts
Normal file
45
src/lib/webauthn.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { JWT_SECRET } from '@/lib/jwt-secret'
|
||||
import { legal } from '@/config/legal'
|
||||
|
||||
/** Ermittelt Relying-Party-ID (Hostname) und Origin aus dem Request. */
|
||||
export function rpFromRequest(req: NextRequest): { rpID: string; origin: string; rpName: string } {
|
||||
const host = req.headers.get('host') || 'localhost:3000'
|
||||
const proto = req.headers.get('x-forwarded-proto') || (host.startsWith('localhost') ? 'http' : 'https')
|
||||
const rpID = host.split(':')[0] // Hostname ohne Port
|
||||
const origin = `${proto}://${host}`
|
||||
return { rpID, origin, rpName: legal.projectName }
|
||||
}
|
||||
|
||||
// Base64URL <-> Bytes (Node Buffer)
|
||||
export function bytesToB64url(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64url')
|
||||
}
|
||||
export function b64urlToBytes(s: string): Uint8Array<ArrayBuffer> {
|
||||
const buf = Buffer.from(s, 'base64url')
|
||||
const out = new Uint8Array(buf.byteLength) // frisches ArrayBuffer-Backing (Typ-Kompatibilität)
|
||||
out.set(buf)
|
||||
return out
|
||||
}
|
||||
|
||||
type ChallengePurpose = 'wa-reg' | 'wa-auth'
|
||||
|
||||
/** Signiert die Challenge zustandslos (kurzlebig), damit kein Server-State nötig ist. */
|
||||
export async function signChallenge(challenge: string, userId: string, purpose: ChallengePurpose): Promise<string> {
|
||||
return await new SignJWT({ ch: challenge, uid: userId, purpose })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(JWT_SECRET)
|
||||
}
|
||||
|
||||
export async function verifyChallenge(token: string, purpose: ChallengePurpose): Promise<{ challenge: string; userId: string } | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||
if (payload.purpose !== purpose || typeof payload.ch !== 'string' || typeof payload.uid !== 'string') return null
|
||||
return { challenge: payload.ch, userId: payload.uid }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user