diff --git a/package-lock.json b/package-lock.json index b63ca54..aac581d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nebula", - "version": "0.6.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nebula", - "version": "0.6.0", + "version": "0.9.0", "hasInstallScript": true, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -18,6 +18,7 @@ "lucide-react": "^0.511.0", "next": "^15.3.3", "nodemailer": "^9.0.3", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -30,6 +31,7 @@ "@tailwindcss/postcss": "^4.1.7", "@types/node": "^22.15.21", "@types/nodemailer": "^8.0.1", + "@types/qrcode": "^1.5.6", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", "prisma": "^6.8.2", @@ -2331,6 +2333,16 @@ "@types/node": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -2475,6 +2487,30 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -2538,6 +2574,15 @@ } } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -2650,6 +2695,35 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2707,6 +2781,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2776,6 +2859,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2800,6 +2889,12 @@ "fast-check": "^3.23.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -2969,6 +3064,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2984,6 +3092,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -3114,6 +3231,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -3419,6 +3545,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4457,6 +4595,42 @@ "devOptional": true, "license": "MIT" }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -4482,6 +4656,15 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4573,6 +4756,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -4673,6 +4865,23 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -4832,6 +5041,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -4885,6 +5109,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -4970,6 +5200,20 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4984,6 +5228,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -5457,6 +5713,12 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5474,6 +5736,61 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index b9fcde4..ec7229b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lucide-react": "^0.511.0", "next": "^15.3.3", "nodemailer": "^9.0.3", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -55,6 +56,7 @@ "@tailwindcss/postcss": "^4.1.7", "@types/node": "^22.15.21", "@types/nodemailer": "^8.0.1", + "@types/qrcode": "^1.5.6", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", "prisma": "^6.8.2", diff --git a/prisma/migrations/20260726220000_totp/migration.sql b/prisma/migrations/20260726220000_totp/migration.sql new file mode 100644 index 0000000..830ad48 --- /dev/null +++ b/prisma/migrations/20260726220000_totp/migration.sql @@ -0,0 +1,47 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "totpEnabledAt" TIMESTAMP(3), +ADD COLUMN "totpSecretCipher" TEXT; + +-- CreateTable +CREATE TABLE "RecoveryCode" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RecoveryCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PendingLogin" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PendingLogin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "RecoveryCode_codeHash_key" ON "RecoveryCode"("codeHash"); + +-- CreateIndex +CREATE INDEX "RecoveryCode_userId_idx" ON "RecoveryCode"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingLogin_tokenHash_key" ON "PendingLogin"("tokenHash"); + +-- CreateIndex +CREATE INDEX "PendingLogin_userId_idx" ON "PendingLogin"("userId"); + +-- CreateIndex +CREATE INDEX "PendingLogin_expiresAt_idx" ON "PendingLogin"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "RecoveryCode" ADD CONSTRAINT "RecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PendingLogin" ADD CONSTRAINT "PendingLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index a5d6f0e..68e904a 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -18,7 +18,7 @@ export async function generateMetadata(): Promise { export default async function AccountPage() { const user = await requireUserPage(); - const [passkeys, sessions, targets] = await Promise.all([ + const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([ prisma.credential.findMany({ where: { userId: user.id }, select: { @@ -58,6 +58,11 @@ export default async function AccountPage() { }, orderBy: { name: "asc" }, }), + prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { totpEnabledAt: true }, + }), + prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }), ]); const data: AccountData = { @@ -69,6 +74,11 @@ export default async function AccountPage() { passkeys, sessions, passkeysAvailable: WEBAUTHN_AVAILABLE, + totp: { + enabled: Boolean(konto.totpEnabledAt), + enabledAt: konto.totpEnabledAt, + recoveryCodesLeft: offeneCodes, + }, }; return ( diff --git a/src/app/api/account/totp/recovery/route.ts b/src/app/api/account/totp/recovery/route.ts new file mode 100644 index 0000000..7f6e103 --- /dev/null +++ b/src/app/api/account/totp/recovery/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireUserApi } from "@/lib/auth/guard"; +import { verifyPassword } from "@/lib/auth/password"; +import { issueRecoveryCodes } from "@/lib/auth/recovery"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ password: z.string().min(1).max(200) }); + +/** + * Neue Wiederherstellungscodes. Die alten verfallen dabei – sonst waeren + * Zettel im Umlauf, von denen niemand mehr weiss, welche noch gelten. + * + * Verlangt das Passwort, wie das Abschalten auch: die Codes sind gleichwertig + * zum zweiten Faktor. + */ +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = schema.safeParse(body.data); + if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422); + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: auth.user.id }, + select: { passwordHash: true, totpEnabledAt: true }, + }); + + if (!user.totpEnabledAt) { + return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409); + } + if (!(await verifyPassword(parsed.data.password, user.passwordHash))) { + return jsonError("Das Passwort stimmt nicht.", 401); + } + + return NextResponse.json({ recoveryCodes: await issueRecoveryCodes(auth.user.id) }); +} diff --git a/src/app/api/account/totp/route.ts b/src/app/api/account/totp/route.ts new file mode 100644 index 0000000..45f4fe9 --- /dev/null +++ b/src/app/api/account/totp/route.ts @@ -0,0 +1,167 @@ +import { NextResponse } from "next/server"; +import QRCode from "qrcode"; +import { z } from "zod"; +import { requireUserApi } from "@/lib/auth/guard"; +import { verifyPassword } from "@/lib/auth/password"; +import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery"; +import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp"; +import { APP_NAME } from "@/lib/config"; +import { encryptSecret, tryDecryptSecret } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Zwei-Faktor-Anmeldung per Einmalkennwort. + * + * Die Einrichtung laeuft in zwei Schritten: POST legt einen Schluessel an und + * zeigt ihn als QR-Code, PUT schaltet ihn nach einem bestaetigten Code scharf. + * Erst dann fragt die Anmeldung danach. Wer die Einrichtung abbricht oder den + * QR-Code nie scannt, sperrt sich so nicht aus. + */ + +/** Zustand fuer die Anzeige. */ +export async function GET() { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: auth.user.id }, + select: { totpEnabledAt: true }, + }); + + return NextResponse.json({ + enabled: Boolean(user.totpEnabledAt), + enabledAt: user.totpEnabledAt, + recoveryCodesLeft: user.totpEnabledAt + ? await countUnusedRecoveryCodes(auth.user.id) + : 0, + }); +} + +/** Schritt 1: Schluessel erzeugen und als QR-Code zurueckgeben. */ +export async function POST() { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: auth.user.id }, + select: { totpEnabledAt: true, username: true }, + }); + + if (user.totpEnabledAt) { + return jsonError( + "Die Zwei-Faktor-Anmeldung ist bereits aktiv. Bitte zuerst abschalten.", + 409, + ); + } + + // Bei jedem Anlauf ein frischer Schluessel: ein halb eingerichteter aus + // einem frueheren Versuch koennte in einer Authenticator-App liegen, ohne + // dass hier bekannt waere, in welcher. + const secret = generateTotpSecret(); + await prisma.user.update({ + where: { id: auth.user.id }, + data: { totpSecretCipher: encryptSecret(secret) }, + }); + + const uri = otpauthUri({ secret, account: user.username, issuer: APP_NAME }); + + return NextResponse.json({ + // Das Bild als data:-URI, damit die Content-Security-Policy nichts + // nachladen muss. img-src erlaubt data: bereits. + qr: await QRCode.toDataURL(uri, { margin: 1, width: 320 }), + uri, + /** Zum Abtippen, falls die Kamera nicht mitspielt. */ + secret: groupSecret(secret), + }); +} + +const enableSchema = z.object({ code: z.string().min(6).max(10) }); + +/** Schritt 2: Code bestaetigen, scharfschalten, Wiederherstellungscodes ausgeben. */ +export async function PUT(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = enableSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: auth.user.id }, + select: { totpSecretCipher: true, totpEnabledAt: true }, + }); + + if (user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist bereits aktiv.", 409); + + const secret = tryDecryptSecret(user.totpSecretCipher); + if (!secret) { + return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409); + } + + if (!verifyTotp(secret, parsed.data.code)) { + return jsonError( + "Der Code stimmt nicht. Geht die Uhr des Geraets richtig?", + 401, + ); + } + + await prisma.user.update({ + where: { id: auth.user.id }, + data: { totpEnabledAt: new Date() }, + }); + + return NextResponse.json({ + ok: true, + // Einmalige Ausgabe: gespeichert wird nur ihr Hash. + recoveryCodes: await issueRecoveryCodes(auth.user.id), + }); +} + +const disableSchema = z.object({ password: z.string().min(1).max(200) }); + +/** Abschalten. Verlangt das Passwort, nicht nur eine offene Sitzung. */ +export async function DELETE(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = disableSchema.safeParse(body.data); + if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422); + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: auth.user.id }, + select: { passwordHash: true, totpEnabledAt: true }, + }); + + if (!user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409); + + // Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor + // nicht im Vorbeigehen entfernen koennen. + if (!(await verifyPassword(parsed.data.password, user.passwordHash))) { + return jsonError("Das Passwort stimmt nicht.", 401); + } + + await prisma.$transaction([ + prisma.user.update({ + where: { id: auth.user.id }, + data: { totpSecretCipher: null, totpEnabledAt: null }, + }), + prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }), + prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }), + ]); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 77fde27..ab1a8c3 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa"; import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password"; import { createSession, setSessionCookie } from "@/lib/auth/session"; import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users"; @@ -68,6 +69,18 @@ export async function POST(request: Request) { return jsonError(GENERIC, 401); } + // Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier + // noch keine Sitzung, sondern nur ein Vorgang mit kurzer Frist. + if (user.totpEnabledAt) { + // Die Zaehler bleiben stehen, bis die Anmeldung wirklich durch ist: + // sonst liesse sich ueber wiederholte Passworteingaben die Sperre + // umgehen, die falsche Passwoerter aufbaut. + const token = await createPendingLogin(user.id); + const response = NextResponse.json({ ok: false, mfaRequired: true }); + setMfaCookie(response, token); + return response; + } + await noteSuccessfulLogin(user.id); resetRateLimit(`login:user:${username}`); resetRateLimit(`login:ip:${ip}`); diff --git a/src/app/api/auth/mfa/route.ts b/src/app/api/auth/mfa/route.ts new file mode 100644 index 0000000..4f48c01 --- /dev/null +++ b/src/app/api/auth/mfa/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + clearMfaCookie, + discardPendingLogin, + noteMfaFailure, + readPendingLogin, +} from "@/lib/auth/mfa"; +import { redeemRecoveryCode } from "@/lib/auth/recovery"; +import { createSession, setSessionCookie } from "@/lib/auth/session"; +import { verifyTotp } from "@/lib/auth/totp"; +import { noteSuccessfulLogin } from "@/lib/auth/users"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +/** + * Der zweite Schritt der Anmeldung: Einmalkennwort oder + * Wiederherstellungscode. + * + * Der Vorgang steckt im kurzlebigen Cookie aus Schritt eins. Wer hier ohne + * diesen Cookie ankommt, hat kein Passwort vorgezeigt und bekommt nichts. + */ + +const schema = z.object({ + code: z.string().min(1).max(40), +}); + +const GENERIC = "Der Code stimmt nicht."; + +export async function POST(request: Request) { + const ip = clientIp(request); + const limited = rateLimit(`mfa:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = schema.safeParse(body.data); + if (!parsed.success) return jsonError(GENERIC, 401); + + const pending = await readPendingLogin(); + if (!pending) { + const response = jsonError( + "Die Anmeldung ist abgelaufen. Bitte melde dich neu an.", + 401, + ); + clearMfaCookie(response); + return response; + } + + if (!pending.user.isActive) { + await discardPendingLogin(pending.id); + const response = jsonError("Dieses Konto ist deaktiviert.", 403); + clearMfaCookie(response); + return response; + } + + const eingabe = parsed.data.code.trim(); + + // Sechs Ziffern sind ein Einmalkennwort, alles andere ein + // Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die + // Formate ueberschneiden sich nicht. + const istZiffern = /^[\d\s]+$/.test(eingabe); + const erfolg = istZiffern + ? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe) + : await redeemRecoveryCode(pending.user.id, eingabe); + + if (!erfolg) { + const weiterMoeglich = await noteMfaFailure(pending.id); + if (!weiterMoeglich) { + const response = jsonError( + "Zu viele Fehlversuche. Bitte melde dich neu an.", + 429, + ); + clearMfaCookie(response); + return response; + } + return jsonError(GENERIC, 401); + } + + await discardPendingLogin(pending.id); + await noteSuccessfulLogin(pending.user.id); + + const session = await createSession(pending.user.id, { + userAgent: request.headers.get("user-agent"), + ip, + }); + + const response = NextResponse.json({ + ok: true, + user: { id: pending.user.id, username: pending.user.username, role: pending.user.role }, + }); + setSessionCookie(response, session.token, session.expiresAt); + clearMfaCookie(response); + return response; +} diff --git a/src/components/account/AccountManager.tsx b/src/components/account/AccountManager.tsx index 6a0d0bf..862806b 100644 --- a/src/components/account/AccountManager.tsx +++ b/src/components/account/AccountManager.tsx @@ -14,6 +14,7 @@ import { ShieldCheck, Trash2, } from "lucide-react"; +import { TotpSection, type TotpState } from "@/components/account/TotpSection"; import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants"; import { cn, formatDateTime, formatRelative } from "@/lib/utils"; @@ -43,6 +44,7 @@ export interface AccountData { passkeys: PasskeyEntry[]; sessions: SessionEntry[]; passkeysAvailable: boolean; + totp: TotpState; } type Notice = { ok: boolean; text: string } | null; @@ -52,6 +54,7 @@ export function AccountManager({ data }: { data: AccountData }) {
+
diff --git a/src/components/account/TotpSection.tsx b/src/components/account/TotpSection.tsx new file mode 100644 index 0000000..0c1efb2 --- /dev/null +++ b/src/components/account/TotpSection.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Check, + Copy, + Download, + Loader2, + RefreshCw, + ShieldCheck, + ShieldOff, + Smartphone, +} from "lucide-react"; +import { cn, formatDateTime } from "@/lib/utils"; + +/** + * Zwei-Faktor-Anmeldung per Einmalkennwort. + * + * Die Einrichtung ist bewusst zweistufig: erst der QR-Code, dann ein Code zur + * Bestaetigung. Scharf wird der Schluessel erst danach – sonst waere jemand, + * der den QR-Code nicht scannen konnte, beim naechsten Anmelden ausgesperrt. + */ + +export interface TotpState { + enabled: boolean; + enabledAt: string | Date | null; + recoveryCodesLeft: number; +} + +type Notice = { ok: boolean; text: string } | null; + +export function TotpSection({ + state, + hasPassword, +}: { + state: TotpState; + hasPassword: boolean; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + // Einrichtung + const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null); + const [code, setCode] = useState(""); + + // Wird genau einmal angezeigt, direkt nach dem Erzeugen. + const [codes, setCodes] = useState(null); + + // Abschalten und Neuerzeugen verlangen beides das Passwort. + const [password, setPassword] = useState(""); + const [confirming, setConfirming] = useState<"disable" | "recovery" | null>(null); + + async function call(url: string, method: string, body?: unknown) { + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt."); + return payload; + } + + async function start() { + setBusy(true); + setNotice(null); + try { + const payload = await call("/api/account/totp", "POST"); + setSetup({ qr: payload.qr, secret: payload.secret }); + } catch (caught) { + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + async function confirm(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setNotice(null); + try { + const payload = await call("/api/account/totp", "PUT", { code }); + setSetup(null); + setCode(""); + setCodes(payload.recoveryCodes); + router.refresh(); + } catch (caught) { + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + async function submitPassword(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setNotice(null); + try { + if (confirming === "disable") { + await call("/api/account/totp", "DELETE", { password }); + setNotice({ ok: true, text: "Die Zwei-Faktor-Anmeldung ist abgeschaltet." }); + setCodes(null); + } else { + const payload = await call("/api/account/totp/recovery", "POST", { password }); + setCodes(payload.recoveryCodes); + setNotice({ ok: true, text: "Neue Codes erzeugt. Die alten gelten nicht mehr." }); + } + setConfirming(null); + setPassword(""); + router.refresh(); + } catch (caught) { + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + + +
+

+ Zwei-Faktor-Anmeldung +

+

+ {state.enabled + ? `Aktiv seit ${formatDateTime(state.enabledAt)}. Nach dem Passwort fragt die Anmeldung nach einem Code.` + : "Ein zeitbasiertes Einmalkennwort zusätzlich zum Passwort — aus einer Authenticator-App."} +

+
+
+ + {!hasPassword ? ( +

+ Dieses Konto hat kein Passwort und meldet sich per Passkey an. Ein zweiter Faktor + käme dabei nie zum Einsatz — der Passkey ist bereits an dein Gerät gebunden. +

+ ) : null} + + {/* Frisch erzeugte Wiederherstellungscodes, einmalige Anzeige. */} + {codes ? setCodes(null)} /> : null} + + {hasPassword && !state.enabled && !setup && !codes ? ( + + ) : null} + + {/* Schritt 1: QR-Code scannen, Schritt 2: Code bestaetigen. */} + {setup ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- data-URI, kein Netzabruf */} + QR-Code für die Authenticator-App +
+

+ Scanne den Code mit deiner Authenticator-App — etwa Aegis, 2FAS, Ente Auth + oder dem Passwortmanager deiner Wahl. +

+

Geht die Kamera nicht, trage den Schlüssel von Hand ein:

+ + {setup.secret} + +
+
+ +
+ + setCode(event.target.value)} + inputMode="numeric" + autoComplete="one-time-code" + maxLength={7} + required + autoFocus + /> +
+ +
+ + +
+
+ ) : null} + + {state.enabled && !setup ? ( +
+

+ {state.recoveryCodesLeft > 0 ? ( + <> + Noch {state.recoveryCodesLeft}{" "} + Wiederherstellungscodes übrig. + + ) : ( + + Keine Wiederherstellungscodes mehr übrig. Ohne sie hilft bei einem verlorenen + Gerät nichts mehr — bitte neue erzeugen. + + )} +

+ + {confirming ? ( +
+
+ + setPassword(event.target.value)} + autoComplete="current-password" + required + autoFocus + /> +
+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : null} + + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} + +/** + * Die Codes werden nur dieses eine Mal gezeigt. Deshalb Kopieren und + * Herunterladen direkt daneben – und ein Knopf, der das Wegklicken zu einer + * bewussten Handlung macht. + */ +function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) { + const [copied, setCopied] = useState(false); + const text = codes.join("\n"); + + async function copy() { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Ohne Zwischenablage-Recht bleibt Markieren von Hand. + } + } + + function download() { + const url = URL.createObjectURL(new Blob([`${text}\n`], { type: "text/plain" })); + const link = document.createElement("a"); + link.href = url; + link.download = "nebula-wiederherstellungscodes.txt"; + link.click(); + URL.revokeObjectURL(url); + } + + return ( +
+

Wiederherstellungscodes

+

+ Jeder Code gilt einmal. Bewahre sie an einem sicheren Ort auf — sie sind der einzige + Weg zurück, wenn dein Gerät verloren geht. Sie werden nur jetzt angezeigt. +

+ +
    + {codes.map((entry) => ( +
  • + {entry} +
  • + ))} +
+ +
+ + + +
+
+ ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index adcafb0..b1d8fcb 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { startAuthentication } from "@simplewebauthn/browser"; -import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react"; +import { Fingerprint, KeyRound, Loader2, LogIn, ShieldCheck } from "lucide-react"; export function LoginForm({ passkeysAvailable, @@ -18,8 +18,11 @@ export function LoginForm({ const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [busy, setBusy] = useState<"password" | "passkey" | null>(null); + const [busy, setBusy] = useState<"password" | "passkey" | "code" | null>(null); const [error, setError] = useState(null); + /** Passwort stimmt, jetzt fehlt der zweite Faktor. */ + const [mfa, setMfa] = useState(false); + const [code, setCode] = useState(""); function done() { // replace statt push: die Anmeldeseite soll nicht im Verlauf landen. @@ -39,6 +42,14 @@ export function LoginForm({ }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen."); + + // Zweiter Faktor eingerichtet: das Passwort allein reicht nicht. + if (payload.mfaRequired) { + setMfa(true); + setPassword(""); + setBusy(null); + return; + } done(); } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); @@ -46,6 +57,26 @@ export function LoginForm({ } } + async function submitCode(event: React.FormEvent) { + event.preventDefault(); + setBusy("code"); + setError(null); + try { + const response = await fetch("/api/auth/mfa", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Der Code stimmt nicht."); + done(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setCode(""); + setBusy(null); + } + } + async function loginWithPasskey() { setBusy("passkey"); setError(null); @@ -82,6 +113,70 @@ export function LoginForm({ } } + // Beim zweiten Schritt bleibt nur noch der Code stehen. Benutzername und + // Passwort daneben einzublenden wuerde nur zum erneuten Absenden verleiten. + if (mfa) { + return ( +
+
+ + + Bitte den sechsstelligen Code aus deiner Authenticator-App eingeben. Kein + Zugriff darauf? Ein Wiederherstellungscode geht auch. + +
+ +
+ + setCode(event.target.value)} + // Nicht type="number": das schluckt fuehrende Nullen und die + // Bindestriche der Wiederherstellungscodes. + inputMode="text" + autoComplete="one-time-code" + autoCapitalize="none" + spellCheck={false} + maxLength={40} + required + autoFocus + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + + +
+ ); + } + return (
{passkeysAvailable ? ( diff --git a/src/lib/auth/mfa.ts b/src/lib/auth/mfa.ts new file mode 100644 index 0000000..834ea64 --- /dev/null +++ b/src/lib/auth/mfa.ts @@ -0,0 +1,128 @@ +import { createHash, randomBytes } from "node:crypto"; +import { cookies } from "next/headers"; +import type { NextResponse } from "next/server"; +import { COOKIES_SECURE, MFA_COOKIE, MFA_TTL_MS } from "@/lib/config"; +import { tryDecryptSecret } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; + +/** + * Der Zwischenzustand zwischen Passwort und zweitem Faktor. + * + * Bewusst nicht als halbfertige Sitzung: eine Sitzungszeile mit einem + * "noch nicht gueltig"-Merker waere eine Zeile, die bei einem vergessenen + * Filter irgendwo doch als Anmeldung durchgeht. Ein eigenes Modell kann + * dieses Versehen nicht passieren. + */ + +/** Nach so vielen Fehlversuchen ist der Vorgang verbrannt. */ +export const MFA_MAX_ATTEMPTS = 5; + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +export async function createPendingLogin(userId: string): Promise { + const token = randomBytes(32).toString("base64url"); + + // Aeltere Vorgaenge desselben Kontos verwerfen: es kann nur einen geben, + // sonst sammeln sich bei jedem Anlauf neue Rateversuche an. + await prisma.pendingLogin.deleteMany({ + where: { OR: [{ userId }, { expiresAt: { lt: new Date() } }] }, + }); + + await prisma.pendingLogin.create({ + data: { + userId, + tokenHash: hashToken(token), + expiresAt: new Date(Date.now() + MFA_TTL_MS), + }, + }); + + return token; +} + +export function setMfaCookie(response: NextResponse, token: string) { + response.cookies.set({ + name: MFA_COOKIE, + value: token, + httpOnly: true, + sameSite: "lax", + secure: COOKIES_SECURE, + path: "/", + maxAge: Math.floor(MFA_TTL_MS / 1000), + }); +} + +export function clearMfaCookie(response: NextResponse) { + response.cookies.set({ + name: MFA_COOKIE, + value: "", + httpOnly: true, + sameSite: "lax", + secure: COOKIES_SECURE, + path: "/", + maxAge: 0, + }); +} + +export interface PendingLoginState { + id: string; + attempts: number; + user: { + id: string; + username: string; + role: "ADMIN" | "USER"; + isActive: boolean; + /** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */ + totpSecret: string | null; + }; +} + +/** Liest den laufenden Vorgang aus dem Cookie. */ +export async function readPendingLogin(): Promise { + const store = await cookies(); + const token = store.get(MFA_COOKIE)?.value; + if (!token) return null; + + const pending = await prisma.pendingLogin.findUnique({ + where: { tokenHash: hashToken(token) }, + include: { user: true }, + }); + if (!pending) return null; + + if (pending.expiresAt.getTime() <= Date.now()) { + await prisma.pendingLogin.delete({ where: { id: pending.id } }).catch(() => undefined); + return null; + } + + return { + id: pending.id, + attempts: pending.attempts, + user: { + id: pending.user.id, + username: pending.user.username, + role: pending.user.role, + isActive: pending.user.isActive, + totpSecret: tryDecryptSecret(pending.user.totpSecretCipher), + }, + }; +} + +/** Zaehlt einen Fehlversuch. Gibt zurueck, ob noch Versuche offen sind. */ +export async function noteMfaFailure(id: string): Promise { + const updated = await prisma.pendingLogin.update({ + where: { id }, + data: { attempts: { increment: 1 } }, + select: { attempts: true }, + }); + + if (updated.attempts >= MFA_MAX_ATTEMPTS) { + await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined); + return false; + } + return true; +} + +export async function discardPendingLogin(id: string): Promise { + await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined); +} diff --git a/src/lib/auth/recovery.test.ts b/src/lib/auth/recovery.test.ts new file mode 100644 index 0000000..3e7e2e1 --- /dev/null +++ b/src/lib/auth/recovery.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { hashCode, normalizeCode } from "./recovery"; + +describe("normalizeCode", () => { + it("setzt den Bindestrich, wenn er beim Abtippen fehlt", () => { + expect(normalizeCode("a7k2mp9xqd")).toBe("a7k2m-p9xqd"); + }); + + it("nimmt Grossschreibung und Leerzeichen hin", () => { + // Aus einem Ausdruck abgeschrieben sieht ein Code selten so aus wie + // beim Erzeugen. + expect(normalizeCode("A7K2M-P9XQD")).toBe("a7k2m-p9xqd"); + expect(normalizeCode(" a7k2m p9xqd ")).toBe("a7k2m-p9xqd"); + expect(normalizeCode("a7k2m—p9xqd")).toBe("a7k2m-p9xqd"); + }); + + it("laesst zu kurze Eingaben unveraendert, statt sie zurechtzubiegen", () => { + expect(normalizeCode("abc")).toBe("abc"); + }); +}); + +describe("hashCode", () => { + it("bildet Schreibweisen desselben Codes auf denselben Hash ab", () => { + expect(hashCode("A7K2M-P9XQD")).toBe(hashCode("a7k2mp9xqd")); + }); + + it("unterscheidet verschiedene Codes", () => { + expect(hashCode("a7k2m-p9xqd")).not.toBe(hashCode("a7k2m-p9xqe")); + }); + + it("gibt den Code nicht preis", () => { + expect(hashCode("a7k2m-p9xqd")).not.toContain("a7k2m"); + }); +}); diff --git a/src/lib/auth/recovery.ts b/src/lib/auth/recovery.ts new file mode 100644 index 0000000..2bb7278 --- /dev/null +++ b/src/lib/auth/recovery.ts @@ -0,0 +1,100 @@ +import { createHash, randomInt, timingSafeEqual } from "node:crypto"; +import { prisma } from "@/lib/db"; + +/** + * Wiederherstellungscodes fuer den Fall, dass der Authenticator verloren geht. + * + * Ohne sie waere die Zwei-Faktor-Anmeldung eine Falle: ein neues Handy, und + * das Konto ist zu. Ein Administrator kann zwar aushelfen – im + * Einzelbenutzerbetrieb ist der Administrator aber genau die ausgesperrte + * Person. + */ + +/** Wie viele Codes bei der Einrichtung erzeugt werden. */ +export const RECOVERY_CODE_COUNT = 10; + +/** + * Alphabet ohne die Paare, die man beim Abschreiben verwechselt: kein O/0, + * kein I/l/1, kein B/8. Die Codes werden von Hand aus einem Ausdruck oder + * Passwortmanager uebertragen, oft unter Zeitdruck. + */ +const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +const GROUP = 5; + +/** Ein Code der Form "a7k2m-p9xqd" – rund 50 Bit Zufall. */ +function generateCode(): string { + const zeichen = Array.from( + { length: GROUP * 2 }, + () => ALPHABET[randomInt(ALPHABET.length)], + ).join(""); + return `${zeichen.slice(0, GROUP)}-${zeichen.slice(GROUP)}`; +} + +/** Vereinheitlicht Eingaben: Grossschreibung und fehlender Bindestrich. */ +export function normalizeCode(input: string): string { + const clean = input.toLowerCase().replace(/[^a-z0-9]/g, ""); + if (clean.length !== GROUP * 2) return clean; + return `${clean.slice(0, GROUP)}-${clean.slice(GROUP)}`; +} + +export function hashCode(code: string): string { + return createHash("sha256").update(normalizeCode(code)).digest("base64url"); +} + +/** + * Erzeugt einen frischen Satz und verwirft den alten. Gibt die Codes im + * Klartext zurueck – das ist die einzige Gelegenheit, sie zu sehen. + */ +export async function issueRecoveryCodes(userId: string): Promise { + const codes = Array.from({ length: RECOVERY_CODE_COUNT }, generateCode); + + await prisma.$transaction([ + prisma.recoveryCode.deleteMany({ where: { userId } }), + prisma.recoveryCode.createMany({ + data: codes.map((code) => ({ userId, codeHash: hashCode(code) })), + }), + ]); + + return codes; +} + +/** + * Loest einen Code ein. Ein Code gilt genau einmal; verbraucht wird er auch + * dann, wenn die Anmeldung danach aus anderem Grund scheitert – lieber ein + * Code zu viel verbraucht als einer, der zweimal geht. + */ +export async function redeemRecoveryCode(userId: string, input: string): Promise { + const eingabe = normalizeCode(input); + if (eingabe.length < GROUP * 2) return false; + + const offen = await prisma.recoveryCode.findMany({ + where: { userId, usedAt: null }, + select: { id: true, codeHash: true }, + }); + + const gesucht = Buffer.from(hashCode(eingabe), "utf8"); + let treffer: string | null = null; + for (const code of offen) { + const vorhanden = Buffer.from(code.codeHash, "utf8"); + // Nicht abbrechen, sobald es passt: die Laufzeit soll nicht verraten, + // an welcher Stelle der Liste der Code stand. + if (vorhanden.length === gesucht.length && timingSafeEqual(vorhanden, gesucht)) { + treffer = code.id; + } + } + + if (!treffer) return false; + + // updateMany mit der Bedingung "noch unbenutzt": zwei gleichzeitige + // Versuche mit demselben Code koennen so nicht beide durchkommen. + const result = await prisma.recoveryCode.updateMany({ + where: { id: treffer, usedAt: null }, + data: { usedAt: new Date() }, + }); + return result.count === 1; +} + +/** Wie viele Codes noch ungenutzt sind – fuer den Hinweis im Konto. */ +export async function countUnusedRecoveryCodes(userId: string): Promise { + return prisma.recoveryCode.count({ where: { userId, usedAt: null } }); +} diff --git a/src/lib/auth/totp.test.ts b/src/lib/auth/totp.test.ts new file mode 100644 index 0000000..538fea4 --- /dev/null +++ b/src/lib/auth/totp.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + TOTP_PERIOD, + base32Decode, + base32Encode, + counterFor, + generateTotpSecret, + groupSecret, + otpauthUri, + totpCode, + verifyTotp, +} from "./totp"; + +describe("Base32", () => { + // Testvektoren aus RFC 4648, Abschnitt 10 – ohne die Auffuellzeichen, + // die Authenticator-Apps nicht erwarten. + it.each([ + ["", ""], + ["f", "MY"], + ["fo", "MZXQ"], + ["foo", "MZXW6"], + ["foob", "MZXW6YQ"], + ["fooba", "MZXW6YTB"], + ["foobar", "MZXW6YTBOI"], + ])("kodiert %j als %j", (klartext, erwartet) => { + expect(base32Encode(Buffer.from(klartext, "utf8"))).toBe(erwartet); + }); + + it("dekodiert zurueck, auch mit Leerzeichen und Auffuellzeichen", () => { + expect(base32Decode("MZXW6YTBOI").toString("utf8")).toBe("foobar"); + expect(base32Decode("mzxw 6ytb oi").toString("utf8")).toBe("foobar"); + expect(base32Decode("MZXW6YTBOI======").toString("utf8")).toBe("foobar"); + }); + + it("weist fremde Zeichen ab, statt still Unsinn zu liefern", () => { + // 0, 1 und 8 gehoeren nicht zum Alphabet – gerade sie werden aber gern + // mit O, I und B verwechselt. + expect(() => base32Decode("MZXW0YTB")).toThrow(); + }); +}); + +describe("TOTP nach RFC 6238", () => { + // Der Schluessel aus Anhang B des RFC: die ASCII-Ziffern 1234567890, + // zweimal hintereinander. + const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8")); + + /** + * Der RFC nennt achtstellige Codes. Sechs Stellen sind davon die letzten + * sechs – gerechnet wird modulo 10^6 statt 10^8. + */ + it.each([ + [59, "287082"], + [1111111109, "081804"], + [1111111111, "050471"], + [1234567890, "005924"], + [2000000000, "279037"], + [20000000000, "353130"], + ])("liefert zum Zeitpunkt %i den Code %s", (sekunden, erwartet) => { + const counter = Math.floor(sekunden / TOTP_PERIOD); + expect(totpCode(SECRET, counter)).toBe(erwartet); + }); + + it("rechnet Zeitpunkte in Fenster um", () => { + expect(counterFor(new Date(59_000))).toBe(1); + expect(counterFor(new Date(1111111109_000))).toBe(37037036); + }); +}); + +describe("verifyTotp", () => { + const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8")); + const JETZT = new Date(1111111109_000); + + it("nimmt den Code des aktuellen Fensters an", () => { + expect(verifyTotp(SECRET, "081804", JETZT)).toBe(true); + }); + + it("nimmt auch das vorige und das naechste Fenster an", () => { + // Eine um bis zu 30 Sekunden falsch gehende Uhr soll niemanden aussperren. + const vorher = new Date(JETZT.getTime() - TOTP_PERIOD * 1000); + const nachher = new Date(JETZT.getTime() + TOTP_PERIOD * 1000); + expect(verifyTotp(SECRET, "081804", vorher)).toBe(true); + expect(verifyTotp(SECRET, "081804", nachher)).toBe(true); + }); + + it("lehnt ein Fenster weiter ab", () => { + const zuFrueh = new Date(JETZT.getTime() - 2 * TOTP_PERIOD * 1000); + expect(verifyTotp(SECRET, "081804", zuFrueh)).toBe(false); + }); + + it("lehnt falsche Codes und Unfug ab", () => { + expect(verifyTotp(SECRET, "000000", JETZT)).toBe(false); + expect(verifyTotp(SECRET, "", JETZT)).toBe(false); + expect(verifyTotp(SECRET, "81804", JETZT)).toBe(false); + expect(verifyTotp(SECRET, "abcdef", JETZT)).toBe(false); + // Fuehrende Nullen duerfen nicht als Zahl verlorengehen. + expect(verifyTotp(SECRET, "81804 ", JETZT)).toBe(false); + }); + + it("sieht ueber Leerzeichen in der Eingabe hinweg", () => { + expect(verifyTotp(SECRET, "081 804", JETZT)).toBe(true); + }); +}); + +describe("Einrichtung", () => { + it("erzeugt Schluessel, die sich unterscheiden und lang genug sind", () => { + const a = generateTotpSecret(); + const b = generateTotpSecret(); + expect(a).not.toBe(b); + // 20 Byte ergeben 32 Base32-Zeichen. + expect(a).toHaveLength(32); + expect(base32Decode(a)).toHaveLength(20); + }); + + it("baut eine otpauth-URI mit Aussteller im Label und im Parameter", () => { + const uri = otpauthUri({ secret: "ABCD", account: "chefin", issuer: "Nebula" }); + expect(uri).toContain("otpauth://totp/Nebula%3Achefin?"); + expect(uri).toContain("secret=ABCD"); + expect(uri).toContain("issuer=Nebula"); + expect(uri).toContain("digits=6"); + expect(uri).toContain("period=30"); + }); + + it("gruppiert den Schluessel zum Abtippen", () => { + expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH"); + }); +}); diff --git a/src/lib/auth/totp.ts b/src/lib/auth/totp.ts new file mode 100644 index 0000000..ff570a6 --- /dev/null +++ b/src/lib/auth/totp.ts @@ -0,0 +1,158 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * Zeitbasierte Einmalkennwoerter nach RFC 6238 (TOTP über HMAC-SHA1). + * + * Selbst gerechnet statt als Abhaengigkeit: es sind wenige Zeilen, das + * Verfahren steht seit 2011 fest, und der RFC bringt Testvektoren mit – + * die Richtigkeit laesst sich also beweisen statt nur behaupten + * (siehe totp.test.ts). + * + * SHA-1 ist hier kein Mangel. Es geht nicht um Kollisionsresistenz, sondern + * um HMAC mit einem geheimen Schluessel, und jede Authenticator-App der Welt + * erwartet genau diese Voreinstellung. + */ + +/** Stellenzahl des Codes. */ +export const TOTP_DIGITS = 6; +/** Laenge eines Zeitfensters in Sekunden. */ +export const TOTP_PERIOD = 30; +/** + * Wie viele Fenster in jede Richtung zusaetzlich gelten. Eines deckt eine um + * bis zu 30 Sekunden falsch gehende Uhr ab – mehr wuerde den Zeitraum + * unnoetig verbreitern, in dem ein abgefangener Code noch taugt. + */ +export const TOTP_WINDOW = 1; + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** Base32 nach RFC 4648, ohne Auffuellzeichen – so erwarten es die Apps. */ +export function base32Encode(data: Buffer): string { + let bits = 0; + let value = 0; + let output = ""; + + for (const byte of data) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + return output; +} + +export function base32Decode(input: string): Buffer { + // Leerzeichen und Auffuellzeichen grosszuegig hinnehmen: Benutzer tippen + // den Schluessel gelegentlich aus einer Anzeige in Vierergruppen ab. + const clean = input.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, ""); + let bits = 0; + let value = 0; + const bytes: number[] = []; + + for (const char of clean) { + const index = BASE32_ALPHABET.indexOf(char); + if (index === -1) throw new Error("Ungueltiges Zeichen im Base32-Schluessel"); + value = (value << 5) | index; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +/** + * Neuer Schluessel. 20 Byte entsprechen der Empfehlung des RFC fuer + * HMAC-SHA1 und ergeben 32 Base32-Zeichen. + */ +export function generateTotpSecret(): string { + return base32Encode(randomBytes(20)); +} + +/** Der Code fuer ein bestimmtes Zeitfenster. */ +export function totpCode(secret: string, counter: number): string { + const key = base32Decode(secret); + + // Zaehler als 8-Byte-Zahl in Big-Endian. BigInt, weil er theoretisch + // ueber 2^32 hinausgeht – praktisch erst in einigen tausend Jahren, aber + // die Bitschieberei mit Zahlen waere hier ohnehin heikel. + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(BigInt(counter)); + + const digest = createHmac("sha1", key).update(buffer).digest(); + + // Dynamic Truncation nach RFC 4226, Abschnitt 5.3. + const offset = digest[digest.length - 1] & 0x0f; + const binary = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + + return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0"); +} + +/** Das Zeitfenster, in dem ein Zeitpunkt liegt. */ +export function counterFor(at: Date = new Date()): number { + return Math.floor(at.getTime() / 1000 / TOTP_PERIOD); +} + +/** + * Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert + * die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert. + */ +export function verifyTotp( + secret: string, + code: string, + at: Date = new Date(), + window: number = TOTP_WINDOW, +): boolean { + const eingabe = code.replace(/\s/g, ""); + if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false; + + const jetzt = counterFor(at); + let treffer = false; + for (let schritt = -window; schritt <= window; schritt += 1) { + // Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab, + // welches Fenster gepasst hat. + if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true; + } + return treffer; +} + +function constantEquals(a: string, b: string): boolean { + const left = Buffer.from(a, "utf8"); + const right = Buffer.from(b, "utf8"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +/** + * Die URI, die als QR-Code angezeigt wird. Das Label enthaelt sowohl den + * Aussteller als auch das Konto – so schreiben es die Authenticator-Apps + * vor, sonst steht in der Liste nur ein nackter Benutzername. + */ +export function otpauthUri(options: { + secret: string; + account: string; + issuer: string; +}): string { + const label = `${options.issuer}:${options.account}`; + const query = new URLSearchParams({ + secret: options.secret, + issuer: options.issuer, + algorithm: "SHA1", + digits: String(TOTP_DIGITS), + period: String(TOTP_PERIOD), + }); + return `otpauth://totp/${encodeURIComponent(label)}?${query.toString()}`; +} + +/** Der Schluessel in Vierergruppen – zum Abtippen, wenn die Kamera streikt. */ +export function groupSecret(secret: string): string { + return secret.replace(/(.{4})/g, "$1 ").trim(); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 61cce2f..0cfb9fd 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -79,6 +79,14 @@ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; export const SESSION_COOKIE = "nebula_session"; +/** + * Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor. + * Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von + * vorne an. + */ +export const MFA_COOKIE = "nebula_mfa"; +export const MFA_TTL_MS = 5 * 60 * 1000; + /** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */ export function describeConfig(): string { return [