Compare commits
3 Commits
5cdf0e63c9
...
f58f1b31bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58f1b31bf | ||
|
|
f588b6d361 | ||
|
|
fa1e911ecd |
@@ -9,7 +9,7 @@ Abgeleitet aus dem tatsächlichen Code-Stand. Offene Fristen/Standorte als Platz
|
||||
| Passwort (bcrypt-Hash) | Authentifizierung | Benutzer | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | mit Konto | Einweg-Hash (bcrypt cost 12) |
|
||||
| Einsatz-/Inhaltsdaten (Pläne, Journale, Zeichnungen, Koordinaten) | Kernfunktion | Org-Mitglieder, ggf. Dritte | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Löschung durch Org | Projekt-/Orglöschung; Backups | Mandantentrennung, Zugriffskontrolle |
|
||||
| Hochgeladene Dateien (Logos, Planbilder, Symbole) | Darstellung | Org | Upload | MinIO | — | `[SERVERSTANDORT]` | bis Löschung | mit Projekt/Org | Zugriffsschutz, Validierung |
|
||||
| Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext) | Nachweis Einwilligung/Vertrag | Benutzer | System | PostgreSQL | — | `[SERVERSTANDORT]` | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | minimale Daten (keine roh-IP) |
|
||||
| Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext, IP, User-Agent, Hash) | Nachweis Einwilligung/Vertrag (Beweiszweck) | Benutzer | System | PostgreSQL (Schweiz, Eigenbetrieb) | — | nein | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | Zugriffskontrolle; IP/User-Agent bewusst zu Beweiszwecken erfasst |
|
||||
| Protokoll-/Sicherheitsdaten | Betrieb, Sicherheit | Benutzer | System | Server-Logs | — | `[SERVERSTANDORT]` | `[FRIST]` | rotierend | Minimierung sensibler Inhalte |
|
||||
| Session-Cookie `auth-token` | Anmeldung | Benutzer | System | Client-Cookie | — | — | 24 h / 14 Tage | Ablauf/Logout | httpOnly, secure (prod), sameSite=lax |
|
||||
| E-Mail-Versand (Verifizierung, Reset, Kontakt, Einladung) | Kommunikation | Benutzer/Empfänger | System/Eingabe | SMTP-Anbieter | E-Mail-Anbieter | `[ABHÄNGIG]` | beim Anbieter | — | TLS, nur bei Bedarf |
|
||||
|
||||
@@ -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
|
||||
|
||||
337
package-lock.json
generated
337
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lageplan",
|
||||
"version": "1.7.3",
|
||||
"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",
|
||||
@@ -38,6 +40,7 @@
|
||||
"minio": "^7.1.3",
|
||||
"next": "^15.1.0",
|
||||
"nodemailer": "^6.9.8",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rdndmb-html5-to-touch": "^9.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -769,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",
|
||||
@@ -1336,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",
|
||||
@@ -1624,6 +1639,56 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
@@ -1634,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",
|
||||
@@ -3382,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",
|
||||
@@ -4528,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",
|
||||
@@ -8653,6 +8925,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/overlap-area": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz",
|
||||
@@ -9091,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",
|
||||
@@ -9458,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",
|
||||
@@ -10582,6 +10889,14 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/through2": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||
@@ -10732,6 +11047,24 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.7.7",
|
||||
"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",
|
||||
@@ -54,6 +56,7 @@
|
||||
"minio": "^7.1.3",
|
||||
"next": "^15.1.0",
|
||||
"nodemailer": "^6.9.8",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rdndmb-html5-to-touch": "^9.0.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -183,6 +183,24 @@ async function migrate() {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`,
|
||||
`CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`,
|
||||
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "ipAddress" TEXT`,
|
||||
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" TEXT`,
|
||||
// MFA / Zwei-Faktor
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpSecret" TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpConfirmedAt" TIMESTAMP(3)`,
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "mfaBackupCodes" TEXT[] NOT NULL DEFAULT '{}'`,
|
||||
`CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
"credentialId" TEXT NOT NULL UNIQUE,
|
||||
"publicKey" TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
"deviceName" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS webauthn_credentials_user_idx ON webauthn_credentials("userId")`,
|
||||
]
|
||||
for (const sql of tableMigrations) {
|
||||
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
||||
|
||||
@@ -126,6 +126,12 @@ model User {
|
||||
resetToken String? @unique
|
||||
resetTokenExpiry DateTime?
|
||||
lastLoginAt DateTime?
|
||||
// ─── Zwei-Faktor-Authentisierung (MFA) ───
|
||||
// TOTP (Authenticator-App): Secret gesetzt = Einrichtung begonnen; confirmedAt gesetzt = aktiv.
|
||||
totpSecret String?
|
||||
totpConfirmedAt DateTime?
|
||||
// Einmalige Backup-Codes (bcrypt-gehasht), Fallback wenn kein Faktor verfügbar.
|
||||
mfaBackupCodes String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -135,10 +141,29 @@ model User {
|
||||
upgradeRequests UpgradeRequest[]
|
||||
rapports Rapport[]
|
||||
legalAcceptances LegalAcceptance[]
|
||||
webauthnCredentials WebAuthnCredential[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// WebAuthn/FIDO2 (YubiKey, Passkeys, Touch-ID) — registrierte Sicherheitsschlüssel je Benutzer.
|
||||
model WebAuthnCredential {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
credentialId String @unique
|
||||
publicKey String
|
||||
counter Int @default(0)
|
||||
transports String?
|
||||
deviceName String?
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("webauthn_credentials")
|
||||
}
|
||||
|
||||
// ─── Rechtsdokumente & revisionssichere Zustimmungen ──────────
|
||||
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
|
||||
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT
|
||||
@@ -173,6 +198,9 @@ model LegalAcceptance {
|
||||
locale String?
|
||||
// REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION
|
||||
context String
|
||||
// Nachweisdaten (zu Beweiszwecken der Einwilligung): IP + Gerät/Browser zum Zeitpunkt der Zustimmung.
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuth } from '@/components/providers/auth-provider'
|
||||
|
||||
interface Doc { id: string; type: string; version: string; title: string; url: string | null; isActive: boolean; publishedAt: string; contentHash: string; acceptanceCount: number }
|
||||
interface Miss { type: string; version: string; missing: number }
|
||||
interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null }
|
||||
interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null; ipAddress: string | null; userAgent: string | null; contentHash: string | null }
|
||||
|
||||
export default function AdminLegalPage() {
|
||||
const { user, loading, isServerAdmin } = useAuth()
|
||||
@@ -137,6 +137,8 @@ export default function AdminLegalPage() {
|
||||
<th className="px-3 py-2">Benutzer</th>
|
||||
<th className="px-3 py-2">Dokument</th>
|
||||
<th className="px-3 py-2">Kontext</th>
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Gerät / Browser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -144,8 +146,10 @@ export default function AdminLegalPage() {
|
||||
<tr key={a.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}</td>
|
||||
<td className="px-3 py-2"><span className="text-foreground">{a.name}</span><br /><span className="text-xs text-muted-foreground">{a.email}</span></td>
|
||||
<td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span></td>
|
||||
<td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span>{a.contentHash && <><br /><span className="font-mono text-[10px] text-muted-foreground">{a.contentHash.slice(0, 12)}…</span></>}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground font-mono whitespace-nowrap">{a.ipAddress || '—'}</td>
|
||||
<td className="px-3 py-2 text-[10px] text-muted-foreground max-w-[200px] truncate" title={a.userAgent || ''}>{a.userAgent || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function GET(req: NextRequest) {
|
||||
where: userFilter,
|
||||
orderBy: { acceptedAt: 'desc' },
|
||||
take: 2000,
|
||||
select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true },
|
||||
select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true, contentHash: true, ipAddress: true, userAgent: true },
|
||||
})
|
||||
|
||||
// Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten.
|
||||
@@ -43,11 +43,11 @@ export async function GET(req: NextRequest) {
|
||||
}))
|
||||
|
||||
if (format === 'csv') {
|
||||
const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'organizationId']
|
||||
const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'ipAddress', 'userAgent', 'contentHash', 'organizationId']
|
||||
const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"`
|
||||
const lines = [header.join(',')]
|
||||
for (const r of rows) {
|
||||
lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.organizationId || ''].map(esc).join(','))
|
||||
lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.ipAddress || '', r.userAgent || '', r.contentHash || '', r.organizationId || ''].map(esc).join(','))
|
||||
}
|
||||
return new NextResponse(lines.join('\r\n'), {
|
||||
headers: {
|
||||
|
||||
38
src/app/api/admin/users/[id]/mfa-reset/route.ts
Normal file
38
src/app/api/admin/users/[id]/mfa-reset/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession, isAdmin } from '@/lib/auth'
|
||||
|
||||
// POST: setzt die MFA eines Benutzers zurück (TOTP + Backup-Codes + WebAuthn entfernen).
|
||||
// SERVER_ADMIN: jeden Benutzer. TENANT_ADMIN: nur Benutzer der eigenen Organisation.
|
||||
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
if (!session || !isAdmin(session.role)) {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
// TENANT_ADMIN darf nur eigene Organisationsmitglieder zurücksetzen.
|
||||
if (session.role !== 'SERVER_ADMIN') {
|
||||
if (!session.tenantId) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
const membership = await (prisma as any).tenantMembership.findFirst({
|
||||
where: { userId: id, tenantId: session.tenantId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!membership) return NextResponse.json({ error: 'Benutzer nicht in Ihrer Organisation' }, { status: 403 })
|
||||
}
|
||||
|
||||
await (prisma as any).$transaction([
|
||||
(prisma as any).webAuthnCredential.deleteMany({ where: { userId: id } }),
|
||||
(prisma as any).user.update({
|
||||
where: { id },
|
||||
data: { totpSecret: null, totpConfirmedAt: null, mfaBackupCodes: [] },
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error resetting MFA:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { login, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
|
||||
import { login, createToken, createMfaToken, userHasMfa, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validations'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
@@ -36,6 +36,23 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Zweiter Faktor erforderlich? Dann noch KEINE Session setzen, sondern MFA-Zwischentoken liefern.
|
||||
if (await userHasMfa(result.user.id)) {
|
||||
const totpEnabled = await (prisma as any).user.findUnique({
|
||||
where: { id: result.user.id },
|
||||
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
|
||||
})
|
||||
const mfaToken = await createMfaToken(result.user.id, rememberMe)
|
||||
return NextResponse.json({
|
||||
mfaRequired: true,
|
||||
mfaToken,
|
||||
methods: {
|
||||
totp: !!totpEnabled?.totpConfirmedAt,
|
||||
webauthn: (totpEnabled?._count?.webauthnCredentials ?? 0) > 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update lastLoginAt
|
||||
try {
|
||||
await (prisma as any).user.update({
|
||||
|
||||
64
src/app/api/auth/mfa-login/verify/route.ts
Normal file
64
src/app/api/auth/mfa-login/verify/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { verifyMfaToken, buildSessionForUser, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
|
||||
import { verifyTotp, verifyBackupCode } from '@/lib/mfa'
|
||||
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
// POST (öffentlich): zweiter Faktor per TOTP-Code ODER Backup-Code. Setzt bei Erfolg die Session.
|
||||
// body: { mfaToken, code }
|
||||
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. Bitte erneut anmelden.' }, { status: 401 })
|
||||
|
||||
const code = String(body.code || '')
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { id: mfa.userId },
|
||||
select: { id: true, totpSecret: true, totpConfirmedAt: true, mfaBackupCodes: true },
|
||||
})
|
||||
if (!user) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 401 })
|
||||
|
||||
let ok = false
|
||||
// 1) TOTP
|
||||
if (user.totpConfirmedAt && user.totpSecret && verifyTotp(user.totpSecret, code)) {
|
||||
ok = true
|
||||
} else {
|
||||
// 2) Backup-Code — bei Treffer entwerten
|
||||
const idx = await verifyBackupCode(user.mfaBackupCodes || [], code)
|
||||
if (idx >= 0) {
|
||||
ok = true
|
||||
const remaining = [...(user.mfaBackupCodes as string[])]
|
||||
remaining.splice(idx, 1)
|
||||
await (prisma as any).user.update({ where: { id: user.id }, data: { mfaBackupCodes: remaining } })
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) return NextResponse.json({ error: 'Code ungültig.' }, { status: 401 })
|
||||
|
||||
const session = await buildSessionForUser(user.id)
|
||||
if (!session.success || !session.user) {
|
||||
return NextResponse.json({ error: session.error || 'Anmeldung fehlgeschlagen' }, { status: 401 })
|
||||
}
|
||||
|
||||
try { await (prisma as any).user.update({ where: { id: user.id }, 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('MFA verify error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
28
src/app/api/auth/mfa/status/route.ts
Normal file
28
src/app/api/auth/mfa/status/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
// GET: MFA-Status des angemeldeten Benutzers für die Einstellungen.
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { id: session.id },
|
||||
select: { totpConfirmedAt: true, mfaBackupCodes: true, webauthnCredentials: { select: { id: true, deviceName: true, createdAt: true, lastUsedAt: true } } },
|
||||
})
|
||||
|
||||
const totpEnabled = !!user?.totpConfirmedAt
|
||||
const credentials = user?.webauthnCredentials || []
|
||||
return NextResponse.json({
|
||||
totpEnabled,
|
||||
backupCodesRemaining: (user?.mfaBackupCodes || []).length,
|
||||
webauthn: credentials,
|
||||
hasMfa: totpEnabled || credentials.length > 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching MFA status:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/mfa/totp/confirm/route.ts
Normal file
46
src/app/api/auth/mfa/totp/confirm/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyTotp, generateBackupCodes } from '@/lib/mfa'
|
||||
|
||||
// POST: bestätigt die TOTP-Einrichtung mit einem Code aus der App. Bei Erfolg wird TOTP aktiviert
|
||||
// und einmalige Backup-Codes werden erzeugt (Klartext nur hier einmalig zurückgegeben).
|
||||
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 code = String(body.code || '')
|
||||
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { id: session.id },
|
||||
select: { totpSecret: true, totpConfirmedAt: true, mfaBackupCodes: true },
|
||||
})
|
||||
if (!user?.totpSecret) {
|
||||
return NextResponse.json({ error: 'Keine Einrichtung offen. Bitte erneut starten.' }, { status: 400 })
|
||||
}
|
||||
if (!verifyTotp(user.totpSecret, code)) {
|
||||
return NextResponse.json({ error: 'Code ungültig. Bitte erneut versuchen.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Backup-Codes nur erzeugen, wenn noch keine existieren (Neuaktivierung).
|
||||
let plainCodes: string[] | null = null
|
||||
let hashed = user.mfaBackupCodes as string[]
|
||||
if (!user.totpConfirmedAt || !hashed || hashed.length === 0) {
|
||||
const gen = await generateBackupCodes(10)
|
||||
plainCodes = gen.plain
|
||||
hashed = gen.hashed
|
||||
}
|
||||
|
||||
await (prisma as any).user.update({
|
||||
where: { id: session.id },
|
||||
data: { totpConfirmedAt: new Date(), mfaBackupCodes: hashed },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, backupCodes: plainCodes })
|
||||
} catch (error) {
|
||||
console.error('Error confirming TOTP:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
22
src/app/api/auth/mfa/totp/disable/route.ts
Normal file
22
src/app/api/auth/mfa/totp/disable/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
// POST: deaktiviert TOTP für den angemeldeten Benutzer. Backup-Codes werden nur entfernt,
|
||||
// wenn auch kein WebAuthn mehr aktiv ist (sonst bleiben sie als Fallback bestehen).
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const cnt = await (prisma as any).webAuthnCredential.count({ where: { userId: session.id } })
|
||||
const data: any = { totpSecret: null, totpConfirmedAt: null }
|
||||
if (cnt === 0) data.mfaBackupCodes = []
|
||||
|
||||
await (prisma as any).user.update({ where: { id: session.id }, data })
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error disabling TOTP:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
31
src/app/api/auth/mfa/totp/setup/route.ts
Normal file
31
src/app/api/auth/mfa/totp/setup/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import QRCode from 'qrcode'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateTotpSecret, totpKeyUri } from '@/lib/mfa'
|
||||
|
||||
// POST: startet die TOTP-Einrichtung — erzeugt ein Secret (noch nicht bestätigt) und liefert
|
||||
// otpauth-URI + QR-Code zum Scannen. Aktiv wird TOTP erst nach /confirm.
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const secret = generateTotpSecret()
|
||||
const uri = totpKeyUri(session.email, secret)
|
||||
|
||||
// Secret vormerken (unbestätigt). totpConfirmedAt bleibt NULL bis zur Bestätigung.
|
||||
await (prisma as any).user.update({
|
||||
where: { id: session.id },
|
||||
data: { totpSecret: secret, totpConfirmedAt: null },
|
||||
})
|
||||
|
||||
let qr = ''
|
||||
try { qr = await QRCode.toDataURL(uri, { width: 220, margin: 1 }) } catch { /* QR optional */ }
|
||||
|
||||
return NextResponse.json({ secret, uri, qr })
|
||||
} catch (error) {
|
||||
console.error('Error setting up TOTP:', 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 })
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,8 @@ export async function POST(req: NextRequest) {
|
||||
context: 'REGISTRATION',
|
||||
types: ['TERMS', 'PRIVACY'],
|
||||
locale: 'de-CH',
|
||||
ipAddress: getClientIp(req),
|
||||
userAgent: req.headers.get('user-agent'),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getClientIp } from '@/lib/rate-limit'
|
||||
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
|
||||
|
||||
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT']
|
||||
@@ -38,6 +39,8 @@ export async function POST(req: NextRequest) {
|
||||
context,
|
||||
types,
|
||||
locale: 'de-CH',
|
||||
ipAddress: getClientIp(req),
|
||||
userAgent: req.headers.get('user-agent'),
|
||||
})
|
||||
|
||||
const isOrgAdmin = user.role === 'TENANT_ADMIN'
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DatenschutzPage() {
|
||||
<li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li>
|
||||
<li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li>
|
||||
<li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li>
|
||||
<li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde.</li>
|
||||
<li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde — zu Beweiszwecken zusammen mit IP-Adresse und Gerät/Browser (User-Agent) sowie dem Dokument-Prüfwert (Hash).</li>
|
||||
<li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -28,7 +28,11 @@ function LoginForm() {
|
||||
const [resendSuccess, setResendSuccess] = useState(false)
|
||||
const [tenantLogo, setTenantLogo] = useState<string | null>(null)
|
||||
const [tenantName, setTenantName] = useState<string | null>(null)
|
||||
const { login } = useAuth()
|
||||
// MFA-Zwischenschritt
|
||||
const [mfaToken, setMfaToken] = useState<string | null>(null)
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
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()
|
||||
@@ -52,6 +56,18 @@ function LoginForm() {
|
||||
}
|
||||
}, [tenantSlug])
|
||||
|
||||
const doRedirect = async () => {
|
||||
if (redirectTo) { router.push(redirectTo); return }
|
||||
try {
|
||||
const meRes = await fetch('/api/auth/me')
|
||||
if (meRes.ok) {
|
||||
const meData = await meRes.json()
|
||||
if (meData.user?.tenantSlug) { router.push(`/${meData.user.tenantSlug}`); return }
|
||||
}
|
||||
} catch {}
|
||||
router.push('/app')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
@@ -59,26 +75,12 @@ function LoginForm() {
|
||||
const result = await login(email, password, rememberMe)
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Erfolgreich angemeldet',
|
||||
description: 'Willkommen bei Lageplan!',
|
||||
})
|
||||
// Redirect: use explicit redirect param, or tenant slug, or /app
|
||||
if (redirectTo) {
|
||||
router.push(redirectTo)
|
||||
} else {
|
||||
try {
|
||||
const meRes = await fetch('/api/auth/me')
|
||||
if (meRes.ok) {
|
||||
const meData = await meRes.json()
|
||||
if (meData.user?.tenantSlug) {
|
||||
router.push(`/${meData.user.tenantSlug}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
router.push('/app')
|
||||
}
|
||||
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
|
||||
await doRedirect()
|
||||
} 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',
|
||||
@@ -90,6 +92,33 @@ function LoginForm() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleMfaSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!mfaToken) return
|
||||
setIsLoading(true)
|
||||
const result = await verifyMfa(mfaToken, mfaCode)
|
||||
if (result.success) {
|
||||
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
|
||||
await doRedirect()
|
||||
} else {
|
||||
toast({ title: 'Code ungültig', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' })
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleWebAuthn = async () => {
|
||||
if (!mfaToken) return
|
||||
setIsLoading(true)
|
||||
const result = await verifyMfaWebAuthn(mfaToken)
|
||||
if (result.success) {
|
||||
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
|
||||
await doRedirect()
|
||||
} else if (result.error !== 'Abgebrochen') {
|
||||
toast({ title: 'Fehlgeschlagen', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' })
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
@@ -142,7 +171,49 @@ function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mfaToken && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">Zweiter Faktor erforderlich.</p>
|
||||
|
||||
{mfaMethods.webauthn && (
|
||||
<Button type="button" onClick={handleWebAuthn} disabled={isLoading} className="w-full bg-red-600 hover:bg-red-700">
|
||||
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Warten...</>) : 'Mit Sicherheitsschlüssel / Passkey anmelden'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mfaMethods.webauthn && mfaMethods.totp && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500"><span className="flex-1 h-px bg-gray-700" />oder Code<span className="flex-1 h-px bg-gray-700" /></div>
|
||||
)}
|
||||
|
||||
{mfaMethods.totp && (
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mfa-code">Bestätigungscode</Label>
|
||||
<Input
|
||||
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">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className={`space-y-4 ${mfaToken ? 'hidden' : ''}`}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText,
|
||||
} from 'lucide-react'
|
||||
import { MfaSettings } from '@/components/settings/mfa-settings'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [tenant, setTenant] = useState<any>(null)
|
||||
@@ -120,6 +121,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zwei-Faktor-Authentisierung */}
|
||||
<MfaSettings />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-800/50 p-5">
|
||||
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function SicherheitPage() {
|
||||
<li><strong>Passwörter:</strong> werden mit einem geeigneten Einwegverfahren (bcrypt) gehasht, nie im Klartext gespeichert.</li>
|
||||
<li><strong>Übertragung:</strong> verschlüsselt über HTTPS/TLS; HSTS aktiviert.</li>
|
||||
<li><strong>Sitzungen:</strong> httpOnly-Session-Cookie, in Produktion mit <code>secure</code>-Flag.</li>
|
||||
<li><strong>Zwei-Faktor (2FA):</strong> optional pro Konto — Authenticator-App (TOTP) und Sicherheitsschlüssel/Passkeys (WebAuthn/YubiKey), mit Backup-Codes.</li>
|
||||
<li><strong>Web-Härtung:</strong> Sicherheits-Header (u.a. Content-Security-Policy, <code>X-Content-Type-Options</code>, Referrer-Policy, Schutz vor Clickjacking über <code>frame-ancestors</code>).</li>
|
||||
<li><strong>Missbrauchsschutz:</strong> Rate-Limiting für Login, Registrierung, Passwort-Reset, Kontaktformular und geteilte Ansichten.</li>
|
||||
<li><strong>Uploads:</strong> Validierung und Zugriffsschutz für hochgeladene Dateien.</li>
|
||||
|
||||
@@ -109,6 +109,15 @@ export function UsersTab() {
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}
|
||||
|
||||
const handleResetUserMfa = async (targetUser: UserRecord) => {
|
||||
if (!confirm(`Zwei-Faktor (2FA) von "${targetUser.name}" zurücksetzen? Der Benutzer meldet sich danach nur mit Passwort an, bis er 2FA neu einrichtet.`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${targetUser.id}/mfa-reset`, { method: 'POST' })
|
||||
if (res.ok) { toast({ title: '2FA zurückgesetzt' }) }
|
||||
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}
|
||||
|
||||
const handleToggleUserVerified = async (targetUser: UserRecord) => {
|
||||
try {
|
||||
const newVal = !targetUser.emailVerified
|
||||
@@ -251,6 +260,9 @@ export function UsersTab() {
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen" onClick={() => handleResetUserPassword(u)}>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Zwei-Faktor (2FA) zurücksetzen" onClick={() => handleResetUserMfa(u)}>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => openEditUser(u)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,9 @@ interface AuthContextType {
|
||||
user: User | null
|
||||
tenant: TenantInfo | null
|
||||
loading: boolean
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string }>
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean; mfaToken?: string; methods?: { totp: boolean; webauthn: boolean } }>
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<{ success: boolean; error?: string }>
|
||||
verifyMfaWebAuthn: (mfaToken: string) => Promise<{ success: boolean; error?: string }>
|
||||
logout: () => Promise<void>
|
||||
canEdit: () => boolean
|
||||
isAdmin: () => boolean
|
||||
@@ -77,12 +79,59 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Zweiter Faktor erforderlich — Login noch nicht abgeschlossen.
|
||||
if (res.ok && data.mfaRequired) {
|
||||
return { success: false, mfaRequired: true, mfaToken: data.mfaToken, methods: data.methods }
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || 'Login fehlgeschlagen' }
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Verbindungsfehler' }
|
||||
}
|
||||
}
|
||||
|
||||
const verifyMfa = async (mfaToken: string, code: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/mfa-login/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mfaToken, code }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.user) {
|
||||
setUser(data.user)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: data.error || 'Code ungültig' }
|
||||
} catch {
|
||||
return { success: false, error: 'Verbindungsfehler' }
|
||||
}
|
||||
}
|
||||
|
||||
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' })
|
||||
@@ -106,7 +155,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, tenant, loading, login, logout, canEdit, isAdmin, isServerAdmin }}>
|
||||
<AuthContext.Provider value={{ user, tenant, loading, login, verifyMfa, verifyMfaWebAuthn, logout, canEdit, isAdmin, isServerAdmin }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
186
src/components/settings/mfa-settings.tsx
Normal file
186
src/components/settings/mfa-settings.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from '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'
|
||||
|
||||
interface Status { totpEnabled: boolean; backupCodesRemaining: number; webauthn: { id: string; deviceName: string | null }[]; hasMfa: boolean }
|
||||
|
||||
export function MfaSettings() {
|
||||
const { toast } = useToast()
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/mfa/status')
|
||||
if (res.ok) setStatus(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const startSetup = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/mfa/totp/setup', { method: 'POST' })
|
||||
const d = await res.json()
|
||||
if (res.ok) setSetup({ qr: d.qr, secret: d.secret })
|
||||
else toast({ title: 'Fehler', description: d.error, variant: 'destructive' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/mfa/totp/confirm', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (res.ok) {
|
||||
setSetup(null); setCode('')
|
||||
if (d.backupCodes) setBackupCodes(d.backupCodes)
|
||||
toast({ title: 'Zwei-Faktor aktiviert', description: 'Ab jetzt beim Login erforderlich.' })
|
||||
refresh()
|
||||
} else {
|
||||
toast({ title: 'Code ungültig', description: d.error, variant: 'destructive' })
|
||||
}
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const disable = async () => {
|
||||
if (!confirm2('Zwei-Faktor wirklich deaktivieren? Dein Konto ist danach nur noch mit Passwort geschützt.')) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/mfa/totp/disable', { method: 'POST' })
|
||||
if (res.ok) { toast({ title: 'Deaktiviert', description: 'Authenticator-App entfernt.' }); refresh() }
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const copyBackup = async () => {
|
||||
if (!backupCodes) return
|
||||
try { await navigator.clipboard.writeText(backupCodes.join('\n')); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch { /* */ }
|
||||
}
|
||||
|
||||
const addWebAuthn = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser')
|
||||
const optRes = await fetch('/api/auth/mfa/webauthn/register/options', { method: 'POST' })
|
||||
const opt = await optRes.json()
|
||||
if (!optRes.ok) { toast({ title: 'Fehler', description: opt.error, variant: 'destructive' }); return }
|
||||
const att = await startRegistration({ optionsJSON: opt.options })
|
||||
const name = (typeof window !== 'undefined' && window.prompt('Name für diesen Schlüssel (z. B. „YubiKey blau", „iPhone"):', 'Sicherheitsschlüssel')) || 'Sicherheitsschlüssel'
|
||||
const res = await fetch('/api/auth/mfa/webauthn/register/verify', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ response: att, challengeToken: opt.challengeToken, deviceName: name }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (res.ok) { toast({ title: 'Schlüssel registriert' }); refresh() }
|
||||
else toast({ title: 'Fehler', description: d.error, variant: 'destructive' })
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'NotAllowedError' && e?.name !== 'AbortError') toast({ title: 'Abgebrochen oder nicht unterstützt', variant: 'destructive' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const removeWebAuthn = async (id: string) => {
|
||||
if (!confirm2('Diesen Sicherheitsschlüssel entfernen?')) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/auth/mfa/webauthn/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) { toast({ title: 'Entfernt' }); refresh() }
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border p-5">
|
||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
|
||||
Zwei-Faktor-Authentisierung (2FA)
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Zusätzlicher Schutz beim Login mit einer Authenticator-App. Empfohlen — besonders für Administratoren.
|
||||
</p>
|
||||
|
||||
{/* Einmalig angezeigte Backup-Codes */}
|
||||
{backupCodes && (
|
||||
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-4">
|
||||
<p className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-2">Backup-Codes — jetzt sicher speichern!</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mb-3">Jeder Code funktioniert einmal, falls du keinen Zugriff auf die App hast. Sie werden nur dieses eine Mal angezeigt.</p>
|
||||
<div className="grid grid-cols-2 gap-1.5 font-mono text-sm">
|
||||
{backupCodes.map(c => <span key={c} className="bg-white dark:bg-black/30 rounded px-2 py-1 text-center">{c}</span>)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" variant="outline" onClick={copyBackup}>{copied ? <Check className="w-4 h-4 mr-1 text-green-600" /> : <Copy className="w-4 h-4 mr-1" />} Kopieren</Button>
|
||||
<Button size="sm" onClick={() => setBackupCodes(null)}>Gespeichert</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status ? (
|
||||
<div className="py-4 flex justify-center"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
) : status.totpEnabled ? (
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-400 px-2.5 py-1 font-medium">
|
||||
<ShieldCheck className="w-3.5 h-3.5" /> Authenticator-App aktiv
|
||||
</span>
|
||||
<span className="ml-3 text-muted-foreground text-xs">{status.backupCodesRemaining} Backup-Codes übrig</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={disable} disabled={busy} className="text-destructive hover:text-destructive">Deaktivieren</Button>
|
||||
</div>
|
||||
) : setup ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">1. Scanne den QR-Code mit Google Authenticator, Microsoft Authenticator, Authy o. Ä.:</p>
|
||||
{setup.qr && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={setup.qr} alt="TOTP QR-Code" className="w-44 h-44 rounded-lg border border-border" />
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Manuell: <code className="font-mono">{setup.secret}</code></p>
|
||||
<p className="text-sm">2. Gib den angezeigten 6-stelligen Code ein:</p>
|
||||
<div className="flex gap-2">
|
||||
<Input inputMode="numeric" placeholder="123456" value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} className="font-mono tracking-widest w-40" />
|
||||
<Button onClick={confirm} disabled={busy || code.length !== 6}>{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Aktivieren'}</Button>
|
||||
<Button variant="outline" onClick={() => { setSetup(null); setCode('') }}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={startSetup} disabled={busy}>
|
||||
<KeyRound className="w-4 h-4 mr-2" /> Authenticator-App einrichten
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Sicherheitsschlüssel / Passkeys (WebAuthn) */}
|
||||
{status && (
|
||||
<div className="mt-5 pt-5 border-t border-border">
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2"><Usb className="w-4 h-4 text-muted-foreground" /> Sicherheitsschlüssel & Passkeys</h3>
|
||||
{status.webauthn.length > 0 && (
|
||||
<ul className="mb-3 space-y-1.5">
|
||||
{status.webauthn.map(w => (
|
||||
<li key={w.id} className="flex items-center justify-between text-sm rounded-lg border border-border px-3 py-1.5">
|
||||
<span className="flex items-center gap-2"><KeyRound className="w-3.5 h-3.5 text-muted-foreground" /> {w.deviceName || 'Sicherheitsschlüssel'}</span>
|
||||
<button onClick={() => removeWebAuthn(w.id)} disabled={busy} className="text-destructive hover:opacity-70" title="Entfernen"><Trash2 className="w-4 h-4" /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={addWebAuthn} disabled={busy}>
|
||||
<Usb className="w-4 h-4 mr-2" /> Sicherheitsschlüssel / Passkey hinzufügen
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">YubiKey, Touch-ID/Windows Hello oder Passkey. Funktioniert nur über HTTPS.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vermeidet Namenskollision mit dem lokalen confirm()-State-Setter oben.
|
||||
function confirm2(msg: string): boolean {
|
||||
return typeof window !== 'undefined' ? window.confirm(msg) : true
|
||||
}
|
||||
@@ -46,42 +46,26 @@ export async function getSession(): Promise<UserPayload | null> {
|
||||
return await verifyToken(token)
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
/**
|
||||
* Baut die Session-Payload für einen (bereits authentifizierten) Benutzer per ID.
|
||||
* Wird sowohl beim normalen Login (nach Passwortprüfung) als auch nach erfolgreicher
|
||||
* MFA-Verifikation verwendet, damit die Logik nur an einer Stelle lebt.
|
||||
*/
|
||||
export async function buildSessionForUser(
|
||||
userId: string
|
||||
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
|
||||
const user = await (prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
password: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
},
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true, role: true, emailVerified: true, isActive: true },
|
||||
}) as any)
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||
if (!isValidPassword) {
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
// Gesperrte Benutzer dürfen sich nicht anmelden (isActive === false).
|
||||
// Tolerant: nur blockieren, wenn explizit false (alte Datensätze ohne Wert bleiben aktiv).
|
||||
if (!user) return { success: false, error: 'Benutzer nicht gefunden' }
|
||||
if (user.isActive === false) {
|
||||
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
|
||||
}
|
||||
|
||||
// Track email verification status (allow login regardless)
|
||||
const emailVerified = (user as any).emailVerified !== false
|
||||
const emailVerified = user.emailVerified !== false
|
||||
|
||||
// Get first tenant membership for non-server-admins
|
||||
let tenantId: string | undefined
|
||||
let tenantSlug: string | undefined
|
||||
if ((user.role as string) !== 'SERVER_ADMIN') {
|
||||
@@ -91,7 +75,6 @@ export async function login(
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
if (membership) {
|
||||
// Check if tenant is active
|
||||
if (!membership.tenant.isActive) {
|
||||
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
|
||||
}
|
||||
@@ -109,10 +92,65 @@ export async function login(
|
||||
tenantSlug,
|
||||
emailVerified,
|
||||
}
|
||||
|
||||
return { success: true, user: userPayload }
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
|
||||
const user = await (prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, password: true, isActive: true },
|
||||
}) as any)
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||
if (!isValidPassword) {
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
if (user.isActive === false) {
|
||||
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
|
||||
}
|
||||
|
||||
return buildSessionForUser(user.id)
|
||||
}
|
||||
|
||||
// ─── MFA: Zwischen-Token nach Passwort, vor zweitem Faktor ───
|
||||
|
||||
/** Kurzlebiges Token (10 Min) zwischen Passwort und zweitem Faktor. Ist KEINE Session. */
|
||||
export async function createMfaToken(userId: string, rememberMe: boolean): Promise<string> {
|
||||
return await new SignJWT({ uid: userId, rememberMe, purpose: 'mfa' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('10m')
|
||||
.sign(JWT_SECRET)
|
||||
}
|
||||
|
||||
export async function verifyMfaToken(token: string): Promise<{ userId: string; rememberMe: boolean } | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||
if (payload.purpose !== 'mfa' || typeof payload.uid !== 'string') return null
|
||||
return { userId: payload.uid, rememberMe: !!payload.rememberMe }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True, wenn der Benutzer einen zweiten Faktor aktiv hat (TOTP bestätigt oder WebAuthn registriert). */
|
||||
export async function userHasMfa(userId: string): Promise<boolean> {
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
|
||||
})
|
||||
if (!user) return false
|
||||
return !!user.totpConfirmedAt || (user._count?.webauthnCredentials ?? 0) > 0
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ interface RecordInput {
|
||||
locale?: string | null
|
||||
/** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */
|
||||
types: LegalDocumentType[]
|
||||
/** Nachweisdaten (Beweiszweck): IP + Gerät/Browser zum Zeitpunkt der Zustimmung. */
|
||||
ipAddress?: string | null
|
||||
userAgent?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +91,8 @@ export async function recordAcceptances(input: RecordInput): Promise<number> {
|
||||
contentHash: d.contentHash,
|
||||
context: input.context,
|
||||
locale: input.locale ?? 'de-CH',
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ? String(input.userAgent).slice(0, 400) : null,
|
||||
}))
|
||||
if (rows.length === 0) return 0
|
||||
const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true })
|
||||
|
||||
54
src/lib/mfa.ts
Normal file
54
src/lib/mfa.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { authenticator } from 'otplib'
|
||||
import { randomBytes } from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { legal } from '@/config/legal'
|
||||
|
||||
// Kleine Toleranz gegen Uhr-Drift (±1 Zeitfenster = ±30s).
|
||||
authenticator.options = { window: 1 }
|
||||
|
||||
/** Neues TOTP-Secret erzeugen (Base32). */
|
||||
export function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret()
|
||||
}
|
||||
|
||||
/** otpauth://-URI für den QR-Code (Authenticator-App). */
|
||||
export function totpKeyUri(email: string, secret: string): string {
|
||||
const issuer = legal.projectName
|
||||
return authenticator.keyuri(email, issuer, secret)
|
||||
}
|
||||
|
||||
/** Prüft einen 6-stelligen TOTP-Code gegen das Secret. */
|
||||
export function verifyTotp(secret: string, token: string): boolean {
|
||||
if (!secret || !token) return false
|
||||
const clean = token.replace(/\s+/g, '')
|
||||
if (!/^\d{6}$/.test(clean)) return false
|
||||
try {
|
||||
return authenticator.verify({ token: clean, secret })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Erzeugt n Backup-Codes im Format xxxxx-xxxxx (Klartext) + bcrypt-Hashes zur Speicherung. */
|
||||
export async function generateBackupCodes(n = 10): Promise<{ plain: string[]; hashed: string[] }> {
|
||||
const plain: string[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const raw = randomBytes(5).toString('hex') // 10 Hex-Zeichen
|
||||
plain.push(`${raw.slice(0, 5)}-${raw.slice(5)}`)
|
||||
}
|
||||
const hashed = await Promise.all(plain.map(c => bcrypt.hash(c.replace(/-/g, ''), 10)))
|
||||
return { plain, hashed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen eingegebenen Backup-Code gegen die gehashte Liste.
|
||||
* Gibt den Index des passenden Codes zurück (zum Entwerten) oder -1.
|
||||
*/
|
||||
export async function verifyBackupCode(hashedList: string[], code: string): Promise<number> {
|
||||
const clean = (code || '').replace(/[\s-]/g, '').toLowerCase()
|
||||
if (clean.length < 8) return -1
|
||||
for (let i = 0; i < hashedList.length; i++) {
|
||||
if (await bcrypt.compare(clean, hashedList[i])) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const PUBLIC_API_PREFIXES = [
|
||||
'/api/auth/verify-email',
|
||||
'/api/auth/resend-verification',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/mfa-login/',
|
||||
'/api/contact',
|
||||
'/api/demo',
|
||||
'/api/donate',
|
||||
|
||||
Reference in New Issue
Block a user