From f58f1b31bf1ec416f4d944b4f444c0992920a9a2 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Thu, 23 Jul 2026 18:15:03 +0200 Subject: [PATCH] feat(auth): WebAuthn (YubiKey/Passkeys) + Admin-2FA-Reset-Button (v1.8.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../technical-organizational-measures.md | 3 +- package-lock.json | 267 +++++++++++++++++- package.json | 4 +- .../auth/mfa-login/webauthn/options/route.ts | 37 +++ .../auth/mfa-login/webauthn/verify/route.ts | 72 +++++ src/app/api/auth/mfa/webauthn/[id]/route.ts | 32 +++ .../mfa/webauthn/register/options/route.ts | 39 +++ .../mfa/webauthn/register/verify/route.ts | 48 ++++ src/app/login/page.tsx | 72 +++-- src/app/sicherheit/page.tsx | 1 + src/components/admin/users-tab.tsx | 12 + src/components/providers/auth-provider.tsx | 27 +- src/components/settings/mfa-settings.tsx | 53 +++- src/lib/webauthn.ts | 45 +++ 14 files changed, 686 insertions(+), 26 deletions(-) create mode 100644 src/app/api/auth/mfa-login/webauthn/options/route.ts create mode 100644 src/app/api/auth/mfa-login/webauthn/verify/route.ts create mode 100644 src/app/api/auth/mfa/webauthn/[id]/route.ts create mode 100644 src/app/api/auth/mfa/webauthn/register/options/route.ts create mode 100644 src/app/api/auth/mfa/webauthn/register/verify/route.ts create mode 100644 src/lib/webauthn.ts diff --git a/docs/legal/technical-organizational-measures.md b/docs/legal/technical-organizational-measures.md index 187954f..9573d67 100644 --- a/docs/legal/technical-organizational-measures.md +++ b/docs/legal/technical-organizational-measures.md @@ -6,7 +6,8 @@ Legende: ✅ vorhanden · ⚠️ teilweise / zu bestätigen · ❌ offen / nicht ## Zugriff & Berechtigungen - ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`. - ✅ Rollenkonzept: SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER. -- ⚠️ MFA für System-/Org-Admins: ❌ nicht vorhanden → **Empfehlung offen**. +- ✅ MFA/Zwei-Faktor verfügbar: TOTP (Authenticator-App) + WebAuthn (YubiKey/Passkeys), freiwillig + pro Benutzer; Admin kann MFA zurücksetzen. Backup-Codes als Fallback. (SMS bewusst nicht umgesetzt.) - ✅ Admin-APIs serverseitig mit `isAdmin`/`isServerAdmin` geschützt. ## Authentifizierung & Sessions diff --git a/package-lock.json b/package-lock.json index e0dccc5..20f4f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lageplan", - "version": "1.7.8", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lageplan", - "version": "1.7.8", + "version": "1.8.0", "hasInstallScript": true, "dependencies": { "@dnd-kit/core": "^6.1.0", @@ -27,6 +27,8 @@ "@radix-ui/react-toast": "^1.2.0", "@radix-ui/react-tooltip": "^1.1.0", "@react-pdf/renderer": "^4.3.2", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -770,6 +772,12 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1337,6 +1345,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -1685,6 +1699,174 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -3433,6 +3615,31 @@ "@daybrush/utils": "^1.4.0" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -4579,6 +4786,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -9153,6 +9374,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -9520,6 +9759,12 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10802,6 +11047,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 2b4c3dc..80b302d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.8.0", + "version": "1.8.1", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { @@ -43,6 +43,8 @@ "@radix-ui/react-toast": "^1.2.0", "@radix-ui/react-tooltip": "^1.1.0", "@react-pdf/renderer": "^4.3.2", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/src/app/api/auth/mfa-login/webauthn/options/route.ts b/src/app/api/auth/mfa-login/webauthn/options/route.ts new file mode 100644 index 0000000..55b54d7 --- /dev/null +++ b/src/app/api/auth/mfa-login/webauthn/options/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa-login/webauthn/verify/route.ts b/src/app/api/auth/mfa-login/webauthn/verify/route.ts new file mode 100644 index 0000000..2f76dab --- /dev/null +++ b/src/app/api/auth/mfa-login/webauthn/verify/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/webauthn/[id]/route.ts b/src/app/api/auth/mfa/webauthn/[id]/route.ts new file mode 100644 index 0000000..6deab54 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/[id]/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/webauthn/register/options/route.ts b/src/app/api/auth/mfa/webauthn/register/options/route.ts new file mode 100644 index 0000000..b83cd7a --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/register/options/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/mfa/webauthn/register/verify/route.ts new file mode 100644 index 0000000..640f34f --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/register/verify/route.ts @@ -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 }) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e045505..f8062a4 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -31,7 +31,8 @@ function LoginForm() { // MFA-Zwischenschritt const [mfaToken, setMfaToken] = useState(null) const [mfaCode, setMfaCode] = useState('') - const { login, verifyMfa } = useAuth() + const [mfaMethods, setMfaMethods] = useState<{ totp: boolean; webauthn: boolean }>({ totp: false, webauthn: false }) + const { login, verifyMfa, verifyMfaWebAuthn } = useAuth() const router = useRouter() const searchParams = useSearchParams() const { toast } = useToast() @@ -79,6 +80,7 @@ function LoginForm() { } else if (result.mfaRequired && result.mfaToken) { // Zweiter Faktor nötig — Code-Schritt anzeigen. setMfaToken(result.mfaToken) + if (result.methods) setMfaMethods(result.methods) } else { toast({ title: 'Anmeldung fehlgeschlagen', @@ -104,6 +106,19 @@ function LoginForm() { setIsLoading(false) } + const handleWebAuthn = async () => { + if (!mfaToken) return + setIsLoading(true) + const result = await verifyMfaWebAuthn(mfaToken) + if (result.success) { + toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' }) + await doRedirect() + } else if (result.error !== 'Abgebrochen') { + toast({ title: 'Fehlgeschlagen', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' }) + } + setIsLoading(false) + } + return (
@@ -157,28 +172,45 @@ function LoginForm() { )} {mfaToken && ( -
-
- - setMfaCode(e.target.value)} - disabled={isLoading} - /> -

Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.

-
- +
+

Zweiter Faktor erforderlich.

+ + {mfaMethods.webauthn && ( + + )} + + {mfaMethods.webauthn && mfaMethods.totp && ( +
oder Code
+ )} + + {mfaMethods.totp && ( + +
+ + setMfaCode(e.target.value)} + disabled={isLoading} + /> +

Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.

+
+ + + )} + - +
)}
diff --git a/src/app/sicherheit/page.tsx b/src/app/sicherheit/page.tsx index be0521c..0465c65 100644 --- a/src/app/sicherheit/page.tsx +++ b/src/app/sicherheit/page.tsx @@ -26,6 +26,7 @@ export default function SicherheitPage() {
  • Passwörter: werden mit einem geeigneten Einwegverfahren (bcrypt) gehasht, nie im Klartext gespeichert.
  • Übertragung: verschlüsselt über HTTPS/TLS; HSTS aktiviert.
  • Sitzungen: httpOnly-Session-Cookie, in Produktion mit secure-Flag.
  • +
  • Zwei-Faktor (2FA): optional pro Konto — Authenticator-App (TOTP) und Sicherheitsschlüssel/Passkeys (WebAuthn/YubiKey), mit Backup-Codes.
  • Web-Härtung: Sicherheits-Header (u.a. Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Schutz vor Clickjacking über frame-ancestors).
  • Missbrauchsschutz: Rate-Limiting für Login, Registrierung, Passwort-Reset, Kontaktformular und geteilte Ansichten.
  • Uploads: Validierung und Zugriffsschutz für hochgeladene Dateien.
  • diff --git a/src/components/admin/users-tab.tsx b/src/components/admin/users-tab.tsx index 2bb319d..664d358 100644 --- a/src/components/admin/users-tab.tsx +++ b/src/components/admin/users-tab.tsx @@ -109,6 +109,15 @@ export function UsersTab() { } catch { toast({ title: 'Fehler', variant: 'destructive' }) } } + const handleResetUserMfa = async (targetUser: UserRecord) => { + if (!confirm(`Zwei-Faktor (2FA) von "${targetUser.name}" zurücksetzen? Der Benutzer meldet sich danach nur mit Passwort an, bis er 2FA neu einrichtet.`)) return + try { + const res = await fetch(`/api/admin/users/${targetUser.id}/mfa-reset`, { method: 'POST' }) + if (res.ok) { toast({ title: '2FA zurückgesetzt' }) } + else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) } + } catch { toast({ title: 'Fehler', variant: 'destructive' }) } + } + const handleToggleUserVerified = async (targetUser: UserRecord) => { try { const newVal = !targetUser.emailVerified @@ -251,6 +260,9 @@ export function UsersTab() { + diff --git a/src/components/providers/auth-provider.tsx b/src/components/providers/auth-provider.tsx index 2dd8aaa..eff2269 100644 --- a/src/components/providers/auth-provider.tsx +++ b/src/components/providers/auth-provider.tsx @@ -31,6 +31,7 @@ interface AuthContextType { loading: boolean login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean; mfaToken?: string; methods?: { totp: boolean; webauthn: boolean } }> verifyMfa: (mfaToken: string, code: string) => Promise<{ success: boolean; error?: string }> + verifyMfaWebAuthn: (mfaToken: string) => Promise<{ success: boolean; error?: string }> logout: () => Promise canEdit: () => boolean isAdmin: () => boolean @@ -107,6 +108,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } + const verifyMfaWebAuthn = async (mfaToken: string) => { + try { + const { startAuthentication } = await import('@simplewebauthn/browser') + const optRes = await fetch('/api/auth/mfa-login/webauthn/options', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mfaToken }), + }) + const opt = await optRes.json() + if (!optRes.ok) return { success: false, error: opt.error || 'Fehler' } + + const assertion = await startAuthentication({ optionsJSON: opt.options }) + + const verRes = await fetch('/api/auth/mfa-login/webauthn/verify', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mfaToken, challengeToken: opt.challengeToken, response: assertion }), + }) + const data = await verRes.json() + if (verRes.ok && data.user) { setUser(data.user); return { success: true } } + return { success: false, error: data.error || 'Verifikation fehlgeschlagen' } + } catch (e: any) { + if (e?.name === 'NotAllowedError' || e?.name === 'AbortError') return { success: false, error: 'Abgebrochen' } + return { success: false, error: 'Sicherheitsschlüssel-Fehler' } + } + } + const logout = async () => { try { await fetch('/api/auth/logout', { method: 'POST' }) @@ -130,7 +155,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } return ( - + {children} ) diff --git a/src/components/settings/mfa-settings.tsx b/src/components/settings/mfa-settings.tsx index 59a15fd..14d38a2 100644 --- a/src/components/settings/mfa-settings.tsx +++ b/src/components/settings/mfa-settings.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState, useCallback } from 'react' -import { ShieldCheck, Loader2, KeyRound, Copy, Check } from 'lucide-react' +import { ShieldCheck, Loader2, KeyRound, Copy, Check, Usb, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' @@ -68,6 +68,36 @@ export function MfaSettings() { try { await navigator.clipboard.writeText(backupCodes.join('\n')); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch { /* */ } } + const addWebAuthn = async () => { + setBusy(true) + try { + const { startRegistration } = await import('@simplewebauthn/browser') + const optRes = await fetch('/api/auth/mfa/webauthn/register/options', { method: 'POST' }) + const opt = await optRes.json() + if (!optRes.ok) { toast({ title: 'Fehler', description: opt.error, variant: 'destructive' }); return } + const att = await startRegistration({ optionsJSON: opt.options }) + const name = (typeof window !== 'undefined' && window.prompt('Name für diesen Schlüssel (z. B. „YubiKey blau", „iPhone"):', 'Sicherheitsschlüssel')) || 'Sicherheitsschlüssel' + const res = await fetch('/api/auth/mfa/webauthn/register/verify', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response: att, challengeToken: opt.challengeToken, deviceName: name }), + }) + const d = await res.json() + if (res.ok) { toast({ title: 'Schlüssel registriert' }); refresh() } + else toast({ title: 'Fehler', description: d.error, variant: 'destructive' }) + } catch (e: any) { + if (e?.name !== 'NotAllowedError' && e?.name !== 'AbortError') toast({ title: 'Abgebrochen oder nicht unterstützt', variant: 'destructive' }) + } finally { setBusy(false) } + } + + const removeWebAuthn = async (id: string) => { + if (!confirm2('Diesen Sicherheitsschlüssel entfernen?')) return + setBusy(true) + try { + const res = await fetch(`/api/auth/mfa/webauthn/${id}`, { method: 'DELETE' }) + if (res.ok) { toast({ title: 'Entfernt' }); refresh() } + } finally { setBusy(false) } + } + return (

    @@ -125,6 +155,27 @@ export function MfaSettings() { Authenticator-App einrichten )} + + {/* Sicherheitsschlüssel / Passkeys (WebAuthn) */} + {status && ( +
    +

    Sicherheitsschlüssel & Passkeys

    + {status.webauthn.length > 0 && ( +
      + {status.webauthn.map(w => ( +
    • + {w.deviceName || 'Sicherheitsschlüssel'} + +
    • + ))} +
    + )} + +

    YubiKey, Touch-ID/Windows Hello oder Passkey. Funktioniert nur über HTTPS.

    +
    + )}

    ) } diff --git a/src/lib/webauthn.ts b/src/lib/webauthn.ts new file mode 100644 index 0000000..9b6fdc4 --- /dev/null +++ b/src/lib/webauthn.ts @@ -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 { + 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 { + 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 + } +}