diff --git a/docs/HANDOVER-DEV.md b/docs/HANDOVER-DEV.md index e34d99c..8021a23 100644 --- a/docs/HANDOVER-DEV.md +++ b/docs/HANDOVER-DEV.md @@ -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. --- diff --git a/package-lock.json b/package-lock.json index 5983f75..f6e2da4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index da022ca..d9e18dc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260720183635_platform_admins/migration.sql b/prisma/migrations/20260720183635_platform_admins/migration.sql new file mode 100644 index 0000000..e92c157 --- /dev/null +++ b/prisma/migrations/20260720183635_platform_admins/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e90bfb9..1729c3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/prisma/seed.ts b/prisma/seed.ts index bcdeff3..03a21ea 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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() diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 969cb31..1f0e576 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -29,9 +29,10 @@ const ACTION_MODULE: Record = { "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; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 78e33b3..10a1cd3 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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) && ( + {canManageTenant && (
- {canManageTenant && ( - - {t("settings")} - - )} - {isPlatformAdmin && ( - - {t("admin")} - - )} + + {t("settings")} +
)} diff --git a/src/app/(app)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx similarity index 93% rename from src/app/(app)/admin/[id]/page.tsx rename to src/app/(platform)/admin/[id]/page.tsx index 556e138..42e7e60 100644 --- a/src/app/(app)/admin/[id]/page.tsx +++ b/src/app/(platform)/admin/[id]/page.tsx @@ -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 = { ACTIVE: "ok", SUSPE const STATUS_LABEL: Record = { 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 {u.name} {u.email} - {u.isPlatformAdmin && Superadmin} + {u.status} ))} diff --git a/src/app/(app)/admin/page.tsx b/src/app/(platform)/admin/page.tsx similarity index 96% rename from src/app/(app)/admin/page.tsx rename to src/app/(platform)/admin/page.tsx index 389865b..ae546c8 100644 --- a/src/app/(app)/admin/page.tsx +++ b/src/app/(platform)/admin/page.tsx @@ -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 = { 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({ diff --git a/src/app/(platform)/layout.tsx b/src/app/(platform)/layout.tsx new file mode 100644 index 0000000..fd7eeaf --- /dev/null +++ b/src/app/(platform)/layout.tsx @@ -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 ( +
+
+ + ISMS · Plattform-Administration + +
+ + {admin.name} · {admin.email} + +
+ +
+
+
+ {children} +
+ ); +} diff --git a/src/app/api/platform-auth/[...nextauth]/route.ts b/src/app/api/platform-auth/[...nextauth]/route.ts new file mode 100644 index 0000000..ee53a7b --- /dev/null +++ b/src/app/api/platform-auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { platformHandlers } from "@/server/platform-auth"; + +export const { GET, POST } = platformHandlers; diff --git a/src/app/platform/enroll-mfa/page.tsx b/src/app/platform/enroll-mfa/page.tsx new file mode 100644 index 0000000..7da044d --- /dev/null +++ b/src/app/platform/enroll-mfa/page.tsx @@ -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 ( +
+
+

Zwei-Faktor-Authentifizierung einrichten

+

+ 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. +

+ +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR-Code für die Authenticator-App +
+

Manuell eingeben:

+ {secret} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/platform/login/page.tsx b/src/app/platform/login/page.tsx new file mode 100644 index 0000000..9b67e14 --- /dev/null +++ b/src/app/platform/login/page.tsx @@ -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 ( +
+
+

Plattform-Administration

+

+ Betreiberzugang — getrennt vom Mandanten-Login +

+ + {error && ( +

+ Anmeldung fehlgeschlagen. Bitte E-Mail, Passwort und — falls MFA eingerichtet — den 6-stelligen Code prüfen. +

+ )} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} diff --git a/src/components/platform-enroll-form.tsx b/src/components/platform-enroll-form.tsx new file mode 100644 index 0000000..2a3e540 --- /dev/null +++ b/src/components/platform-enroll-form.tsx @@ -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 ( +
+
+ MFA ist aktiv. Bewahren Sie die folgenden Recovery-Codes sicher auf — sie werden + nur jetzt angezeigt und ermöglichen den Zugang, falls der Authenticator verloren geht. +
+
    + {state.recoveryCodes.map((c) => ( +
  • {c}
  • + ))} +
+ +
+ ); + } + + return ( +
+
+ + +
+ {state.status === "error" && ( +

+ {state.message} +

+ )} + +
+ ); +} diff --git a/src/server/actions/admin.ts b/src/server/actions/admin.ts index 9a838f0..58f5ebe 100644 --- a/src/server/actions/admin.ts +++ b/src/server/actions/admin.ts @@ -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; } diff --git a/src/server/actions/platform.ts b/src/server/actions/platform.ts new file mode 100644 index 0000000..65c3d72 --- /dev/null +++ b/src/server/actions/platform.ts @@ -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 { + 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" }); +} diff --git a/src/server/audit.ts b/src/server/audit.ts index 80639d5..3b23191 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -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, + }, + }); +} diff --git a/src/server/mfa.ts b/src/server/mfa.ts new file mode 100644 index 0000000..ea10295 --- /dev/null +++ b/src/server/mfa.ts @@ -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; +} diff --git a/src/server/platform-auth.ts b/src/server/platform-auth.ts new file mode 100644 index 0000000..1bad567 --- /dev/null +++ b/src/server/platform-auth.ts @@ -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; +} diff --git a/src/server/provision.ts b/src/server/provision.ts index ba3ad89..d1ce0ce 100644 --- a/src/server/provision.ts +++ b/src/server/provision.ts @@ -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) { diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index d621b18..1555397 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -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; } }