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

- 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:
Pepe Ziberi
2026-07-23 18:15:03 +02:00
parent f588b6d361
commit f58f1b31bf
14 changed files with 686 additions and 26 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -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' : ''}`}>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
) )

View File

@@ -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 &amp; 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
View 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
}
}