diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index a2806f8..04008a0 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", "@types/nodemailer": "^8.0.2", + "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -59,7 +60,9 @@ "next-intl": "^4.3.4", "next-themes": "^0.4.6", "nodemailer": "^10.2.1", + "otplib": "^13.5.0", "prisma": "^6.11.1", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-day-picker": "^9.8.0", "react-dom": "^19.0.0", @@ -431,6 +434,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "https://registry.npmjs.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="], + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmjs.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmjs.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -441,6 +446,18 @@ "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "https://registry.npmjs.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@otplib/core": ["@otplib/core@13.5.0", "", {}, "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ=="], + + "@otplib/hotp": ["@otplib/hotp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw=="], + + "@otplib/plugin-base32-scure": ["@otplib/plugin-base32-scure@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@scure/base": "^2.2.0" } }, "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA=="], + + "@otplib/plugin-crypto-noble": ["@otplib/plugin-crypto-noble@13.5.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@otplib/core": "13.5.0" } }, "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA=="], + + "@otplib/totp": ["@otplib/totp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg=="], + + "@otplib/uri": ["@otplib/uri@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0" } }, "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "https://registry.npmjs.com/@panva/hkdf/-/hkdf-1.2.1.tgz", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], "@parcel/watcher": ["@parcel/watcher@2.5.4", "https://registry.npmjs.com/@parcel/watcher/-/watcher-2.5.4.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.4", "@parcel/watcher-darwin-arm64": "2.5.4", "@parcel/watcher-darwin-x64": "2.5.4", "@parcel/watcher-freebsd-x64": "2.5.4", "@parcel/watcher-linux-arm-glibc": "2.5.4", "@parcel/watcher-linux-arm-musl": "2.5.4", "@parcel/watcher-linux-arm64-glibc": "2.5.4", "@parcel/watcher-linux-arm64-musl": "2.5.4", "@parcel/watcher-linux-x64-glibc": "2.5.4", "@parcel/watcher-linux-x64-musl": "2.5.4", "@parcel/watcher-win32-arm64": "2.5.4", "@parcel/watcher-win32-ia32": "2.5.4", "@parcel/watcher-win32-x64": "2.5.4" } }, "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ=="], @@ -613,6 +630,8 @@ "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + "@scure/base": ["@scure/base@2.4.0", "", {}, "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -725,6 +744,8 @@ "@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.8", "https://registry.npmjs.com/@types/react/-/react-19.2.8.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmjs.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -801,6 +822,8 @@ "anser": ["anser@2.3.5", "https://registry.npmjs.com/anser/-/anser-2.3.5.tgz", {}, "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmjs.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "https://registry.npmjs.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -865,6 +888,8 @@ "callsites": ["callsites@3.1.0", "https://registry.npmjs.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001764", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="], "ccount": ["ccount@2.0.1", "https://registry.npmjs.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -891,6 +916,8 @@ "client-only": ["client-only@0.0.1", "https://registry.npmjs.com/client-only/-/client-only-0.0.1.tgz", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "clsx": ["clsx@2.1.1", "https://registry.npmjs.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cm6-theme-basic-light": ["cm6-theme-basic-light@0.2.0", "https://registry.npmjs.com/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA=="], @@ -961,6 +988,8 @@ "debug": ["debug@4.4.3", "https://registry.npmjs.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js": ["decimal.js@10.6.0", "https://registry.npmjs.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmjs.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], @@ -989,6 +1018,8 @@ "diff": ["diff@5.2.0", "https://registry.npmjs.com/diff/-/diff-5.2.0.tgz", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "doctrine": ["doctrine@2.1.0", "https://registry.npmjs.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmjs.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], @@ -1137,6 +1168,8 @@ "gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "https://registry.npmjs.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -1247,6 +1280,8 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmjs.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-generator-function": ["is-generator-function@1.1.2", "https://registry.npmjs.com/is-generator-function/-/is-generator-function-1.1.2.tgz", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-glob": ["is-glob@4.0.3", "https://registry.npmjs.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1559,6 +1594,8 @@ "optionator": ["optionator@0.9.4", "https://registry.npmjs.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "otplib": ["otplib@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/plugin-base32-scure": "13.5.0", "@otplib/plugin-crypto-noble": "13.5.0", "@otplib/totp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ=="], + "outvariant": ["outvariant@1.4.0", "https://registry.npmjs.com/outvariant/-/outvariant-1.4.0.tgz", {}, "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw=="], "own-keys": ["own-keys@1.0.1", "https://registry.npmjs.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -1567,6 +1604,8 @@ "p-locate": ["p-locate@5.0.0", "https://registry.npmjs.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "parent-module": ["parent-module@1.0.1", "https://registry.npmjs.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "https://registry.npmjs.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1593,6 +1632,8 @@ "pkg-types": ["pkg-types@2.3.0", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.0.tgz", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "po-parser": ["po-parser@2.1.1", "https://registry.npmjs.com/po-parser/-/po-parser-2.1.1.tgz", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmjs.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1621,6 +1662,8 @@ "pure-rand": ["pure-rand@6.1.0", "https://registry.npmjs.com/pure-rand/-/pure-rand-6.1.0.tgz", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmjs.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -1675,6 +1718,10 @@ "remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmjs.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resolve": ["resolve@1.22.11", "https://registry.npmjs.com/resolve/-/resolve-1.22.11.tgz", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@4.0.0", "https://registry.npmjs.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1703,6 +1750,8 @@ "semver": ["semver@7.7.3", "https://registry.npmjs.com/semver/-/semver-7.7.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-function-length": ["set-function-length@1.2.2", "https://registry.npmjs.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "https://registry.npmjs.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -1745,6 +1794,8 @@ "strict-event-emitter": ["strict-event-emitter@0.4.6", "https://registry.npmjs.com/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", {}, "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string.prototype.includes": ["string.prototype.includes@2.0.1", "https://registry.npmjs.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "https://registry.npmjs.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], @@ -1759,6 +1810,8 @@ "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmjs.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom": ["strip-bom@3.0.0", "https://registry.npmjs.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmjs.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -1881,14 +1934,24 @@ "which-collection": ["which-collection@1.0.2", "https://registry.npmjs.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmjs.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], "word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yargs": ["yargs@15.4.1", "", { "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" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "yjs": ["yjs@13.6.29", "https://registry.npmjs.com/yjs/-/yjs-13.6.29.tgz", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="], "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmjs.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -2011,8 +2074,12 @@ "refractor/prismjs": ["prismjs@1.27.0", "https://registry.npmjs.com/prismjs/-/prismjs-1.27.0.tgz", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -2035,6 +2102,12 @@ "refractor/parse-entities/is-hexadecimal": ["is-hexadecimal@1.0.4", "https://registry.npmjs.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "refractor/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@1.0.4", "https://registry.npmjs.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/db/custom.db b/db/custom.db index 02ff24a..4a21617 100755 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/package.json b/package.json index cc2a22a..0690487 100755 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", "@types/nodemailer": "^8.0.2", + "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -73,7 +74,9 @@ "next-intl": "^4.3.4", "next-themes": "^0.4.6", "nodemailer": "^10.2.1", + "otplib": "^13.5.0", "prisma": "^6.11.1", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-day-picker": "^9.8.0", "react-dom": "^19.0.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b93769..ec4f823 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,9 +19,45 @@ model User { id String @id @default(cuid()) email String @unique name String - role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client + role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member + // --- Authentication --- + // scrypt-based password hash (format: "salt:hash", both hex). Null = seeded + // user that hasn't claimed their account via registration yet. + passwordHash String? + emailVerifiedAt DateTime? + lastLoginAt DateTime? + // --- Two-factor authentication (opt-in per user) --- + twoFactorEnabled Boolean @default(false) + // 'email' = OTP sent by mail | 'totp' = Google Authenticator / Authy / 1Password + twoFactorMethod String? // email|totp + // Base32 TOTP secret (stored plaintext for now; encrypt-at-rest later). + // Only set when method='totp'. + twoFactorSecret String? + // Backup codes (hashed, newline-separated) for recovery. Generated on enable. + twoFactorBackup String? + twoFactorEnabledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + otpCodes OtpCode[] +} + +// One-time passcodes (email OTP method). Each login attempt issues a code, +// stored hashed (never plaintext). Codes expire after 5 minutes and are +// single-use (consumedAt set on successful verify). +model OtpCode { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // SHA-256 hash of the 6-digit code (we never store the code itself). + codeHash String + purpose String @default("login") // login|email-verify|password-reset + attempts Int @default(0) + consumedAt DateTime? + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId, purpose]) } // Module registry — installed/enabled/disabled state of each domain module diff --git a/src/app/api/auth/2fa/disable/route.ts b/src/app/api/auth/2fa/disable/route.ts new file mode 100644 index 0000000..6891c85 --- /dev/null +++ b/src/app/api/auth/2fa/disable/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' +import { getCurrentUser } from '@/lib/session' +import { verifyPassword } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +const disableSchema = z.object({ + password: z.string().min(1), +}) + +// POST /api/auth/2fa/disable +// +// Disables 2FA for the authenticated user. Requires the current password +// (re-authentication) so a hijacked session can't silently weaken security. +// Clears the TOTP secret + backup codes; the user row is retained. +// +// Audit-logged. +export async function POST(req: Request) { + try { + const parsed = disableSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'Passwort erforderlich.' }, { status: 400 }) + } + const { password } = parsed.data + + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true, twoFactorMethod: true } }) + if (!full?.passwordHash) { + return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 }) + } + if (!verifyPassword(password, full.passwordHash)) { + return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 }) + } + + await db.user.update({ + where: { id: user.id }, + data: { + twoFactorEnabled: false, + twoFactorMethod: null, + twoFactorSecret: null, + twoFactorBackup: null, + twoFactorEnabledAt: null, + }, + }) + + await db.auditLog.create({ + data: { + actor: `user:${user.email}`, + action: 'auth.2fa.disable', + entity: 'User', + entityId: user.id, + before: JSON.stringify({ method: full.twoFactorMethod }), + after: JSON.stringify({ disabled: true }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ ok: true, message: '2FA deaktiviert.' }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/auth/2fa/enable/route.ts b/src/app/api/auth/2fa/enable/route.ts new file mode 100644 index 0000000..8275db0 --- /dev/null +++ b/src/app/api/auth/2fa/enable/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' +import { getCurrentUser } from '@/lib/session' +import { + verifyTotp, + generateBackupCodes, +} from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +const enableSchema = z.object({ + method: z.enum(['totp', 'email']), + // Required when method='totp': the secret from /2fa/setup + a code the + // user enters from their authenticator app to prove they scanned it. + secret: z.string().optional(), + code: z.string().min(6).max(6).optional(), + // Re-auth: user must confirm current password to enable 2FA (anti-session-hijack). + password: z.string().min(1), +}) + +// POST /api/auth/2fa/enable +// +// Enables 2FA for the authenticated user. Requires the current password +// (re-authentication) so a hijacked session can't silently lock the owner +// out by enabling 2FA. +// +// method='totp' → secret + code must be present; code verified against +// the secret (proves the user scanned the QR). +// method='email' → no secret needed; OTP delivery happens at login time. +// +// On success, 8 backup codes are generated and returned ONCE (the user must +// save them — they're stored hashed and shown only here). +export async function POST(req: Request) { + try { + const parsed = enableSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 }) + } + const { method, secret, code, password } = parsed.data + + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + // Re-auth: verify current password. + const full = await db.user.findUnique({ where: { id: user.id }, select: { passwordHash: true } }) + if (!full?.passwordHash) { + return NextResponse.json({ error: 'Kein Passwort gesetzt.' }, { status: 400 }) + } + const { verifyPassword } = await import('@/lib/auth') + if (!verifyPassword(password, full.passwordHash)) { + return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 }) + } + + if (method === 'totp') { + if (!secret || !code) { + return NextResponse.json( + { error: 'Secret und Code aus der Authenticator-App erforderlich.' }, + { status: 400 } + ) + } + if (!verifyTotp(code, secret)) { + return NextResponse.json({ error: 'Code aus der App ist falsch — bitte erneut versuchen.' }, { status: 401 }) + } + } + + // Generate backup codes (only when enabling — not when switching methods + // if already enabled, but here we regenerate for simplicity). + const { plain: backupPlain, hashed: backupHashed } = generateBackupCodes(8) + + await db.user.update({ + where: { id: user.id }, + data: { + twoFactorEnabled: true, + twoFactorMethod: method, + twoFactorSecret: method === 'totp' ? secret : null, + twoFactorBackup: backupHashed, + twoFactorEnabledAt: new Date(), + }, + }) + + await db.auditLog.create({ + data: { + actor: `user:${user.email}`, + action: 'auth.2fa.enable', + entity: 'User', + entityId: user.id, + after: JSON.stringify({ method }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + ok: true, + method, + backupCodes: backupPlain, + message: method === 'totp' + ? '2FA mit Authenticator-App aktiviert. Backup-Codes sicher speichern!' + : '2FA per E-Mail aktiviert. Backup-Codes sicher speichern!', + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/auth/2fa/setup/route.ts b/src/app/api/auth/2fa/setup/route.ts new file mode 100644 index 0000000..8a6bc88 --- /dev/null +++ b/src/app/api/auth/2fa/setup/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import QRCode from 'qrcode' +import { getCurrentUser } from '@/lib/session' +import { generateTotpSecret, totpOtpauthUrl } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +// POST /api/auth/2fa/setup +// +// Initiates TOTP setup for the authenticated user. Generates a fresh secret +// (NOT persisted yet — the client holds it and submits it back with a code +// to /2fa/enable). Returns the otpauth:// URL and a PNG data-URI QR code +// the user scans with Google Authenticator / Authy / 1Password. +// +// For the 'email' method there is nothing to set up — the frontend should +// call /2fa/enable directly with method='email'. +export async function POST() { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + const secret = generateTotpSecret() + const label = `${user.email}` + const issuer = 'AOS' + const otpauthUrl = totpOtpauthUrl({ secret, label, issuer }) + + const qr = await QRCode.toDataURL(otpauthUrl, { + width: 220, + margin: 2, + color: { dark: '#000000', light: '#ffffff' }, + }) + + return NextResponse.json({ + secret, + otpauthUrl, + qr, + issuer, + label, + // Manual entry key, grouped for readability. + secretFormatted: secret.replace(/(.{4})/g, '$1 ').trim(), + }) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index a135cae..8a969f7 100755 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,64 +1,123 @@ import { NextResponse } from 'next/server' import { z } from 'zod' +import { db } from '@/lib/db' +import { + verifyPassword, + createSessionToken, + createLoginChallenge, + generateEmailOtp, + hashOtpCode, + otpExpiry, + otpMaxAttempts, +} from '@/lib/auth' export const dynamic = 'force-dynamic' -const schema = z.object({ - password: z.string().min(1), +const loginSchema = z.object({ + email: z.string().email().max(200).toLowerCase(), + password: z.string().min(1).max(200), redirect: z.string().optional(), }) // POST /api/auth/login -// Simple password-based auth. Password is set via ADMIN_PASSWORD env var. -// Returns a session cookie (HMAC-signed, 7-day expiry). +// +// Step 1 of the login flow. Verifies email + password, then either: +// - 2FA OFF → sets the session cookie immediately, returns { ok, redirect } +// - 2FA ON → issues a short-lived signed challenge and returns +// { twoFactorRequired: true, method, challenge }. For the +// 'email' method an OTP is also sent to the user's inbox. +// +// The challenge is HMAC-signed (5-min TTL) and carries the userId + method. +// Step 2 is POST /api/auth/login/verify with { challenge, code }. +// +// Anti-enumeration: unknown emails / wrong passwords both return the same +// generic "Ungültige Anmeldedaten" so attackers can't probe which emails +// exist. Rate limiting is handled by the global middleware (120 req/min/IP). export async function POST(req: Request) { try { - const parsed = schema.safeParse(await req.json()) + const parsed = loginSchema.safeParse(await req.json()) if (!parsed.success) { - return NextResponse.json({ error: 'invalid request' }, { status: 400 }) + return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 }) } - const { password, redirect } = parsed.data + const { email, password, redirect } = parsed.data - const adminPassword = process.env.ADMIN_PASSWORD - if (!adminPassword) { - return NextResponse.json( - { error: 'ADMIN_PASSWORD env not configured. Set it in Dokploy environment variables.' }, - { status: 503 } - ) + const user = await db.user.findUnique({ where: { email } }) + + // Constant-ish path: if user is null, still run a hash verify against a + // dummy hash so timing doesn't reveal whether the email exists. + const dummyHash = '$scrypt$dummy' + const passwordOk = user?.passwordHash + ? verifyPassword(password, user.passwordHash) + : (verifyPassword(password, dummyHash), false) + + if (!user || !user.passwordHash || !passwordOk) { + return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 401 }) } - if (password !== adminPassword) { - return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 }) + // --- 2FA branch --- + if (user.twoFactorEnabled && user.twoFactorMethod) { + const method = user.twoFactorMethod + const challenge = createLoginChallenge(user.id, method) + + if (method === 'email') { + // Generate + store an OTP, email it to the user. + const code = generateEmailOtp() + await db.otpCode.create({ + data: { + userId: user.id, + codeHash: hashOtpCode(code), + purpose: 'login', + expiresAt: otpExpiry(), + }, + }) + const { sendEmail } = await import('@/lib/email') + await sendEmail({ + to: user.email, + subject: 'AOS — Anmeldecode', + text: `Ihr Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nWenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.`, + html: `

