Separater Superadmin-Store + eigener Login + MFA-Pflicht (Phase-1-Härtung Paket 2)
Plattform-Administratoren sind nicht länger isPlatformAdmin-Nutzer innerhalb eines Mandanten, sondern ein getrennter Store mit eigener Auth-Domäne — Voraussetzung für den sicheren Betrieb beim ersten echten Kunden. Store & Migration - Neues Modell PlatformAdmin (kein tenant_id): Argon2id-Hash, TOTP-Secret, Recovery-Codes (nur SHA-256-Hashes), Fehlversuchszähler + Sperre, lastLogin. - AuditLog.tenant_id nullable → mandantenlose Plattform-Ereignisse (scope=platform). - Datenmigration: bestehende isPlatformAdmin-Nutzer in den neuen Store übernommen (gleicher Hash → Login sofort möglich), Flag mandantenweit auf false gesetzt. Getrennter Login + MFA - Zweite NextAuth-Instanz (server/platform-auth.ts) mit eigenem Cookie und eigenem basePath /api/platform-auth; Session trägt bewusst KEINEN tenantId. - TOTP-MFA (otplib): Enrollment beim ersten Login (/platform/enroll-mfa, QR + Klartext-Secret), danach bei jedem Login erzwungen; 10 einmalige Recovery-Codes. - Härtung: Konto-Sperre nach 5 Fehlversuchen (15 min), Audit aller Anmeldungen, Fehlversuche und Sperren (scope=platform). Autorisierung / Trennung - Admin-Konsole nach (platform)/admin verschoben; (platform)/layout.tsx erzwingt Plattform-Session + aktivierte MFA. Mandanten-Session hat KEINEN Zugriff auf /admin. - Mandanten-Shell zeigt keinen /admin-Link mehr; admin-Actions prüfen die Plattform-Session statt des abgelösten Flags. - provision/seed setzen isPlatformAdmin nicht mehr; Seed legt den Demo-Plattform- Admin (admin@demo.example) im getrennten Store an. Browser-verifiziert: Plattform-Login → erzwungenes MFA-Enrollment → Recovery-Codes → /admin; Login ohne Code scheitert (?error=1); Mandanten-Session auf /admin wird auf /platform/login umgeleitet. tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,9 @@ npx tsx prisma/seed.ts
|
||||
npm run dev # http://localhost:3000 (Port 3000, siehe .claude/launch.json)
|
||||
```
|
||||
|
||||
**Demo-Logins** (Passwort `Demo1234!`): `admin@demo.example` (Superadmin + Mandanten-Admin), `auditor@demo.example`, `owner@demo.example`, `user@demo.example`.
|
||||
**Demo-Logins** (Passwort `Demo1234!`): `admin@demo.example` (Mandanten-Admin + ISB), `auditor@demo.example`, `owner@demo.example`, `user@demo.example` — alle über den **Mandanten-Login** `/login`.
|
||||
|
||||
**Plattform-Admin (Betrieb):** getrennter Store + eigener Login `/platform/login` (TOTP-MFA-Pflicht, Enrollment beim ersten Login). Demo-Konto: `admin@demo.example` / `Demo1234!` (gleiche Adresse, aber getrennte Session ohne Mandantenkontext). Das frühere `isPlatformAdmin`-Flag ist abgelöst; die Admin-Konsole `/admin` ist nur mit Plattform-Session erreichbar. Siehe **Paket 2** der Produktionshärtung.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+377
-15
@@ -25,6 +25,8 @@
|
||||
"next": "16.2.10",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-intl": "^4.13.1",
|
||||
"otplib": "^13.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.12.0",
|
||||
@@ -35,6 +37,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
@@ -2394,6 +2397,18 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz",
|
||||
@@ -2700,6 +2715,62 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz",
|
||||
"integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/hotp": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz",
|
||||
"integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.1",
|
||||
"@otplib/uri": "13.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-base32-scure": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz",
|
||||
"integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.1",
|
||||
"@scure/base": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto-noble": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz",
|
||||
"integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@otplib/core": "13.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/totp": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz",
|
||||
"integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.1",
|
||||
"@otplib/hotp": "13.4.1",
|
||||
"@otplib/uri": "13.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/uri": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz",
|
||||
"integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
@@ -3410,6 +3481,15 @@
|
||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sec-ant/readable-stream": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
|
||||
@@ -4163,6 +4243,16 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -4964,7 +5054,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -5465,6 +5554,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
@@ -5582,6 +5680,37 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -5601,7 +5730,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -5614,7 +5742,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -6059,6 +6186,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -6226,6 +6362,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -7542,6 +7684,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
@@ -8258,6 +8409,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -9615,17 +9775,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -10007,6 +10156,20 @@
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
|
||||
"integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.1",
|
||||
"@otplib/hotp": "13.4.1",
|
||||
"@otplib/plugin-base32-scure": "13.4.1",
|
||||
"@otplib/plugin-crypto-noble": "13.4.1",
|
||||
"@otplib/totp": "13.4.1",
|
||||
"@otplib/uri": "13.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -10127,7 +10290,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -10383,6 +10545,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/po-parser": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||
@@ -10676,6 +10847,23 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -10863,6 +11051,15 @@
|
||||
"url": "https://github.com/sponsors/remeda"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -10872,6 +11069,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
@@ -11150,6 +11353,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -12526,6 +12735,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.22",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
||||
@@ -12564,6 +12779,40 @@
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -12595,12 +12844,125 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"next": "16.2.10",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-intl": "^4.13.1",
|
||||
"otplib": "^13.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.12.0",
|
||||
@@ -38,6 +40,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_tenant_id_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "tenant_id" DROP NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "platform_admins" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"mfa_secret" TEXT,
|
||||
"mfa_enrolled_at" TIMESTAMP(3),
|
||||
"recovery_codes" JSONB NOT NULL DEFAULT '[]',
|
||||
"failed_logins" INTEGER NOT NULL DEFAULT 0,
|
||||
"locked_until" TIMESTAMP(3),
|
||||
"last_login_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "platform_admins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "platform_admins_email_key" ON "platform_admins"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- Datenmigration (Phase-1-Härtung Paket 2): bestehende isPlatformAdmin-Nutzer in den
|
||||
-- getrennten PlatformAdmin-Store übernehmen (gleicher Argon2-Hash → Login sofort möglich)
|
||||
-- und das Mandanten-Flag zurücksetzen. MFA wird beim ersten Plattform-Login eingerichtet.
|
||||
INSERT INTO "platform_admins" ("id", "email", "password_hash", "name", "status", "recovery_codes", "failed_logins", "created_at", "updated_at")
|
||||
SELECT gen_random_uuid()::text, lower("email"), "password_hash", "name", 'ACTIVE', '[]'::jsonb, 0, now(), now()
|
||||
FROM "users"
|
||||
WHERE "is_platform_admin" = true
|
||||
ON CONFLICT ("email") DO NOTHING;
|
||||
|
||||
UPDATE "users" SET "is_platform_admin" = false WHERE "is_platform_admin" = true;
|
||||
|
||||
-- platform_admins ist plattformweit (kein tenant_id) → RLS bewusst NICHT aktiviert.
|
||||
-- Zugriff für die eingeschränkte App-Rolle (Härtungs-Iteration) explizit gewähren.
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON "platform_admins" TO isms_app;
|
||||
+25
-3
@@ -166,6 +166,27 @@ model RolePermission {
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
// Plattform-Administratoren (Betreiber). Getrennter Store OHNE tenant_id, eigener
|
||||
// Login (/platform/login), TOTP-MFA-Pflicht + Recovery-Codes. Kein Zugriff auf
|
||||
// Mandanten-Fachdaten (nur Betrieb: Mandanten, Module, Lebenszyklus). §Phase-1-Härtung.
|
||||
model PlatformAdmin {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
name String
|
||||
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
|
||||
mfaSecret String? @map("mfa_secret")
|
||||
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
||||
recoveryCodes Json @default("[]") @map("recovery_codes") // SHA-256-Hashes der Recovery-Codes
|
||||
failedLogins Int @default(0) @map("failed_logins")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("platform_admins")
|
||||
}
|
||||
|
||||
// ── Assets & BIA (ein Modul, SPEC §4.1) ─────────────────────────────────────
|
||||
|
||||
enum AssetType {
|
||||
@@ -692,17 +713,18 @@ model MaturityAssessment {
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
// Nullable: Plattform-Ereignisse (scope=platform, z. B. Superadmin-Login) haben keinen Mandantenbezug.
|
||||
tenantId String? @map("tenant_id")
|
||||
scope String @default("tenant") // tenant | platform
|
||||
actorId String? @map("actor_id")
|
||||
action String // create | update | delete | login | export | …
|
||||
action String // create | update | delete | login | denied | export | …
|
||||
entity String // z. B. asset, risk, user
|
||||
entityId String? @map("entity_id")
|
||||
before Json?
|
||||
after Json?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([tenantId, entity, entityId])
|
||||
@@index([tenantId, createdAt])
|
||||
|
||||
+10
-3
@@ -528,8 +528,15 @@ async function main() {
|
||||
const mc = await importManaged(prisma, tenant.id);
|
||||
console.log(`✔ Verwaltete Register: Krypto ${mc.crypto}, Klassifizierung ${mc.classes}×${mc.aspects}, Risikomatrix (${mc.damage} Schadensdim.), Handbuch ${mc.handbook} Themen`);
|
||||
|
||||
// 11. Admin-Konsole: Mandanten-Einstellungen, Module, Superadmin
|
||||
await prisma.user.updateMany({ where: { tenantId: tenant.id, email: "admin@demo.example" }, data: { isPlatformAdmin: true } });
|
||||
// 11. Admin-Konsole: Mandanten-Einstellungen, Module, Plattform-Admin (getrennter Store)
|
||||
// Demo-Plattform-Admin: gleiche Adresse wie der Mandanten-Admin, damit /login (Mandant)
|
||||
// und /platform/login (Betrieb) mit denselben Demo-Zugangsdaten funktionieren. MFA wird
|
||||
// beim ersten Plattform-Login eingerichtet.
|
||||
await prisma.platformAdmin.upsert({
|
||||
where: { email: "admin@demo.example" },
|
||||
update: { name: "Anna Admin", status: "ACTIVE" },
|
||||
create: { email: "admin@demo.example", name: "Anna Admin", passwordHash: await hash("Demo1234!"), status: "ACTIVE" },
|
||||
});
|
||||
await prisma.tenantSettings.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
update: {},
|
||||
@@ -554,7 +561,7 @@ async function main() {
|
||||
create: { tenantId: tenant.id, moduleKey: key, enabled: true },
|
||||
});
|
||||
}
|
||||
console.log(`✔ Admin-Konsole: Einstellungen, ${MODULE_KEYS.length} Module aktiv, admin@demo.example = Superadmin`);
|
||||
console.log(`✔ Admin-Konsole: Einstellungen, ${MODULE_KEYS.length} Module aktiv; Plattform-Admin admin@demo.example (getrennter Login /platform/login, MFA beim ersten Login)`);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -29,9 +29,10 @@ const ACTION_MODULE: Record<string, string> = {
|
||||
"suppliers.ts": "suppliers",
|
||||
"services.ts": "suppliers",
|
||||
"policies.ts": "policies",
|
||||
// Plattform-Admin (isPlatformAdmin) und Kunden-Einstellungen (tenant:manage) sind
|
||||
// Plattform-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind
|
||||
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
|
||||
"admin.ts": "EXEMPT",
|
||||
"platform.ts": "EXEMPT",
|
||||
"tenant-settings.ts": "EXEMPT",
|
||||
};
|
||||
|
||||
@@ -50,7 +51,7 @@ for (const file of files) {
|
||||
const src = readFileSync(join(ACTIONS_DIR, file), "utf8");
|
||||
|
||||
if (mapped === "EXEMPT") {
|
||||
if (!/require(Session|PlatformAdmin|Permission)/.test(src)) {
|
||||
if (!/require(Session|Platform\w*|Permission)/.test(src)) {
|
||||
errors.push(`${file}: als EXEMPT markiert, aber keine erkennbare Auth-Prüfung.`);
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
LineChart,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { auth, signOut } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
@@ -61,7 +60,6 @@ export default async function AppLayout({
|
||||
};
|
||||
const visibleNav = nav.filter((item) => !item.enabled || moduleEnabled(item.href));
|
||||
|
||||
const isPlatformAdmin = session.user.isPlatformAdmin;
|
||||
const canManageTenant = hasPermission(session, "tenant:manage");
|
||||
|
||||
const initials = (session.user.name ?? "?")
|
||||
@@ -111,18 +109,11 @@ export default async function AppLayout({
|
||||
)
|
||||
)}
|
||||
|
||||
{(canManageTenant || isPlatformAdmin) && (
|
||||
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
|
||||
{canManageTenant && (
|
||||
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
|
||||
<NavLink href="/settings">
|
||||
<Settings className="size-[18px] opacity-85" /> {t("settings")}
|
||||
</NavLink>
|
||||
)}
|
||||
{isPlatformAdmin && (
|
||||
<NavLink href="/admin">
|
||||
<ShieldCheck className="size-[18px] opacity-85" /> {t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
@@ -12,8 +11,7 @@ const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPE
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||
|
||||
export default async function AdminTenantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!session.user.isPlatformAdmin) redirect("/dashboard");
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const { id } = await params;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
@@ -21,7 +19,7 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id
|
||||
include: {
|
||||
settings: true,
|
||||
modules: true,
|
||||
users: { select: { id: true, name: true, email: true, status: true, isPlatformAdmin: true }, orderBy: { createdAt: "asc" } },
|
||||
users: { select: { id: true, name: true, email: true, status: true }, orderBy: { createdAt: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!tenant) notFound();
|
||||
@@ -96,7 +94,7 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id
|
||||
<span className="font-medium">{u.name}</span>
|
||||
<span className="block text-[11px] text-muted-foreground">{u.email}</span>
|
||||
</span>
|
||||
{u.isPlatformAdmin && <Pill tone="violet">Superadmin</Pill>}
|
||||
<Pill tone="mut">{u.status}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1,7 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -22,8 +20,7 @@ const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesp
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
|
||||
const session = await requireSession();
|
||||
if (!session.user.isPlatformAdmin) redirect("/dashboard");
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const params = await searchParams;
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { platformSignOutAction } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Shell des Plattform-Betriebsbereichs (Phase-1-Härtung Paket 2). Zugriff nur mit
|
||||
* Plattform-Session (getrennte Auth-Domäne, kein Mandantenkontext) und aktivierter
|
||||
* MFA. Enthält bewusst KEINE Mandanten-Navigation — Plattform-Admins haben keinen
|
||||
* Zugriff auf Kundenfachdaten.
|
||||
*/
|
||||
export default async function PlatformLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) redirect("/platform/login");
|
||||
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
|
||||
if (!admin || admin.status !== "ACTIVE") redirect("/platform/login");
|
||||
if (!admin.mfaEnrolledAt) redirect("/platform/enroll-mfa");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b bg-[var(--panel)] px-6 py-3">
|
||||
<Link href="/admin" className="flex items-center gap-2 font-heading text-sm font-semibold">
|
||||
<ShieldCheck className="size-5 text-[var(--info)]" /> ISMS · Plattform-Administration
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{admin.name} · {admin.email}
|
||||
</span>
|
||||
<form action={platformSignOutAction}>
|
||||
<Button type="submit" variant="ghost" size="sm">Abmelden</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { platformHandlers } from "@/server/platform-auth";
|
||||
|
||||
export const { GET, POST } = platformHandlers;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import QRCode from "qrcode";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { newTotpSecret, totpUri } from "@/server/mfa";
|
||||
import { PlatformEnrollForm } from "@/components/platform-enroll-form";
|
||||
|
||||
/**
|
||||
* MFA-Einrichtung für Plattform-Administratoren (Pflicht beim ersten Login).
|
||||
* Erzeugt/persistiert ein Einrichtungs-Secret, zeigt QR + Klartext-Secret und
|
||||
* bestätigt per TOTP-Code (Server-Action confirmMfaEnrollment).
|
||||
*/
|
||||
export default async function EnrollMfaPage() {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) redirect("/platform/login");
|
||||
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
|
||||
if (!admin) redirect("/platform/login");
|
||||
if (admin.mfaEnrolledAt) redirect("/admin");
|
||||
|
||||
let secret = admin.mfaSecret;
|
||||
if (!secret) {
|
||||
secret = newTotpSecret();
|
||||
await prisma.platformAdmin.update({ where: { id: admin.id }, data: { mfaSecret: secret } });
|
||||
}
|
||||
const qr = await QRCode.toDataURL(totpUri(admin.email, secret), { margin: 1, width: 208 });
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-md rounded-2xl border bg-card p-8">
|
||||
<p className="font-heading text-lg font-semibold">Zwei-Faktor-Authentifizierung einrichten</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Für Plattform-Administratoren verpflichtend. Scannen Sie den QR-Code mit einer
|
||||
Authenticator-App (z. B. Google Authenticator, Aegis, 1Password) und bestätigen Sie mit dem angezeigten Code.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qr} alt="QR-Code für die Authenticator-App" width={208} height={208} className="rounded-lg border bg-white p-2" />
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Manuell eingeben:</p>
|
||||
<code className="select-all break-all font-mono text-xs">{secret}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<PlatformEnrollForm />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthError } from "next-auth";
|
||||
import { platformAuth, platformSignIn } from "@/server/platform-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/**
|
||||
* Getrennter Login für Plattform-Administratoren (Phase-1-Härtung Paket 2).
|
||||
* Eigene Auth-Domäne, kein Mandantenkontext. TOTP-Code ist ab eingerichteter MFA
|
||||
* erforderlich (Feld optional, damit der erste Login zur Einrichtung durchläuft).
|
||||
*/
|
||||
export default async function PlatformLoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
|
||||
const session = await platformAuth();
|
||||
if (session?.user?.id) redirect("/admin");
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
try {
|
||||
await platformSignIn("credentials", {
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
token: formData.get("token"),
|
||||
redirectTo: "/admin",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) redirect("/platform/login?error=1");
|
||||
throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchlaufen
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
|
||||
<p className="font-heading text-lg font-semibold">Plattform-Administration</p>
|
||||
<h2 className="mt-1 text-sm font-normal text-muted-foreground">
|
||||
Betreiberzugang — getrennt vom Mandanten-Login
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]"
|
||||
>
|
||||
Anmeldung fehlgeschlagen. Bitte E-Mail, Passwort und — falls MFA eingerichtet — den 6-stelligen Code prüfen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={login} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input id="email" name="email" type="email" required autoComplete="email" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input id="password" name="password" type="password" required autoComplete="current-password" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="token">MFA-Code (falls eingerichtet)</Label>
|
||||
<Input
|
||||
id="token"
|
||||
name="token"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-stelliger Code oder Recovery-Code"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Anmelden</Button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { confirmMfaEnrollment, type EnrollState } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const initial: EnrollState = { status: "idle" };
|
||||
|
||||
export function PlatformEnrollForm() {
|
||||
const [state, action, pending] = useActionState(confirmMfaEnrollment, initial);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-[rgba(46,204,113,0.14)] px-3 py-2 text-sm text-[var(--ok)]">
|
||||
MFA ist aktiv. Bewahren Sie die folgenden Recovery-Codes sicher auf — sie werden
|
||||
<strong> nur jetzt</strong> angezeigt und ermöglichen den Zugang, falls der Authenticator verloren geht.
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{state.recoveryCodes.map((c) => (
|
||||
<li key={c} className="rounded border bg-card px-3 py-1.5 text-center tracking-wider">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button nativeButton={false} render={<Link href="/admin" />} className="w-full">
|
||||
Codes gesichert — zur Admin-Konsole
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="token">6-stelliger Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id="token"
|
||||
name="token"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="123456"
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
{pending ? "Prüfe…" : "MFA aktivieren"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { join } from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { provisionTenant } from "@/server/provision";
|
||||
|
||||
/** Nur Plattform-Admins (Superadmin) — mandantenübergreifend. */
|
||||
/** Nur Plattform-Admins über die getrennte Plattform-Session (kein Mandantenkontext). */
|
||||
async function requirePlatformAdmin() {
|
||||
const session = await requireSession();
|
||||
if (!session.user.isPlatformAdmin) throw new Error("Nur für Plattform-Admins.");
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) throw new Error("Nur für Plattform-Admins.");
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/server/db";
|
||||
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
|
||||
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
|
||||
import { writePlatformAudit } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* Plattform-Actions (Phase-1-Härtung Paket 2): MFA-Enrollment und Logout.
|
||||
* EXEMPT vom Modul-Gating (keine per TenantModule gegatete Fachfunktion) — eigene
|
||||
* Autorisierung über requirePlatformSession.
|
||||
*/
|
||||
|
||||
export type EnrollState =
|
||||
| { status: "idle" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "done"; recoveryCodes: string[] };
|
||||
|
||||
/**
|
||||
* Bestätigt die MFA-Einrichtung: prüft den TOTP-Code gegen das (beim Seitenaufruf
|
||||
* gespeicherte) Einrichtungs-Secret, aktiviert MFA und liefert einmalig die
|
||||
* Recovery-Codes im Klartext zurück (nur Hashes werden gespeichert).
|
||||
*/
|
||||
export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormData): Promise<EnrollState> {
|
||||
const session = await requirePlatformSession();
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
|
||||
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
|
||||
if (admin.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits eingerichtet." };
|
||||
if (!admin.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
|
||||
|
||||
const code = String(formData.get("token") ?? "");
|
||||
if (!verifyTotp(code, admin.mfaSecret)) {
|
||||
return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code aus der App eingeben." };
|
||||
}
|
||||
|
||||
const { plain, hashed } = generateRecoveryCodes(10);
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: admin.id },
|
||||
data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, failedLogins: 0, lockedUntil: null },
|
||||
});
|
||||
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { mfaEnrolled: true } });
|
||||
return { status: "done", recoveryCodes: plain };
|
||||
}
|
||||
|
||||
/** Plattform-Logout (eigene Session-Domäne). */
|
||||
export async function platformSignOutAction() {
|
||||
await platformSignOut({ redirectTo: "/platform/login" });
|
||||
}
|
||||
@@ -28,3 +28,29 @@ export async function writeAuditLog(entry: {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit-Eintrag der Plattform-Ebene (kein Mandantenbezug, scope="platform").
|
||||
* Für Superadmin-Anmeldungen und -Aktionen (Phase-1-Härtung Paket 2).
|
||||
*/
|
||||
export async function writePlatformAudit(entry: {
|
||||
actorId?: string;
|
||||
action: "create" | "update" | "delete" | "login" | "logout" | "denied";
|
||||
entity: string;
|
||||
entityId?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: null,
|
||||
scope: "platform",
|
||||
actorId: entry.actorId,
|
||||
action: entry.action,
|
||||
entity: entry.entity,
|
||||
entityId: entry.entityId,
|
||||
before: entry.before as object | undefined,
|
||||
after: entry.after as object | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
||||
import { generateSecret, generateURI, verifySync } from "otplib";
|
||||
|
||||
/**
|
||||
* TOTP-MFA für Plattform-Administratoren (Phase-1-Härtung Paket 2).
|
||||
* Secret + Recovery-Codes gehören zum PlatformAdmin-Store; Recovery-Codes werden
|
||||
* nur als SHA-256-Hash gespeichert und einmalig im Klartext angezeigt.
|
||||
*/
|
||||
|
||||
const ISSUER = "ISMS-Tool · Plattform";
|
||||
|
||||
/** Neues TOTP-Secret (Base32) für die Enrollment-Phase. */
|
||||
export function newTotpSecret(): string {
|
||||
return generateSecret();
|
||||
}
|
||||
|
||||
/** otpauth://-URI für den QR-Code der Authenticator-App. */
|
||||
export function totpUri(email: string, secret: string): string {
|
||||
return generateURI({ secret, label: email, issuer: ISSUER });
|
||||
}
|
||||
|
||||
/** Prüft einen 6-stelligen TOTP-Code gegen das Secret (Zeitfenster-Toleranz aus otplib). */
|
||||
export function verifyTotp(token: string, secret: string): boolean {
|
||||
const t = token.replace(/\s+/g, "");
|
||||
if (!/^\d{6}$/.test(t)) return false;
|
||||
try {
|
||||
return verifySync({ token: t, secret }).valid === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeRecovery = (code: string) => code.replace(/[\s-]/g, "").toLowerCase();
|
||||
|
||||
export function hashRecovery(code: string): string {
|
||||
return createHash("sha256").update(normalizeRecovery(code)).digest("hex");
|
||||
}
|
||||
|
||||
/** Erzeugt `count` Recovery-Codes: Klartext (einmalig anzeigen) + Hashes (speichern). */
|
||||
export function generateRecoveryCodes(count = 10): { plain: string[]; hashed: string[] } {
|
||||
const plain: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const raw = randomBytes(5).toString("hex"); // 10 Hex-Zeichen
|
||||
plain.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 10)}`);
|
||||
}
|
||||
return { plain, hashed: plain.map(hashRecovery) };
|
||||
}
|
||||
|
||||
/** Findet den Index eines passenden Recovery-Code-Hashes (konstante Zeit), sonst -1. */
|
||||
export function matchRecovery(input: string, hashed: string[]): number {
|
||||
const h = Buffer.from(hashRecovery(input));
|
||||
for (let i = 0; i < hashed.length; i++) {
|
||||
const candidate = Buffer.from(hashed[i]);
|
||||
if (candidate.length === h.length && timingSafeEqual(candidate, h)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./db";
|
||||
import { verifyTotp, matchRecovery } from "./mfa";
|
||||
import { writePlatformAudit } from "./audit";
|
||||
|
||||
/**
|
||||
* Getrennte Auth-Domäne für Plattform-Administratoren (Phase-1-Härtung Paket 2).
|
||||
*
|
||||
* Eigene NextAuth-Instanz mit eigenem Cookie und eigenem basePath, damit die
|
||||
* Plattform-Session strikt von der Mandanten-Session getrennt ist und **keinen**
|
||||
* tenantId trägt. Login gegen den PlatformAdmin-Store (kein User/Tenant), TOTP-MFA
|
||||
* sobald eingerichtet, Konto-Sperre nach zu vielen Fehlversuchen, Audit scope=platform.
|
||||
*/
|
||||
|
||||
const LOCK_THRESHOLD = 5;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
token: z.string().optional(), // TOTP-Code oder Recovery-Code
|
||||
});
|
||||
|
||||
/** Fehlversuch zählen und ggf. sperren; gibt immer null zurück (Login fehlgeschlagen). */
|
||||
async function registerFailure(id: string) {
|
||||
const updated = await prisma.platformAdmin.update({
|
||||
where: { id },
|
||||
data: { failedLogins: { increment: 1 } },
|
||||
});
|
||||
if (updated.failedLogins >= LOCK_THRESHOLD) {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id },
|
||||
data: { lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60_000), failedLogins: 0 },
|
||||
});
|
||||
await writePlatformAudit({ actorId: id, action: "denied", entity: "platform_login", entityId: id, after: { locked: true } });
|
||||
} else {
|
||||
await writePlatformAudit({ actorId: id, action: "denied", entity: "platform_login", entityId: id });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: platformHandlers,
|
||||
auth: platformAuth,
|
||||
signIn: platformSignIn,
|
||||
signOut: platformSignOut,
|
||||
} = NextAuth({
|
||||
session: { strategy: "jwt" },
|
||||
basePath: "/api/platform-auth",
|
||||
pages: { signIn: "/platform/login" },
|
||||
// Eigener Cookie-Name → keine Kollision mit der Mandanten-Session.
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `${process.env.NODE_ENV === "production" ? "__Secure-" : ""}platform-authjs.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: { email: {}, password: {}, token: {} },
|
||||
authorize: async (raw) => {
|
||||
const parsed = credentialsSchema.safeParse(raw);
|
||||
if (!parsed.success) return null;
|
||||
const { email, password, token } = parsed.data;
|
||||
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!admin || admin.status !== "ACTIVE") return null;
|
||||
|
||||
// Konto-Sperre aktiv?
|
||||
if (admin.lockedUntil && admin.lockedUntil > new Date()) {
|
||||
await writePlatformAudit({ actorId: admin.id, action: "denied", entity: "platform_login", entityId: admin.id, after: { reason: "locked" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordOk = await verify(admin.passwordHash, password);
|
||||
if (!passwordOk) return registerFailure(admin.id);
|
||||
|
||||
// MFA erforderlich, sobald eingerichtet.
|
||||
if (admin.mfaEnrolledAt && admin.mfaSecret) {
|
||||
const code = (token ?? "").trim();
|
||||
if (!code) return registerFailure(admin.id);
|
||||
|
||||
let mfaOk = verifyTotp(code, admin.mfaSecret);
|
||||
if (!mfaOk) {
|
||||
// Recovery-Code als Fallback (Einmalverbrauch).
|
||||
const codes = Array.isArray(admin.recoveryCodes) ? (admin.recoveryCodes as string[]) : [];
|
||||
const idx = matchRecovery(code, codes);
|
||||
if (idx >= 0) {
|
||||
mfaOk = true;
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: admin.id },
|
||||
data: { recoveryCodes: codes.filter((_, i) => i !== idx) },
|
||||
});
|
||||
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { recoveryCodeUsed: true } });
|
||||
}
|
||||
}
|
||||
if (!mfaOk) return registerFailure(admin.id);
|
||||
}
|
||||
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: admin.id },
|
||||
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
|
||||
});
|
||||
await writePlatformAudit({ actorId: admin.id, action: "login", entity: "platform_admin", entityId: admin.id });
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
// Dummy-Mandantenfelder: Plattform-Session trägt bewusst keinen echten Tenant.
|
||||
tenantId: "",
|
||||
tenantSlug: "",
|
||||
roles: [],
|
||||
permissions: [],
|
||||
isPlatformAdmin: true,
|
||||
mfaEnrolled: !!admin.mfaEnrolledAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.userId = user.id!;
|
||||
token.mfaEnrolled = user.mfaEnrolled;
|
||||
token.isPlatformAdmin = true;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
session.user.id = token.userId;
|
||||
session.user.isPlatformAdmin = true;
|
||||
session.user.mfaEnrolled = token.mfaEnrolled;
|
||||
// Plattform-Session trägt keinen Mandantenkontext.
|
||||
session.user.tenantId = "";
|
||||
session.user.tenantSlug = "";
|
||||
session.user.roles = [];
|
||||
session.user.permissions = [];
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Guard für den Plattform-Bereich (Server-Komponenten/Actions). */
|
||||
export async function requirePlatformSession() {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) throw new Error("Nicht als Plattform-Admin angemeldet");
|
||||
return session;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export interface ProvisionOpts {
|
||||
slug: string;
|
||||
short?: string;
|
||||
sector?: string;
|
||||
admin: { email: string; name: string; password: string; isPlatformAdmin?: boolean };
|
||||
admin: { email: string; name: string; password: string };
|
||||
tisaxLevel?: "AL2" | "AL3";
|
||||
seedPoliciesDir?: string;
|
||||
actorId?: string | null;
|
||||
@@ -85,8 +85,8 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
const passwordHash = await hash(opts.admin.password);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
|
||||
update: { name: opts.admin.name, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
|
||||
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
|
||||
update: { name: opts.admin.name },
|
||||
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash },
|
||||
});
|
||||
const adminRoles = await prisma.role.findMany({ where: { tenantId: tenant.id, key: { in: ["tenant-admin", "isb"] } } });
|
||||
for (const r of adminRoles) {
|
||||
|
||||
Vendored
+4
@@ -9,6 +9,8 @@ declare module "next-auth" {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
isPlatformAdmin: boolean;
|
||||
// Nur in der Plattform-Session (getrennte NextAuth-Instanz) gesetzt.
|
||||
mfaEnrolled?: boolean;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
@@ -19,6 +21,7 @@ declare module "next-auth" {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
isPlatformAdmin: boolean;
|
||||
mfaEnrolled?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,5 +34,6 @@ declare module "@auth/core/jwt" {
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
isPlatformAdmin: boolean;
|
||||
mfaEnrolled?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user