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
|
## Zugriff & Berechtigungen
|
||||||
- ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`.
|
- ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`.
|
||||||
- ✅ Rollenkonzept: SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER.
|
- ✅ 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.
|
- ✅ Admin-APIs serverseitig mit `isAdmin`/`isServerAdmin` geschützt.
|
||||||
|
|
||||||
## Authentifizierung & Sessions
|
## Authentifizierung & Sessions
|
||||||
|
|||||||
267
package-lock.json
generated
267
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.7.8",
|
"version": "1.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.7.8",
|
"version": "1.8.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.0",
|
"@radix-ui/react-toast": "^1.2.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.0",
|
"@radix-ui/react-tooltip": "^1.1.0",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
@@ -770,6 +772,12 @@
|
|||||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
@@ -1337,6 +1345,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/@mapbox/geojson-rewind": {
|
"node_modules/@mapbox/geojson-rewind": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
||||||
@@ -1685,6 +1699,174 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||||
@@ -3433,6 +3615,31 @@
|
|||||||
"@daybrush/utils": "^1.4.0"
|
"@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": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"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"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -9153,6 +9374,24 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
@@ -9520,6 +9759,12 @@
|
|||||||
"@babel/runtime": "^7.9.2"
|
"@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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -10802,6 +11047,24 @@
|
|||||||
"fsevents": "~2.3.3"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.8.0",
|
"version": "1.8.1",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.0",
|
"@radix-ui/react-toast": "^1.2.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.0",
|
"@radix-ui/react-tooltip": "^1.1.0",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.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
|
// MFA-Zwischenschritt
|
||||||
const [mfaToken, setMfaToken] = useState<string | null>(null)
|
const [mfaToken, setMfaToken] = useState<string | null>(null)
|
||||||
const [mfaCode, setMfaCode] = useState('')
|
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 router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -79,6 +80,7 @@ function LoginForm() {
|
|||||||
} else if (result.mfaRequired && result.mfaToken) {
|
} else if (result.mfaRequired && result.mfaToken) {
|
||||||
// Zweiter Faktor nötig — Code-Schritt anzeigen.
|
// Zweiter Faktor nötig — Code-Schritt anzeigen.
|
||||||
setMfaToken(result.mfaToken)
|
setMfaToken(result.mfaToken)
|
||||||
|
if (result.methods) setMfaMethods(result.methods)
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Anmeldung fehlgeschlagen',
|
title: 'Anmeldung fehlgeschlagen',
|
||||||
@@ -104,6 +106,19 @@ function LoginForm() {
|
|||||||
setIsLoading(false)
|
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 (
|
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="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">
|
<div className="w-full max-w-md">
|
||||||
@@ -157,28 +172,45 @@ function LoginForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaToken && (
|
{mfaToken && (
|
||||||
<form onSubmit={handleMfaSubmit} className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<p className="text-sm text-gray-300">Zweiter Faktor erforderlich.</p>
|
||||||
<Label htmlFor="mfa-code">Bestätigungscode</Label>
|
|
||||||
<Input
|
{mfaMethods.webauthn && (
|
||||||
id="mfa-code"
|
<Button type="button" onClick={handleWebAuthn} disabled={isLoading} className="w-full bg-red-600 hover:bg-red-700">
|
||||||
inputMode="numeric"
|
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Warten...</>) : 'Mit Sicherheitsschlüssel / Passkey anmelden'}
|
||||||
autoFocus
|
</Button>
|
||||||
autoComplete="one-time-code"
|
)}
|
||||||
placeholder="6-stelliger Code oder Backup-Code"
|
|
||||||
value={mfaCode}
|
{mfaMethods.webauthn && mfaMethods.totp && (
|
||||||
onChange={(e) => setMfaCode(e.target.value)}
|
<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>
|
||||||
disabled={isLoading}
|
)}
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400">Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.</p>
|
{mfaMethods.totp && (
|
||||||
</div>
|
<form onSubmit={handleMfaSubmit} className="space-y-3">
|
||||||
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={isLoading || mfaCode.trim().length < 6}>
|
<div className="space-y-1.5">
|
||||||
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüfen...</>) : 'Bestätigen'}
|
<Label htmlFor="mfa-code">Bestätigungscode</Label>
|
||||||
</Button>
|
<Input
|
||||||
|
id="mfa-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="6-stelliger Code oder Backup-Code"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(e) => setMfaCode(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400">Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
<button type="button" onClick={() => { setMfaToken(null); setMfaCode('') }} className="w-full text-sm text-gray-400 hover:text-gray-200">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className={`space-y-4 ${mfaToken ? 'hidden' : ''}`}>
|
<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>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>Ü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>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>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>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>
|
<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' }) }
|
} 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) => {
|
const handleToggleUserVerified = async (targetUser: UserRecord) => {
|
||||||
try {
|
try {
|
||||||
const newVal = !targetUser.emailVerified
|
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)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen" onClick={() => handleResetUserPassword(u)}>
|
||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
</Button>
|
</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)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => openEditUser(u)}>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface AuthContextType {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean; mfaToken?: string; methods?: { totp: boolean; webauthn: 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 }>
|
verifyMfa: (mfaToken: string, code: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
verifyMfaWebAuthn: (mfaToken: string) => Promise<{ success: boolean; error?: string }>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
canEdit: () => boolean
|
canEdit: () => boolean
|
||||||
isAdmin: () => 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 () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
@@ -130,7 +155,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
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 { /* */ }
|
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 (
|
return (
|
||||||
<div className="bg-card rounded-lg border p-5">
|
<div className="bg-card rounded-lg border p-5">
|
||||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2">
|
<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
|
<KeyRound className="w-4 h-4 mr-2" /> Authenticator-App einrichten
|
||||||
</Button>
|
</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>
|
</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