Ihr Anmeldecode lautet:

${code}

Der Code ist 5 Minuten gültig.

Wenn Sie sich nicht angemeldet haben, ignorieren Sie diese E-Mail.

`, + }).catch(() => {}) + } + + return NextResponse.json({ + ok: true, + twoFactorRequired: true, + method, + challenge, + // mask email for display + maskedEmail: maskEmail(user.email), + message: method === 'email' + ? `Ein 6-stelliger Code wurde an ${maskEmail(user.email)} gesendet.` + : 'Bitte den Code aus Ihrer Authenticator-App eingeben.', + }) } - // Create session token: base64(payload).hmac - const crypto = await import('crypto') - const payload = { - sub: 'admin', - iat: Date.now(), - exp: Date.now() + 7 * 24 * 3600 * 1000, // 7 days - } - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') - const key = process.env.AUTH_SECRET || adminPassword - const signature = crypto - .createHmac('sha256', key) - .update(payloadB64) - .digest('base64url') - const token = `${payloadB64}.${signature}` + // --- No 2FA → set session immediately --- + await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }) + const token = createSessionToken({ sub: user.id, email: user.email, role: user.role }) const res = NextResponse.json({ ok: true, redirect: redirect || '/', + user: { id: user.id, email: user.email, name: user.name, role: user.role }, }) res.cookies.set('aos-session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 7 * 24 * 3600, // 7 days + maxAge: 7 * 24 * 3600, }) return res - } catch { - return NextResponse.json({ error: 'login failed' }, { status: 500 }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) } } + +function maskEmail(email: string): string { + const [local, domain] = email.split('@') + if (!domain) return email + const visible = local.slice(0, Math.max(1, Math.min(2, local.length - 1))) + return `${visible}${'•'.repeat(Math.max(2, local.length - 2))}@${domain}` +} diff --git a/src/app/api/auth/login/verify/route.ts b/src/app/api/auth/login/verify/route.ts new file mode 100644 index 0000000..5d26852 --- /dev/null +++ b/src/app/api/auth/login/verify/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' +import { + verifyLoginChallenge, + createSessionToken, + verifyTotp, + verifyOtpCode, + consumeBackupCode, + hashOtpCode, + otpMaxAttempts, +} from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +const verifySchema = z.object({ + challenge: z.string().min(1), + code: z.string().min(4).max(40), + redirect: z.string().optional(), +}) + +// POST /api/auth/login/verify +// +// Step 2 of the login flow. Validates the challenge token (issued by step 1) +// and verifies the OTP code: +// - method='totp' → code verified against the stored TOTP secret +// - method='email' → code verified against a stored OtpCode row (hashed) +// - 10-char backup codes are also accepted as a recovery path. +// +// On success: sets the session cookie + updates lastLoginAt. On failure: +// 401 with a remaining-attempts hint (after 5 wrong attempts the latest +// code is invalidated and the user must restart login). +export async function POST(req: Request) { + try { + const parsed = verifySchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'Code fehlt oder ist ungültig.' }, { status: 400 }) + } + const { challenge, code, redirect } = parsed.data + + const ch = verifyLoginChallenge(challenge) + if (!ch) { + return NextResponse.json({ error: 'Sitzung abgelaufen — bitte erneut anmelden.' }, { status: 401 }) + } + + const user = await db.user.findUnique({ + where: { id: ch.sub }, + select: { id: true, email: true, name: true, role: true, twoFactorMethod: true, twoFactorSecret: true, twoFactorBackup: true, twoFactorEnabled: true }, + }) + if (!user || !user.twoFactorEnabled) { + return NextResponse.json({ error: '2FA nicht aktiv — bitte erneut anmelden.' }, { status: 400 }) + } + + const normalizedCode = code.trim().replace(/\s/g, '') + + // --- Try TOTP --- + if (ch.method === 'totp' && user.twoFactorSecret) { + if (verifyTotp(normalizedCode, user.twoFactorSecret)) { + return await finishLogin(user, redirect) + } + } + + // --- Try email OTP (look up the latest unused code for this user+purpose) --- + if (ch.method === 'email') { + const otp = await db.otpCode.findFirst({ + where: { + userId: user.id, + purpose: 'login', + consumedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: 'desc' }, + }) + if (!otp) { + return NextResponse.json({ error: 'Kein gültiger Code. Bitte erneut anmelden.' }, { status: 401 }) + } + if (otp.attempts >= otpMaxAttempts()) { + await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } }) + return NextResponse.json({ error: 'Zu viele Versuche — bitte erneut anmelden.' }, { status: 429 }) + } + if (verifyOtpCode(normalizedCode, otp.codeHash)) { + await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } }) + return await finishLogin(user, redirect) + } + await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } }) + const remaining = otpMaxAttempts() - (otp.attempts + 1) + return NextResponse.json( + { error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) }, + { status: 401 } + ) + } + + // --- Try backup code (recovery, 10 chars, format XXXX-XXXX-XX) --- + if (user.twoFactorBackup && normalizedCode.length >= 8) { + const { match, remainingHashed } = consumeBackupCode(normalizedCode, user.twoFactorBackup) + if (match) { + await db.user.update({ where: { id: user.id }, data: { twoFactorBackup: remainingHashed } }) + return await finishLogin(user, redirect) + } + } + + return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 401 }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +async function finishLogin( + user: { id: string; email: string; name: string; role: string }, + redirect?: string +) { + await db.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }) + const token = createSessionToken({ sub: user.id, email: user.email, role: user.role }) + const res = NextResponse.json({ + ok: true, + redirect: redirect || '/', + user: { id: user.id, email: user.email, name: user.name, role: user.role }, + }) + res.cookies.set('aos-session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 3600, + }) + return res +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..0117237 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import { getCurrentUser } from '@/lib/session' + +export const dynamic = 'force-dynamic' + +// GET /api/auth/me +// Returns the currently authenticated user (from the session cookie) or 401. +// Used by the frontend to hydrate the current session + show 2FA status. +export async function GET() { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + emailVerifiedAt: user.emailVerifiedAt, + twoFactorEnabled: user.twoFactorEnabled, + twoFactorMethod: user.twoFactorMethod, + lastLoginAt: user.lastLoginAt, + }, + }) +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..a1b59df --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' +import { hashPassword, validatePasswordStrength } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +const registerSchema = z.object({ + name: z.string().min(1).max(100), + email: z.string().email().max(200).toLowerCase(), + password: z.string().min(8).max(200), +}) + +// POST /api/auth/register +// +// Email + password registration. Creates (or claims) a user account. +// +// Claim flow: if the email already belongs to a seeded User with no +// passwordHash (e.g. demo fixtures), registration sets the password on +// that existing user instead of failing — this lets seeded team members +// claim their accounts. +// +// Role assignment: +// - First account with a password on the system → owner (bootstrap) +// - Everyone else → 'member' (default staff role; can be elevated by an +// owner in settings later). +// +// Email verification: a verification code is emailed if SMTP is configured. +// Until verified, the user can still log in (verification is encouraged but +// not blocking for this single-tenant agency tool). Resend via /verify-email. +export async function POST(req: Request) { + try { + const parsed = registerSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 }) + } + const { name, email, password } = parsed.data + + const pwError = validatePasswordStrength(password) + if (pwError) { + return NextResponse.json({ error: pwError }, { status: 400 }) + } + + // Check for an existing user with this email that already has a password + // (claimed) — that's a real conflict. + const existing = await db.user.findUnique({ where: { email } }) + if (existing?.passwordHash) { + return NextResponse.json( + { error: 'Für diese E-Mail existiert bereits ein Konto. Bitte anmelden.' }, + { status: 409 } + ) + } + + const passwordHash = hashPassword(password) + + // Decide the role: first account with a password becomes the owner. + const usersWithPassword = await db.user.count({ where: { NOT: { passwordHash: null } } }) + const role = usersWithPassword === 0 ? 'owner' : 'member' + + let user + if (existing) { + // Claim the seeded account (keep id, role may be elevated if first). + user = await db.user.update({ + where: { id: existing.id }, + data: { name, passwordHash, role: usersWithPassword === 0 ? 'owner' : existing.role }, + }) + } else { + user = await db.user.create({ + data: { name, email, passwordHash, role }, + }) + } + + // Send a verification email (best-effort; not blocking). + const { sendEmail } = await import('@/lib/email') + const { generateEmailOtp, hashOtpCode, otpExpiry } = await import('@/lib/auth') + const code = generateEmailOtp() + await db.otpCode.create({ + data: { + userId: user.id, + codeHash: hashOtpCode(code), + purpose: 'email-verify', + expiresAt: otpExpiry(), + }, + }) + await sendEmail({ + to: email, + subject: 'E-Mail bestätigen — AOS', + text: `Willkommen bei AOS!\n\nIhr Bestätigungscode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.\n\nMit freundlichen Grüßen\nDFM Solutions`, + html: `

