feat(auth): Zwei-Faktor-Anmeldung per TOTP
Ein Einmalkennwort zusaetzlich zum Passwort, mit QR-Code zum Einrichten und zehn Wiederherstellungscodes fuer den Fall, dass das Geraet verloren geht. Die Rechnung nach RFC 6238 steht in lib/auth/totp.ts statt in einer Abhaengigkeit. Es sind wenige Zeilen, das Verfahren liegt seit 2011 fest, und der RFC bringt Testvektoren mit - die Richtigkeit ist damit belegt und nicht nur behauptet. Fuer den QR-Code kommt qrcode dazu; ihn selbst zu zeichnen waere unverhaeltnismaessig. Einrichtung in zwei Schritten: der Schluessel wird hinterlegt, aber erst ein bestaetigter Code schaltet ihn scharf. Wer den QR-Code nicht scannen konnte und abbricht, sperrt sich sonst beim naechsten Anmelden selbst aus. Zwischen Passwort und Code steht ein eigenes Modell statt einer halbfertigen Sitzung. Eine Sitzungszeile mit einem "noch nicht gueltig"-Merker waere eine Zeile, die bei einem vergessenen Filter irgendwo doch als Anmeldung durchgeht; dieses Versehen kann hier nicht passieren. Nach fuenf Fehlversuchen ist der Vorgang verbrannt, damit sechs Stellen nicht in Ruhe durchprobiert werden. Die Wiederherstellungscodes liegen als SHA-256 statt als scrypt: sie sind zufaellig erzeugt und lang genug, dass Raten aussichtslos ist - ein langsames Verfahren brauchte bei jeder Anmeldung zehn teure Vergleiche und kaufte nichts dafuer. Das Alphabet laesst O, I und B weg, weil die Codes von Hand abgeschrieben werden. Abschalten und Neuerzeugen verlangen das Passwort. Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor nicht im Vorbeigehen entfernen koennen. Nachgewiesen im Browser: Einrichtung, Anmeldung mit Code, Anmeldung mit Wiederherstellungscode, derselbe Code ein zweites Mal abgelehnt, Abschalten mit falschem und richtigem Passwort. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+319
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.6.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.6.0",
|
"version": "0.9.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
@@ -2331,6 +2333,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -2475,6 +2487,30 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001806",
|
"version": "1.0.30001806",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||||
@@ -2650,6 +2695,35 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/comma-separated-tokens": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
"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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -2800,6 +2889,12 @@
|
|||||||
"fast-check": "^3.23.1"
|
"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": {
|
"node_modules/empathic": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"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": "^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": {
|
"node_modules/giget": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
@@ -3114,6 +3231,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/is-hexadecimal": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
|
||||||
@@ -3419,6 +3545,18 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/longest-streak": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||||
@@ -4457,6 +4595,42 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@@ -4482,6 +4656,15 @@
|
|||||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"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": "^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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
@@ -4673,6 +4865,23 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/rc9": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
@@ -4832,6 +5041,21 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||||
@@ -4885,6 +5109,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@@ -4970,6 +5200,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
@@ -4984,6 +5228,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -5474,6 +5736,61 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -18,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function AccountPage() {
|
export default async function AccountPage() {
|
||||||
const user = await requireUserPage();
|
const user = await requireUserPage();
|
||||||
|
|
||||||
const [passkeys, sessions, targets] = await Promise.all([
|
const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([
|
||||||
prisma.credential.findMany({
|
prisma.credential.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
select: {
|
select: {
|
||||||
@@ -58,6 +58,11 @@ export default async function AccountPage() {
|
|||||||
},
|
},
|
||||||
orderBy: { name: "asc" },
|
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 = {
|
const data: AccountData = {
|
||||||
@@ -69,6 +74,11 @@ export default async function AccountPage() {
|
|||||||
passkeys,
|
passkeys,
|
||||||
sessions,
|
sessions,
|
||||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||||
|
totp: {
|
||||||
|
enabled: Boolean(konto.totpEnabledAt),
|
||||||
|
enabledAt: konto.totpEnabledAt,
|
||||||
|
recoveryCodesLeft: offeneCodes,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
|
||||||
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
|
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
||||||
@@ -68,6 +69,18 @@ export async function POST(request: Request) {
|
|||||||
return jsonError(GENERIC, 401);
|
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);
|
await noteSuccessfulLogin(user.id);
|
||||||
resetRateLimit(`login:user:${username}`);
|
resetRateLimit(`login:user:${username}`);
|
||||||
resetRateLimit(`login:ip:${ip}`);
|
resetRateLimit(`login:ip:${ip}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { TotpSection, type TotpState } from "@/components/account/TotpSection";
|
||||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export interface AccountData {
|
|||||||
passkeys: PasskeyEntry[];
|
passkeys: PasskeyEntry[];
|
||||||
sessions: SessionEntry[];
|
sessions: SessionEntry[];
|
||||||
passkeysAvailable: boolean;
|
passkeysAvailable: boolean;
|
||||||
|
totp: TotpState;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notice = { ok: boolean; text: string } | null;
|
type Notice = { ok: boolean; text: string } | null;
|
||||||
@@ -52,6 +54,7 @@ export function AccountManager({ data }: { data: AccountData }) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ProfileSection data={data} />
|
<ProfileSection data={data} />
|
||||||
<PasswordSection hasPassword={data.hasPassword} />
|
<PasswordSection hasPassword={data.hasPassword} />
|
||||||
|
<TotpSection state={data.totp} hasPassword={data.hasPassword} />
|
||||||
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
|
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
|
||||||
<SessionSection sessions={data.sessions} />
|
<SessionSection sessions={data.sessions} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<Notice>(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<string[] | null>(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 (
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-9 w-9 shrink-0 place-items-center rounded-xl",
|
||||||
|
state.enabled
|
||||||
|
? "bg-emerald-500/12 text-emerald-300"
|
||||||
|
: "bg-violet-500/12 text-violet-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
Zwei-Faktor-Anmeldung
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasPassword ? (
|
||||||
|
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Frisch erzeugte Wiederherstellungscodes, einmalige Anzeige. */}
|
||||||
|
{codes ? <RecoveryCodes codes={codes} onDone={() => setCodes(null)} /> : null}
|
||||||
|
|
||||||
|
{hasPassword && !state.enabled && !setup && !codes ? (
|
||||||
|
<button type="button" onClick={start} disabled={busy} className="btn btn-primary">
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Smartphone className="h-4 w-4" />}
|
||||||
|
Einrichten
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Schritt 1: QR-Code scannen, Schritt 2: Code bestaetigen. */}
|
||||||
|
{setup ? (
|
||||||
|
<form onSubmit={confirm} className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- data-URI, kein Netzabruf */}
|
||||||
|
<img
|
||||||
|
src={setup.qr}
|
||||||
|
alt="QR-Code für die Authenticator-App"
|
||||||
|
width={160}
|
||||||
|
height={160}
|
||||||
|
className="shrink-0 rounded-xl bg-white p-2"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 space-y-2 text-sm text-slate-400">
|
||||||
|
<p>
|
||||||
|
Scanne den Code mit deiner Authenticator-App — etwa Aegis, 2FAS, Ente Auth
|
||||||
|
oder dem Passwortmanager deiner Wahl.
|
||||||
|
</p>
|
||||||
|
<p>Geht die Kamera nicht, trage den Schlüssel von Hand ein:</p>
|
||||||
|
<code className="block break-all rounded-lg border border-white/8 bg-black/30 px-3 py-2 font-mono text-xs text-violet-200">
|
||||||
|
{setup.secret}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<label className="field-label" htmlFor="totp-code">
|
||||||
|
Code aus der App
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp-code"
|
||||||
|
className="field text-center text-lg tracking-[0.35em]"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={7}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSetup(null);
|
||||||
|
setCode("");
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.enabled && !setup ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{state.recoveryCodesLeft > 0 ? (
|
||||||
|
<>
|
||||||
|
Noch <strong className="text-slate-200">{state.recoveryCodesLeft}</strong>{" "}
|
||||||
|
Wiederherstellungscodes übrig.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-200">
|
||||||
|
Keine Wiederherstellungscodes mehr übrig. Ohne sie hilft bei einem verlorenen
|
||||||
|
Gerät nichts mehr — bitte neue erzeugen.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{confirming ? (
|
||||||
|
<form onSubmit={submitPassword} className="max-w-sm space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="totp-password">
|
||||||
|
Passwort bestätigen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp-password"
|
||||||
|
className="field"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={cn("btn", confirming === "disable" ? "btn-danger" : "btn-primary")}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
{confirming === "disable" ? "Abschalten" : "Neue Codes erzeugen"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirming(null);
|
||||||
|
setPassword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setConfirming("recovery")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Neue Wiederherstellungscodes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost text-rose-200"
|
||||||
|
onClick={() => setConfirming("disable")}
|
||||||
|
>
|
||||||
|
<ShieldOff className="h-4 w-4" />
|
||||||
|
Abschalten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
notice.ok
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-500/30 bg-amber-500/8 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-amber-100">Wiederherstellungscodes</h3>
|
||||||
|
<p className="mt-1 text-sm text-amber-200/80">
|
||||||
|
Jeder Code gilt einmal. Bewahre sie an einem sicheren Ort auf — sie sind der einzige
|
||||||
|
Weg zurück, wenn dein Gerät verloren geht. <strong>Sie werden nur jetzt angezeigt.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-3 grid grid-cols-2 gap-1.5 font-mono text-sm text-amber-50 sm:grid-cols-2">
|
||||||
|
{codes.map((entry) => (
|
||||||
|
<li key={entry} className="rounded-md bg-black/25 px-2.5 py-1.5">
|
||||||
|
{entry}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={copy} className="btn btn-ghost text-xs">
|
||||||
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
{copied ? "Kopiert" : "Kopieren"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={download} className="btn btn-ghost text-xs">
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Herunterladen
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDone} className="btn btn-ghost text-xs">
|
||||||
|
Ich habe sie gesichert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startAuthentication } from "@simplewebauthn/browser";
|
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({
|
export function LoginForm({
|
||||||
passkeysAvailable,
|
passkeysAvailable,
|
||||||
@@ -18,8 +18,11 @@ export function LoginForm({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = 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<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** Passwort stimmt, jetzt fehlt der zweite Faktor. */
|
||||||
|
const [mfa, setMfa] = useState(false);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
|
||||||
function done() {
|
function done() {
|
||||||
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
|
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
|
||||||
@@ -39,6 +42,14 @@ export function LoginForm({
|
|||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
|
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();
|
done();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : String(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() {
|
async function loginWithPasskey() {
|
||||||
setBusy("passkey");
|
setBusy("passkey");
|
||||||
setError(null);
|
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 (
|
||||||
|
<form onSubmit={submitCode} className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
|
||||||
|
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
||||||
|
<span>
|
||||||
|
Bitte den sechsstelligen Code aus deiner Authenticator-App eingeben. Kein
|
||||||
|
Zugriff darauf? Ein Wiederherstellungscode geht auch.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="login-code">
|
||||||
|
Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-code"
|
||||||
|
className="field text-center text-lg tracking-[0.35em]"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy !== null}>
|
||||||
|
{busy === "code" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Bestätigen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMfa(false);
|
||||||
|
setCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-center text-sm text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{passkeysAvailable ? (
|
{passkeysAvailable ? (
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<PendingLoginState | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<number> {
|
||||||
|
return prisma.recoveryCode.count({ where: { userId, usedAt: null } });
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -79,6 +79,14 @@ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|||||||
|
|
||||||
export const SESSION_COOKIE = "nebula_session";
|
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. */
|
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
|
||||||
export function describeConfig(): string {
|
export function describeConfig(): string {
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user