Willkommen bei AOS!

Ihr Bestätigungscode lautet:

${code}

Der Code ist 5 Minuten gültig.

Mit freundlichen Grüßen
DFM Solutions

`, + }).catch(() => {}) + + // Don't auto-login after registration: require explicit login so the + // user learns the flow (and enters 2FA if they later enable it). + return NextResponse.json({ + ok: true, + message: 'Konto erstellt. Bitte melden Sie sich an.', + role, + emailVerificationSent: true, + // If this is the very first owner, flag it so the UI can show guidance. + isFirstOwner: role === 'owner', + }, { status: 201 }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/auth/resend-otp/route.ts b/src/app/api/auth/resend-otp/route.ts new file mode 100644 index 0000000..63ea4db --- /dev/null +++ b/src/app/api/auth/resend-otp/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' +import { + verifyLoginChallenge, + generateEmailOtp, + hashOtpCode, + otpExpiry, +} from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ + challenge: z.string().min(1), +}) + +// POST /api/auth/resend-otp +// +// Re-issues an email OTP for an in-progress 2FA login. The challenge token +// from step 1 is required — this ensures only users who already proved +// their password can request a new code (no anonymous spam). +// +// Cooldown: at most 1 resend per 30s (enforced by checking the latest code's +// createdAt). Prevents email bombing. +export async function POST(req: Request) { + try { + const parsed = schema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'Ungültige Anfrage' }, { status: 400 }) + } + const { challenge } = parsed.data + + const ch = verifyLoginChallenge(challenge) + if (!ch || ch.method !== 'email') { + return NextResponse.json({ error: 'Ungültige oder abgelaufene Sitzung.' }, { status: 401 }) + } + + // Cooldown: reject if the latest code is < 30s old. + const latest = await db.otpCode.findFirst({ + where: { userId: ch.sub, purpose: 'login' }, + orderBy: { createdAt: 'desc' }, + }) + if (latest && Date.now() - latest.createdAt.getTime() < 30_000) { + return NextResponse.json( + { error: 'Bitte 30 Sekunden warten, bevor Sie einen neuen Code anfordern.', cooldown: true }, + { status: 429 } + ) + } + + const code = generateEmailOtp() + await db.otpCode.create({ + data: { + userId: ch.sub, + codeHash: hashOtpCode(code), + purpose: 'login', + expiresAt: otpExpiry(), + }, + }) + + const user = await db.user.findUnique({ where: { id: ch.sub }, select: { email: true } }) + if (user) { + const { sendEmail } = await import('@/lib/email') + await sendEmail({ + to: user.email, + subject: 'AOS — Neuer Anmeldecode', + text: `Ihr neuer Anmeldecode lautet: ${code}\n\nDer Code ist 5 Minuten gültig.`, + html: `

Ihr neuer Anmeldecode lautet:

${code}

Der Code ist 5 Minuten gültig.

`, + }).catch(() => {}) + } + + return NextResponse.json({ ok: true, message: 'Neuer Code gesendet.' }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..15806ca --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' +import { verifyOtpCode, otpMaxAttempts } from '@/lib/auth' +import { getCurrentUser } from '@/lib/session' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ + code: z.string().regex(/^\d{6}$/, 'Code muss 6 Ziffern sein.'), +}) + +// POST /api/auth/verify-email +// +// Verifies the email ownership OTP sent at registration. Marks the user's +// emailVerifiedAt. Can be called by: +// - an authenticated user verifying their own email, OR +// - an unauthenticated user with just the code (if they haven't logged in +// yet). In that case we look up by the most recent unconsumed code. +// +// Codes are single-use, 5-min TTL, max 5 attempts. +export async function POST(req: Request) { + try { + const parsed = schema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'Ungültiger Code.' }, { status: 400 }) + } + const { code } = parsed.data + + // Try the authenticated user first; fall back to the latest unconsumed code. + const me = await getCurrentUser() + let userId = me?.id + + let otp + if (userId) { + otp = await db.otpCode.findFirst({ + where: { userId, purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + }) + } else { + // Find the most recent unconsumed email-verify code matching this code. + // This is a single-tenant tool with low volume — acceptable. + const candidates = await db.otpCode.findMany({ + where: { purpose: 'email-verify', consumedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + take: 20, + }) + otp = candidates.find((c) => verifyOtpCode(code, c.codeHash)) ?? null + if (otp) userId = otp.userId + } + + if (!otp || !userId) { + return NextResponse.json({ error: 'Kein gültiger Code gefunden — bitte neu anfordern.' }, { status: 404 }) + } + if (otp.attempts >= otpMaxAttempts()) { + await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } }) + return NextResponse.json({ error: 'Zu viele Versuche — bitte neuen Code anfordern.' }, { status: 429 }) + } + + if (!verifyOtpCode(code, otp.codeHash)) { + await db.otpCode.update({ where: { id: otp.id }, data: { attempts: { increment: 1 } } }) + const remaining = otpMaxAttempts() - (otp.attempts + 1) + return NextResponse.json( + { error: 'Falscher Code.', remainingAttempts: Math.max(0, remaining) }, + { status: 401 } + ) + } + + await db.otpCode.update({ where: { id: otp.id }, data: { consumedAt: new Date() } }) + await db.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } }) + + return NextResponse.json({ ok: true, message: 'E-Mail bestätigt.' }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5983f38..580d3fb 100755 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,65 +2,224 @@ import { Suspense, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Loader2, Lock } from 'lucide-react' +import { Loader2, Lock, Mail, ShieldCheck, ArrowLeft, RotateCw } from 'lucide-react' +import { toast } from 'sonner' + +type Step = + | { kind: 'credentials' } + | { kind: 'otp'; method: string; challenge: string; maskedEmail: string } function LoginForm() { const router = useRouter() const searchParams = useSearchParams() const redirect = searchParams.get('redirect') || '/' + const registered = searchParams.get('registered') === '1' + + const [step, setStep] = useState({ kind: 'credentials' }) + const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [code, setCode] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [resendCooldown, setResendCooldown] = useState(false) - const handleSubmit = async (e: React.FormEvent) => { + // --- Step 1: email + password --- + const submitCredentials = async (e: React.FormEvent) => { e.preventDefault() - if (!password) return + if (!email || !password) return setLoading(true) setError('') try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ password, redirect }), + body: JSON.stringify({ email: email.toLowerCase(), password, redirect }), }) const data = await res.json() if (!res.ok) { setError(data.error || 'Login fehlgeschlagen') return } - router.push(data.redirect || '/') - router.refresh() + if (data.twoFactorRequired) { + setStep({ + kind: 'otp', + method: data.method, + challenge: data.challenge, + maskedEmail: data.maskedEmail, + }) + if (data.message) toast.info(data.message) + } else { + router.push(data.redirect || '/') + router.refresh() + } } catch { - setError('Netzwerkfehler') + setError('Netzwerkfehler — bitte erneut versuchen.') } finally { setLoading(false) } } + // --- Step 2: OTP code --- + const submitOtp = async (e: React.FormEvent) => { + e.preventDefault() + if (step.kind !== 'otp' || !code) return + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ challenge: step.challenge, code, redirect }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error || 'Code ungültig') + if (data.remainingAttempts !== undefined) { + setError(`${data.error || 'Code ungültig'} — ${data.remainingAttempts} Versuche übrig.`) + } + return + } + router.push(data.redirect || '/') + router.refresh() + } catch { + setError('Netzwerkfehler — bitte erneut versuchen.') + } finally { + setLoading(false) + } + } + + const resendOtp = async () => { + if (step.kind !== 'otp') return + setResendCooldown(true) + const t = toast.loading('Neuen Code anfordern…') + try { + const res = await fetch('/api/auth/resend-otp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ challenge: step.challenge }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Fehlgeschlagen') + toast.success('Neuer Code gesendet.', { id: t }) + // 30s cooldown to match backend. + setTimeout(() => setResendCooldown(false), 30_000) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t }) + setResendCooldown(false) + } + } + + if (step.kind === 'otp') { + return ( +
+
+ + + {step.method === 'totp' + ? '2-Faktor-Authentifizierung: Code aus Ihrer Authenticator-App eingeben.' + : `Ein 6-stelliger Code wurde an ${step.maskedEmail} gesendet.`} + +
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, step.method === 'totp' ? 6 : 6))} + placeholder="000000" + inputMode="numeric" + pattern="[0-9]*" + autoComplete="one-time-code" + autoFocus + className="text-center text-lg tracking-[0.4em]" + /> +
+ {error &&

{error}

} + +
+ + {step.method === 'email' && ( + + )} +
+

+ Backup-Code vergessen? Geben Sie einen 10-stelligen Backup-Code ein (Format: XXXX-XXXX-XX). +

+
+ ) + } + return ( -
+ +
+ +
+ + setEmail(e.target.value)} + placeholder="name@firma.de" + autoComplete="email" + autoFocus + className="pl-9" + /> +
+
- setPassword(e.target.value)} - placeholder="Admin-Passwort eingeben" - autoFocus - autoComplete="current-password" - /> +
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + className="pl-9" + /> +
- {error && ( -

{error}

+ {error &&

{error}

} + {registered && ( +

+ Konto erstellt. Ein Bestätigungscode wurde an Ihre E-Mail gesendet — bitte anmelden. +

)} - +

+ Noch kein Konto?{' '} + + Konto erstellen + +

) } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..8f8abf6 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, Lock, Mail, User, ShieldCheck } from 'lucide-react' + +export default function RegisterPage() { + const router = useRouter() + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const pwRules = [ + { ok: password.length >= 8, label: 'Mind. 8 Zeichen' }, + { ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' }, + { ok: /[A-Z]/.test(password), label: 'Großbuchstabe' }, + { ok: /[0-9]/.test(password), label: 'Zahl' }, + ] + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name || !email || !password) return + if (password !== confirm) { + setError('Passwörter stimmen nicht überein.') + return + } + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, email: email.toLowerCase(), password }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error || 'Registrierung fehlgeschlagen') + return + } + // Registration successful → go to login. + router.push('/login?registered=1') + router.refresh() + } catch { + setError('Netzwerkfehler — bitte erneut versuchen.') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ +
+

Konto erstellen

+

+ Zugang zum AOS — dfmpanel.eu +

+
+ +
+
+ +
+ + setName(e.target.value)} + placeholder="Max Mustermann" + autoComplete="name" + autoFocus + className="pl-9" + /> +
+
+
+ +
+ + setEmail(e.target.value)} + placeholder="name@firma.de" + autoComplete="email" + className="pl-9" + /> +
+
+
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + className="pl-9" + /> +
+ {password && ( +
+ {pwRules.map((r) => ( + + {r.ok ? : null} + {r.label} + + ))} +
+ )} +
+
+ +
+ + setConfirm(e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + className="pl-9" + /> +
+
+ {error &&

{error}

} + +
+ +

+ Bereits ein Konto?{' '} + + Anmelden + +

+
+
+ ) +} diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index c925c0b..4f9d25d 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -2,6 +2,7 @@ import { Menu, Search, Bell, ChevronRight } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' +import { useFetch } from '@/hooks/use-fetch' import { ThemeToggle } from '@/components/theme-toggle' import { LocaleSwitcher } from './locale-switcher' import { NotificationsBell } from './notifications-bell' @@ -51,6 +52,14 @@ export function TopBar() { const { view, setSidebarOpen, setCommandOpen } = useAos() const meta = TITLES[view] const crumbParts = meta.crumb.split(' / ') + const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me') + const user = meData?.user + const initials = (user?.name ?? '?') + .split(' ') + .map((p) => p[0]) + .slice(0, 2) + .join('') + .toUpperCase() return (
@@ -95,19 +104,19 @@ export function TopBar() { - Max Brandt - max@nordlicht.digital + {user?.name ?? '—'} + {user?.email ?? ''} Rolle - Owner + {user?.role ?? '—'} Profil Sitzungen diff --git a/src/components/aos/two-factor-settings.tsx b/src/components/aos/two-factor-settings.tsx new file mode 100644 index 0000000..9eb369b --- /dev/null +++ b/src/components/aos/two-factor-settings.tsx @@ -0,0 +1,397 @@ +'use client' + +import { useState } from 'react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + ShieldCheck, + Smartphone, + Mail, + Loader2, + Lock, + Copy, + Check, + AlertTriangle, + KeyRound, +} from 'lucide-react' +import { toast } from 'sonner' + +interface MeResponse { + user: { + id: string + email: string + name: string + role: string + emailVerifiedAt: string | null + twoFactorEnabled: boolean + twoFactorMethod: string | null + lastLoginAt: string | null + } +} + +type EnableStep = + | { kind: 'choose-method' } + | { kind: 'totp-setup'; secret: string; qr: string; otpauthUrl: string } + | { kind: 'enter-code'; method: 'totp' | 'email'; secret?: string } + | { kind: 'backup-codes'; codes: string[] } + +/** + * TwoFactorSettings — self-contained 2FA management card. + * + * States: + * - 2FA OFF → "Enable" button → method chooser (Email / Authenticator) + * - Email: re-auth (password) → enable → show backup codes + * - TOTP: setup (QR + secret) → enter code from app → re-auth → enable → backup codes + * - 2FA ON → show method + status → "Disable" (re-auth required) + * + * Fetches /api/auth/me for the current status. Uses: + * POST /api/auth/2fa/setup → TOTP provisioning + * POST /api/auth/2fa/enable → finalize (re-auth + verify) + * POST /api/auth/2fa/disable → turn off (re-auth required) + */ +export function TwoFactorSettings() { + const { data, loading, refetch } = useFetch('/api/auth/me') + const [enableOpen, setEnableOpen] = useState(false) + const [disableOpen, setDisableOpen] = useState(false) + const [step, setStep] = useState({ kind: 'choose-method' }) + const [password, setPassword] = useState('') + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(null) + + const user = data?.user + + const reset = () => { + setStep({ kind: 'choose-method' }) + setPassword('') + setCode('') + setBusy(false) + } + + // --- TOTP setup: get QR + secret --- + const startTotp = async () => { + setBusy(true) + const t = toast.loading('QR-Code wird generiert…') + try { + const r = await fetch('/api/auth/2fa/setup', { method: 'POST' }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'Setup fehlgeschlagen') + setStep({ kind: 'totp-setup', secret: res.secret, qr: res.qr, otpauthUrl: res.otpauthUrl }) + toast.success('QR-Code bereit. Mit Authenticator-App scannen.', { id: t }) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t }) + } finally { + setBusy(false) + } + } + + // --- Enable 2FA (re-auth + verify code) --- + const enable = async () => { + if (!password) return + if (step.kind === 'enter-code' && !code) return + setBusy(true) + const t = toast.loading('2FA wird aktiviert…') + try { + const method = step.kind === 'enter-code' ? step.method : 'email' + const body: Record = { method, password } + if (step.kind === 'enter-code' && step.method === 'totp') { + body.code = code + body.secret = step.secret ?? '' + } + const r = await fetch('/api/auth/2fa/enable', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'Aktivierung fehlgeschlagen') + toast.success(res.message ?? '2FA aktiviert', { id: t, duration: 6000 }) + if (res.backupCodes?.length) { + setStep({ kind: 'backup-codes', codes: res.backupCodes }) + } else { + setEnableOpen(false) + reset() + refetch() + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t }) + } finally { + setBusy(false) + } + } + + // --- Disable 2FA --- + const disable = async () => { + if (!password) return + setBusy(true) + const t = toast.loading('2FA wird deaktiviert…') + try { + const r = await fetch('/api/auth/2fa/disable', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password }), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'Deaktivierung fehlgeschlagen') + toast.success(res.message ?? '2FA deaktiviert', { id: t }) + setDisableOpen(false) + setPassword('') + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t }) + } finally { + setBusy(false) + } + } + + const copy = (text: string, label: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(label) + setTimeout(() => setCopied(null), 2000) + toast.success(`${label} kopiert`) + }) + } + + const enabled = user?.twoFactorEnabled + const method = user?.twoFactorMethod + + return ( + + + + + Zwei-Faktor-Authentifizierung (2FA) + + + + {loading ? ( +
+ Laden… +
+ ) : !user ? ( +

Nicht angemeldet.

+ ) : enabled ? ( + <> +
+
+ + Aktiv + + + {method === 'totp' ? ( + Authenticator-App + ) : method === 'email' ? ( + E-Mail-OTP + ) : method} + +
+ +
+

+ Beim Anmelden wird zusätzlich zum Passwort ein einmaliger Code benötigt. Backup-Codes wurden bei der Einrichtung angezeigt — sicher aufbewahren. +

+ + ) : ( + <> +

+ Schützen Sie Ihr Konto mit einem zweiten Faktor. Optional — kann jederzeit aktiviert oder deaktiviert werden. +

+ + + )} +
+ + {/* --- Enable flow --- */} + { setEnableOpen(o); if (!o) reset() }}> + + + + + 2FA aktivieren + + + {step.kind === 'backup-codes' + ? 'Backup-Codes — sicher aufbewahren (einmalig sichtbar).' + : 'Wählen Sie eine Methode und bestätigen Sie mit Ihrem Passwort.'} + + + + {step.kind === 'choose-method' && ( +
+ + +
+ )} + + {step.kind === 'totp-setup' && ( +
+
+ {/* QR code as data URI — no next/image optimization needed */} + QR-Code für Authenticator +

+ Scannen Sie den QR-Code mit Ihrer Authenticator-App. +

+
+
+ +
+ + {step.secret} + + +
+
+ +
+ )} + + {step.kind === 'enter-code' && ( +
+ {step.method === 'totp' && ( +
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + inputMode="numeric" + className="text-center text-lg tracking-[0.4em]" + autoFocus + /> +
+ )} + {step.method === 'email' && ( +

+ Beim Aktivieren wird ein Bestätigungscode an Ihre E-Mail gesendet. Geben Sie unten Ihr Passwort ein, um die Einrichtung abzuschließen. +

+ )} +
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + /> +
+ + + + +
+ )} + + {step.kind === 'backup-codes' && ( +
+
+

+ + Diese Codes sind nur jetzt sichtbar. +

+

Bei Verlust Ihres zweiten Faktors erlaubt jeder Code eine Anmeldung. Sicher aufbewahren (z.B. Passwort-Manager oder Ausdruck im Tresor).

+
+
+ {step.codes.map((c) => ( + {c} + ))} +
+
+ + +
+
+ )} +
+
+ + {/* --- Disable flow --- */} + { setDisableOpen(o); if (!o) setPassword('') }}> + + + + + 2FA deaktivieren + + + Ihr Konto ist dann nur noch durch das Passwort geschützt. Bitte Passwort zur Bestätigung eingeben. + + +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + autoFocus + /> +
+ + + + +
+
+
+
+ ) +} diff --git a/src/components/aos/views/settings.tsx b/src/components/aos/views/settings.tsx index 32dd9df..55de5ba 100755 --- a/src/components/aos/views/settings.tsx +++ b/src/components/aos/views/settings.tsx @@ -19,6 +19,7 @@ import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { PageHeader } from '../primitives' +import { TwoFactorSettings } from '../two-factor-settings' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -198,6 +199,9 @@ export function SettingsView() { + {/* Account security — 2FA */} + + {/* Agency settings preview */} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..f77d788 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,262 @@ +import { scryptSync, randomBytes, timingSafeEqual, createHash, randomUUID, createHmac } from 'crypto' +import { generateSecret, verifySync, generateURI } from 'otplib' + +/** + * Password hashing — uses Node's built-in scrypt (no external dependency). + * Format stored in DB: ":" (both 32 bytes). + * + * scrypt parameters (N=16384, r=8, p=1) match OWASP 2023 recommendations + * and run in ~80ms — acceptable for interactive login. + */ + +const SALT_BYTES = 16 +const KEY_LEN = 32 +const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } + +export function hashPassword(password: string): string { + const salt = randomBytes(SALT_BYTES) + const hash = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS) + return `${salt.toString('hex')}:${hash.toString('hex')}` +} + +export function verifyPassword(password: string, stored: string): boolean { + try { + const [saltHex, hashHex] = stored.split(':') + if (!saltHex || !hashHex) return false + const salt = Buffer.from(saltHex, 'hex') + const expected = Buffer.from(hashHex, 'hex') + const actual = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS) + if (actual.length !== expected.length) return false + return timingSafeEqual(actual, expected) + } catch { + return false + } +} + +/** + * Password policy — enforce on registration / password change. + */ +export function validatePasswordStrength(password: string): string | null { + if (password.length < 8) return 'Passwort muss mindestens 8 Zeichen lang sein.' + if (password.length > 200) return 'Passwort ist zu lang.' + if (!/[a-z]/.test(password)) return 'Passwort muss einen Kleinbuchstaben enthalten.' + if (!/[A-Z]/.test(password)) return 'Passwort muss einen Großbuchstaben enthalten.' + if (!/[0-9]/.test(password)) return 'Passwort muss eine Zahl enthalten.' + return null +} + +/** + * TOTP — Time-based One-Time Password (RFC 6238). + * Compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator. + * + * otplib v13 API (async-first, but we use the *Sync variants for + * request-handler simplicity — TOTP computation is fast/synchronous). + * + * epochTolerance: 1 → ±1 step (±30s) to tolerate clock drift. + */ + +const TOTP_OPTS = { + strategy: 'totp' as const, + period: 30, + digits: 6, + epochTolerance: 1, +} + +export function generateTotpSecret(): string { + return generateSecret({ length: 20 }) +} + +export function totpOtpauthUrl(opts: { + secret: string + label: string + issuer?: string +}): string { + return generateURI({ + issuer: opts.issuer ?? 'AOS', + label: opts.label, + secret: opts.secret, + strategy: 'totp', + period: 30, + digits: 6, + }) +} + +export function verifyTotp(token: string, secret: string): boolean { + try { + return verifySync({ + ...TOTP_OPTS, + secret, + token: token.replace(/\s/g, ''), + }) + } catch { + return false + } +} + +/** + * Email OTP — 6-digit numeric codes. We store only the SHA-256 hash in the DB + * (never the code itself). Codes are single-use and expire after 5 minutes. + */ +export function generateEmailOtp(): string { + // 6-digit code with leading zeros preserved. + const n = randomBytes(4).readUInt32BE(0) % 1000000 + return String(n).padStart(6, '0') +} + +export function hashOtpCode(code: string): string { + return createHash('sha256').update(code).digest('hex') +} + +export function verifyOtpCode(code: string, codeHash: string): boolean { + const actual = hashOtpCode(code) + const expected = Buffer.from(codeHash, 'hex') + const actualBuf = Buffer.from(actual, 'hex') + if (actualBuf.length !== expected.length) return false + return timingSafeEqual(actualBuf, expected) +} + +const OTP_TTL_MS = 5 * 60 * 1000 // 5 minutes +const OTP_MAX_ATTEMPTS = 5 + +export function otpExpiry(): Date { + return new Date(Date.now() + OTP_TTL_MS) +} + +export function otpMaxAttempts(): number { + return OTP_MAX_ATTEMPTS +} + +/** + * Backup codes — 8 single-use recovery codes, stored hashed. Shown once to the + * user when 2FA is enabled. Each code: 10 chars, grouped XXXX-XXXX-XX. + */ +export function generateBackupCodes(count = 8): { plain: string[]; hashed: string } { + const plain: string[] = [] + const hashed: string[] = [] + for (let i = 0; i < count; i++) { + const raw = randomBytes(6).toString('base64url').replace(/[-_]/g, '').slice(0, 10).toUpperCase() + const formatted = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 10)}` + plain.push(formatted) + hashed.push(hashOtpCode(formatted)) + } + return { plain, hashed: hashed.join('\n') } +} + +export function consumeBackupCode( + submitted: string, + storedHashed: string +): { match: boolean; remainingHashed: string } { + const hashed = storedHashed.split('\n').filter(Boolean) + const submittedHash = hashOtpCode(submitted.trim().toUpperCase()) + const idx = hashed.findIndex((h) => { + try { + return timingSafeEqual(Buffer.from(h, 'hex'), Buffer.from(submittedHash, 'hex')) + } catch { + return false + } + }) + if (idx === -1) return { match: false, remainingHashed: storedHashed } + const remaining = hashed.filter((_, i) => i !== idx) + return { match: true, remainingHashed: remaining.join('\n') } +} + +/** + * Session token — HMAC-signed JWT-like token. + * Format: base64url(payload).hmac + * The signing key is AUTH_SECRET (required). Falls back to a random ephemeral + * key only in dev when AUTH_SECRET is unset (sessions won't survive restart). + */ + +export interface SessionPayload { + sub: string // user id + email: string + role: string + iat: number + exp: number // ms epoch +} + +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +function getSessionKey(): string { + const key = process.env.AUTH_SECRET + if (!key) { + // Dev fallback — warn so production sets AUTH_SECRET. + if (process.env.NODE_ENV === 'production') { + throw new Error('AUTH_SECRET must be set in production') + } + // Stable per-process fallback (sessions valid until server restart). + return 'dev-insecure-secret-' + process.pid + } + return key +} + +export function createSessionToken(payload: Omit): string { + const full: SessionPayload = { + ...payload, + iat: Date.now(), + exp: Date.now() + SESSION_TTL_MS, + } + const payloadB64 = Buffer.from(JSON.stringify(full)).toString('base64url') + const sig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url') + return `${payloadB64}.${sig}` +} + +export function verifySessionToken(token: string | undefined): SessionPayload | null { + if (!token) return null + const [payloadB64, sig] = token.split('.') + if (!payloadB64 || !sig) return null + try { + const expectedSig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url') + const a = Buffer.from(sig, 'base64url') + const b = Buffer.from(expectedSig, 'base64url') + if (a.length !== b.length || !timingSafeEqual(a, b)) return null + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as SessionPayload + if (payload.exp && Date.now() > payload.exp) return null + return payload + } catch { + return null + } +} + +/** + * Login challenge — short-lived signed token returned from login step 1. + * Carries the userId + method so step 2 (/verify) can issue the session + * without re-sending the password. Expires in 5 minutes. + * + * Format: base64url({ sub, method, jti, exp }).hmac + */ +export function createLoginChallenge(userId: string, method: string): string { + const payload = { + sub: userId, + method, + jti: randomUUID(), + exp: Date.now() + OTP_TTL_MS, + } + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') + const sig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url') + return `${payloadB64}.${sig}` +} + +export interface LoginChallenge { + sub: string + method: string + jti: string + exp: number +} + +export function verifyLoginChallenge(token: string): LoginChallenge | null { + if (!token) return null + const [payloadB64, sig] = token.split('.') + if (!payloadB64 || !sig) return null + try { + const expectedSig = createHmac('sha256', getSessionKey()).update(payloadB64).digest('base64url') + const a = Buffer.from(sig, 'base64url') + const b = Buffer.from(expectedSig, 'base64url') + if (a.length !== b.length || !timingSafeEqual(a, b)) return null + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) as LoginChallenge + if (payload.exp && Date.now() > payload.exp) return null + return payload + } catch { + return null + } +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..17e6fb8 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,47 @@ +import { cookies } from 'next/headers' +import { db } from '@/lib/db' +import { verifySessionToken, type SessionPayload } from '@/lib/auth' + +/** + * Session helpers for server components / route handlers. + * Reads the `aos-session` cookie, verifies the HMAC, and resolves the User. + */ + +export async function getSession(): Promise { + const store = await cookies() + const token = store.get('aos-session')?.value + return verifySessionToken(token) +} + +export async function getCurrentUser() { + const session = await getSession() + if (!session) return null + const user = await db.user.findUnique({ + where: { id: session.sub }, + select: { + id: true, + email: true, + name: true, + role: true, + emailVerifiedAt: true, + twoFactorEnabled: true, + twoFactorMethod: true, + lastLoginAt: true, + }, + }) + return user +} + +export async function requireUser() { + const user = await getCurrentUser() + if (!user) { + throw new Error('UNAUTHORIZED') + } + return user +} + +/** Actor string for AuditLog entries — identifies the authenticated user. */ +export async function getActor(): Promise { + const user = await getCurrentUser() + return user ? `user:${user.email}` : 'anonymous' +} diff --git a/src/middleware.ts b/src/middleware.ts index 4a4346b..b14aac7 100755 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,21 @@ import { NextRequest, NextResponse } from 'next/server' // Middleware: protects all routes except public ones. -// Uses a simple session cookie (HMAC-signed) for auth. -// Password is set via ADMIN_PASSWORD env var. +// +// Session = HMAC-signed token (base64url(payload).hmac) issued by the login +// flow. The signing key is AUTH_SECRET (required). Verified here with a +// constant-time HMAC comparison. The payload carries { sub, email, role, +// iat, exp } with a 7-day TTL. +// +// Auth flow (email + password, optional 2FA): +// /login — email + password step (public) +// /login/verify — 2FA OTP step (public, uses signed challenge) +// /register — create account (public) +// /verify-email — confirm email OTP (public) +// /resend-otp — re-send login OTP (public, challenge-gated) +// /2fa/* — manage 2FA (session-protected) +// /me — current user (session-protected) +// // Also applies global rate limiting to API routes. // --- Simple in-memory rate limiter (per-IP, per-minute) --- @@ -35,7 +48,11 @@ const PUBLIC_PATHS = [ '/api/aos/licenses/validate', '/api/aos/stripe/webhook', '/api/auth/login', + '/api/auth/register', + '/api/auth/verify-email', + '/api/auth/resend-otp', '/login', + '/register', '/_next', '/favicon.ico', '/manifest.json', @@ -50,6 +67,10 @@ const PUBLIC_API_PREFIXES = [ '/api/aos/licenses/validate', '/api/aos/stripe/webhook', '/api/auth/login', + '/api/auth/login/verify', + '/api/auth/register', + '/api/auth/verify-email', + '/api/auth/resend-otp', '/api/v1/', // public REST API (token-auth'd separately) '/api/mcp', // MCP (token-auth'd) ] @@ -66,48 +87,66 @@ function isPublic(pathname: string): boolean { return false } +// --- Session token verification (Web Crypto — Edge-runtime compatible) --- +// +// Token format: base64url(payload).base64url(hmac_sha256(payload)) +// The HMAC key is AUTH_SECRET. We verify with SubtleCrypto.verify, which +// is constant-time in practice and runs in the Edge runtime (unlike Node's +// `crypto` module, which produced the earlier "not supported in Edge +// Runtime" warning). Payload carries { sub, email, role, iat, exp }. +function base64urlToBytes(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)) + const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/') + const bin = atob(b64) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + return bytes +} + async function verifySession(token: string | undefined): Promise { - if (!token) { - console.log('[auth] No session token') - return false - } - const adminPassword = process.env.ADMIN_PASSWORD - if (!adminPassword) { - console.log('[auth] ADMIN_PASSWORD not set') + if (!token) return false + const secret = process.env.AUTH_SECRET + if (!secret) { + // In dev without AUTH_SECRET, allow the dev-ephemeral fallback tokens. + // The route-handler verifySessionToken() enforces the same key. + if (process.env.NODE_ENV !== 'production') return parseAndCheckExpiry(token) return false } try { - // Session token format: base64url(payload).hmac - const [payloadB64, signature] = token.split('.') - if (!payloadB64 || !signature) { - console.log('[auth] Token format invalid (no payload or signature)') - return false - } - // Use base64url to match the encoding in the login route - const decoded = Buffer.from(payloadB64, 'base64url').toString() - const payload = JSON.parse(decoded) - if (payload.exp && Date.now() > payload.exp) { - console.log('[auth] Token expired') - return false - } - // Verify HMAC - const crypto = await import('crypto') - const key = process.env.AUTH_SECRET || adminPassword - const expectedSig = crypto - .createHmac('sha256', key) - .update(payloadB64) - .digest('base64url') - const match = signature === expectedSig - if (!match) { - console.log('[auth] HMAC mismatch') - console.log('[auth] expected:', expectedSig?.substring(0, 20)) - console.log('[auth] got:', signature?.substring(0, 20)) - console.log('[auth] AUTH_SECRET set:', !!process.env.AUTH_SECRET) - console.log('[auth] ADMIN_PASSWORD set:', !!adminPassword) - } - return match - } catch (e) { - console.log('[auth] Verification error:', e instanceof Error ? e.message : 'unknown') + const [payloadB64, sigB64] = token.split('.') + if (!payloadB64 || !sigB64) return false + // Expiry check first (cheap, avoids crypto work for expired tokens). + const payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(payloadB64))) + if (payload.exp && Date.now() > payload.exp) return false + const enc = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ) + return await crypto.subtle.verify( + 'HMAC', + key, + base64urlToBytes(sigB64), + enc.encode(payloadB64) + ) + } catch { + return false + } +} + +// Dev-only fallback: check expiry without HMAC (the login route still does +// full HMAC verification before issuing the token). +function parseAndCheckExpiry(token: string): boolean { + try { + const [payloadB64] = token.split('.') + if (!payloadB64) return false + const payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(payloadB64))) + if (payload.exp && Date.now() > payload.exp) return false + return true + } catch { return false } } @@ -131,52 +170,23 @@ export async function middleware(req: NextRequest) { return NextResponse.next() } - // Check session cookie — simplified: just check if cookie exists. - // Full HMAC verification happens in the login route. If the cookie - // is present, it was set by our login endpoint (which already verified - // the password). This is sufficient for middleware-level protection. + // Verify the session cookie with full HMAC (Web Crypto, Edge-compatible). const sessionToken = req.cookies.get('aos-session')?.value + const valid = await verifySession(sessionToken) - if (!sessionToken) { - // For API routes, return 401 + if (!valid) { if (pathname.startsWith('/api/')) { return NextResponse.json( { error: 'unauthorized', message: 'Authentication required' }, { status: 401 } ) } - // For pages, redirect to login + // For pages, redirect to login with the intended destination. const loginUrl = new URL('/login', req.url) loginUrl.searchParams.set('redirect', pathname) return NextResponse.redirect(loginUrl) } - // Cookie exists — check if it looks valid (has the right format) - // and isn't expired. Full HMAC check is done at login time. - try { - const [payloadB64] = sessionToken.split('.') - if (!payloadB64) { - throw new Error('invalid token format') - } - const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()) - if (payload.exp && Date.now() > payload.exp) { - // Token expired — redirect to login - const loginUrl = new URL('/login', req.url) - loginUrl.searchParams.set('redirect', pathname) - return NextResponse.redirect(loginUrl) - } - } catch { - // Invalid token — treat as unauthenticated - if (pathname.startsWith('/api/')) { - return NextResponse.json( - { error: 'unauthorized', message: 'Invalid session' }, - { status: 401 } - ) - } - const loginUrl = new URL('/login', req.url) - return NextResponse.redirect(loginUrl) - } - return NextResponse.next() }