feat: add TOTP 2FA with QR code and manual secret entry
This commit is contained in:
Generated
+399
-1
@@ -15,7 +15,86 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1"
|
||||
"multer": "^2.1.1",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz",
|
||||
"integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/hotp": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz",
|
||||
"integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@otplib/uri": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-base32-scure": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz",
|
||||
"integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@scure/base": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto-noble": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz",
|
||||
"integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@otplib/core": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/totp": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz",
|
||||
"integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@otplib/hotp": "13.4.0",
|
||||
"@otplib/uri": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/uri": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz",
|
||||
"integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -31,6 +110,30 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"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/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
@@ -209,12 +312,50 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"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/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"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/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
@@ -297,6 +438,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/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -339,6 +489,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -368,6 +524,12 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -537,6 +699,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"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/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -570,6 +745,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -735,6 +919,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"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-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -784,6 +977,18 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"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/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@@ -1036,6 +1241,56 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz",
|
||||
"integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@otplib/hotp": "13.4.0",
|
||||
"@otplib/plugin-base32-scure": "13.4.0",
|
||||
"@otplib/plugin-crypto-noble": "13.4.0",
|
||||
"@otplib/totp": "13.4.0",
|
||||
"@otplib/uri": "13.4.0"
|
||||
}
|
||||
},
|
||||
"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/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1045,6 +1300,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"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/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
@@ -1055,6 +1319,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"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/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@@ -1105,6 +1378,23 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
@@ -1173,6 +1463,21 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"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/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -1272,6 +1577,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -1421,6 +1732,32 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"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/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/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
@@ -1523,11 +1860,72 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"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/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/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -16,6 +16,8 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1"
|
||||
"multer": "^2.1.1",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
// E2EE module — Signal-style hybrid encryption for group chat.
|
||||
// Uses Web Crypto API only. Private keys never leave this device.
|
||||
const E2EE = (() => {
|
||||
const GROUP_ID = 'info1';
|
||||
const IDB_NAME = 'ifb_e2ee_v1';
|
||||
const IDB_STORE = 'k';
|
||||
const sub = crypto.subtle;
|
||||
const te = new TextEncoder();
|
||||
const td = new TextDecoder();
|
||||
const EC = { name: 'ECDH', namedCurve: 'P-256' };
|
||||
|
||||
// ── IndexedDB ─────────────────────────────────────────────────
|
||||
function openIDB() {
|
||||
return new Promise((res, rej) => {
|
||||
const r = indexedDB.open(IDB_NAME, 1);
|
||||
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
||||
r.onsuccess = e => res(e.target.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
async function idbGet(key) {
|
||||
const db = await openIDB();
|
||||
return new Promise((res, rej) => {
|
||||
const t = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(key);
|
||||
t.onsuccess = () => res(t.result);
|
||||
t.onerror = () => rej(t.error);
|
||||
});
|
||||
}
|
||||
async function idbSet(key, val) {
|
||||
const db = await openIDB();
|
||||
return new Promise((res, rej) => {
|
||||
const t = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE).put(val, key);
|
||||
t.onsuccess = () => res();
|
||||
t.onerror = () => rej(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── REST helper ───────────────────────────────────────────────
|
||||
async function rpc(method, path, body) {
|
||||
try {
|
||||
const r = await fetch('/api/' + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return r.json();
|
||||
} catch (e) {
|
||||
console.error('[E2EE] rpc', method, path, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Binary helpers ────────────────────────────────────────────
|
||||
function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
|
||||
function fromB64(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); }
|
||||
function randomId() {
|
||||
try { return crypto.randomUUID(); }
|
||||
catch { return toB64(crypto.getRandomValues(new Uint8Array(18))); }
|
||||
}
|
||||
|
||||
// ── Key generation & import ───────────────────────────────────
|
||||
const genECDH = () => sub.generateKey(EC, true, ['deriveKey', 'deriveBits']);
|
||||
const importECPub = jwk => sub.importKey('jwk', jwk, EC, true, []);
|
||||
const importECPriv = jwk => sub.importKey('jwk', jwk, EC, true, ['deriveKey', 'deriveBits']);
|
||||
const importAES = raw => sub.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
const genAES = () => sub.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
const exportRaw = key => sub.exportKey('raw', key);
|
||||
|
||||
// ECDH(myPriv, theirPub) → HKDF → AES-256-GCM wrap key
|
||||
async function deriveWrapKey(myPriv, theirPub) {
|
||||
const bits = await sub.deriveBits({ name: 'ECDH', public: theirPub }, myPriv, 256);
|
||||
const hk = await sub.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
||||
return sub.deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: te.encode('ifb-e2ee-v1'), info: te.encode(GROUP_ID) },
|
||||
hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
// ── AES-GCM encrypt/decrypt ───────────────────────────────────
|
||||
async function aesEncrypt(key, data) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await sub.encrypt({ name: 'AES-GCM', iv }, key, data);
|
||||
return { iv: toB64(iv), ct: toB64(ct) };
|
||||
}
|
||||
async function aesDecrypt(key, ivB64, ctB64) {
|
||||
return sub.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64) }, key, fromB64(ctB64));
|
||||
}
|
||||
|
||||
// ── Sender key wrap/unwrap ────────────────────────────────────
|
||||
async function wrapSenderKey(rawKey, myPriv, theirPub) {
|
||||
const wk = await deriveWrapKey(myPriv, theirPub);
|
||||
const { iv, ct } = await aesEncrypt(wk, rawKey);
|
||||
return JSON.stringify({ iv, ct });
|
||||
}
|
||||
async function unwrapSenderKey(encJson, myPriv, distPub) {
|
||||
const { iv, ct } = JSON.parse(encJson);
|
||||
const wk = await deriveWrapKey(myPriv, distPub);
|
||||
return aesDecrypt(wk, iv, ct);
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
let myKeyPair = null; // { priv: CryptoKey, pub: CryptoKey }
|
||||
let myUserId = null;
|
||||
let activeSK = null; // { key: CryptoKey, kid: string }
|
||||
let kidCache = {}; // kid → CryptoKey
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────
|
||||
async function init(userId) {
|
||||
myUserId = userId;
|
||||
try {
|
||||
// Load or generate P-256 identity key pair in IndexedDB
|
||||
let privJwk = await idbGet('priv_' + userId);
|
||||
let pubJwk = await idbGet('pub_' + userId);
|
||||
if (!privJwk || !pubJwk) {
|
||||
const kp = await genECDH();
|
||||
privJwk = await sub.exportKey('jwk', kp.privateKey);
|
||||
pubJwk = await sub.exportKey('jwk', kp.publicKey);
|
||||
await idbSet('priv_' + userId, privJwk);
|
||||
await idbSet('pub_' + userId, pubJwk);
|
||||
}
|
||||
myKeyPair = { priv: await importECPriv(privJwk), pub: await importECPub(pubJwk) };
|
||||
|
||||
// Publish public key (server upserts, idempotent)
|
||||
const pubResp = await rpc('POST', 'e2ee/public-key', { public_key_jwk: JSON.stringify(pubJwk) });
|
||||
if (!pubResp || pubResp.error) console.warn('[E2EE] public-key publish failed', pubResp);
|
||||
|
||||
// Fresh sender key per session → forward secrecy
|
||||
await rotateSenderKey();
|
||||
} catch (e) {
|
||||
console.error('[E2EE] init failed', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateSenderKey() {
|
||||
const kid = randomId();
|
||||
const senderKey = await genAES();
|
||||
const raw = await exportRaw(senderKey);
|
||||
|
||||
// Distribute encrypted copy to every member who has a public key
|
||||
const usersResp = await rpc('GET', 'e2ee/users');
|
||||
const users = Array.isArray(usersResp) ? usersResp : [];
|
||||
|
||||
const dists = [];
|
||||
for (const u of users) {
|
||||
try {
|
||||
const theirPub = await importECPub(JSON.parse(u.public_key_jwk));
|
||||
dists.push({ user_id: u.id, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, theirPub) });
|
||||
} catch {}
|
||||
}
|
||||
// Always include self (may not be in users list yet)
|
||||
if (!dists.some(d => d.user_id === myUserId)) {
|
||||
dists.push({ user_id: myUserId, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, myKeyPair.pub) });
|
||||
}
|
||||
|
||||
await rpc('POST', 'e2ee/group-keys', { group_id: GROUP_ID, kid, keys: dists });
|
||||
activeSK = { key: senderKey, kid };
|
||||
kidCache[kid] = senderKey;
|
||||
}
|
||||
|
||||
async function resolveKid(kid) {
|
||||
if (kidCache[kid]) return kidCache[kid];
|
||||
const row = await rpc('GET', `e2ee/group-key?group_id=${GROUP_ID}&kid=${encodeURIComponent(kid)}`);
|
||||
if (!row || row.error) return null;
|
||||
try {
|
||||
const distPubResp = await rpc('GET', 'e2ee/public-key/' + row.distributor_user_id);
|
||||
if (!distPubResp?.public_key_jwk) return null;
|
||||
const distPub = await importECPub(JSON.parse(distPubResp.public_key_jwk));
|
||||
const rawBuf = await unwrapSenderKey(row.encrypted_key, myKeyPair.priv, distPub);
|
||||
const key = await importAES(rawBuf);
|
||||
kidCache[kid] = key;
|
||||
return key;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function encrypt(plaintext) {
|
||||
if (!activeSK) throw new Error('E2EE not initialized');
|
||||
const { iv, ct } = await aesEncrypt(activeSK.key, te.encode(plaintext));
|
||||
// ts included for replay detection on client side
|
||||
return JSON.stringify({ v: 1, kid: activeSK.kid, iv, ct, ts: Date.now() });
|
||||
}
|
||||
|
||||
async function decrypt(content) {
|
||||
let p;
|
||||
try { p = JSON.parse(content); } catch { return content; } // not encrypted
|
||||
if (!p.v || !p.kid || !p.iv || !p.ct) return content; // legacy plaintext
|
||||
const key = await resolveKid(p.kid);
|
||||
if (!key) return '[Nachricht nicht entschlusselbar]';
|
||||
try {
|
||||
const buf = await aesDecrypt(key, p.iv, p.ct);
|
||||
return td.decode(buf);
|
||||
} catch { return '[Entschlusselung fehlgeschlagen]'; }
|
||||
}
|
||||
|
||||
return { init, encrypt, decrypt, rotateSenderKey };
|
||||
})();
|
||||
+148
-12
@@ -8,6 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="/e2ee.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--blue: #2563eb; --blue-d: #1d4ed8;
|
||||
@@ -1421,6 +1422,52 @@ footer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="2fa-section">
|
||||
<div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div>
|
||||
<div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div>
|
||||
<!-- Setup flow -->
|
||||
<div id="2fa-setup-area" style="display:none">
|
||||
<div style="margin-bottom:10px;font-size:13px;color:var(--text-2)">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy).</div>
|
||||
<img id="2fa-qr" style="width:180px;height:180px;border-radius:8px;border:1px solid var(--border);display:block;margin-bottom:8px" alt="QR Code">
|
||||
<details style="margin-bottom:10px;font-size:12px">
|
||||
<summary style="cursor:pointer;color:var(--text-muted);user-select:none">Kein Kamera? Manuell eingeben</summary>
|
||||
<div style="margin-top:6px;padding:8px;background:var(--n-100);border-radius:6px;border:1px solid var(--border)">
|
||||
<div style="color:var(--text-muted);margin-bottom:4px">Geheimschlüssel (Base32):</div>
|
||||
<code id="2fa-secret" style="font-size:13px;word-break:break-all;color:var(--text);letter-spacing:.05em"></code>
|
||||
<div style="color:var(--text-subtle);margin-top:4px;font-size:11px">In App: Konto manuell hinzufügen → TOTP → diesen Schlüssel eingeben</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="settings-fields">
|
||||
<input type="text" id="2fa-confirm-code" placeholder="6-stelliger Code zur Bestätigung" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn-save" style="align-self:flex-start" onclick="confirm2FA()">Bestätigen & aktivieren</button>
|
||||
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FASetup()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Disable flow -->
|
||||
<div id="2fa-disable-area" style="display:none">
|
||||
<div class="settings-fields">
|
||||
<input type="password" id="2fa-disable-pw" placeholder="Aktuelles Passwort">
|
||||
<input type="text" id="2fa-disable-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-danger" style="align-self:flex-start" onclick="disable2FA()">2FA deaktivieren</button>
|
||||
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FADisable()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Idle buttons -->
|
||||
<div id="2fa-idle-area" style="display:none">
|
||||
<button class="btn-save" style="align-self:flex-start" onclick="setup2FA()">2FA einrichten</button>
|
||||
</div>
|
||||
<div id="2fa-enabled-area" style="display:none">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span>
|
||||
</div>
|
||||
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="danger-zone">
|
||||
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
|
||||
@@ -1572,7 +1619,7 @@ function loginUI(username,id,role,subject){
|
||||
</div>`;
|
||||
|
||||
loadAll();
|
||||
initChat(username);
|
||||
initChat(username, id);
|
||||
}
|
||||
|
||||
function toggleDropdown(el){
|
||||
@@ -1870,9 +1917,92 @@ async function saveModal(){
|
||||
function openSettings(){
|
||||
document.getElementById('settings-overlay').style.display='flex';
|
||||
document.getElementById('user-dropdown')?.classList.remove('open');
|
||||
load2FAStatus();
|
||||
}
|
||||
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
|
||||
|
||||
async function load2FAStatus(){
|
||||
const statusRow=document.getElementById('2fa-status-row');
|
||||
document.getElementById('2fa-idle-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='none';
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
try {
|
||||
const r=await api('GET','2fa/status');
|
||||
statusRow.textContent='';
|
||||
if(r.enabled){
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
} else {
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
} catch(e) {
|
||||
statusRow.textContent='Fehler beim Laden.';
|
||||
}
|
||||
}
|
||||
|
||||
async function setup2FA(){
|
||||
document.getElementById('2fa-idle-area').style.display='none';
|
||||
document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…';
|
||||
try {
|
||||
const r=await api('POST','2fa/setup');
|
||||
document.getElementById('2fa-status-row').textContent='';
|
||||
if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;}
|
||||
document.getElementById('2fa-qr').src=r.qr;
|
||||
document.getElementById('2fa-secret').textContent=r.secret;
|
||||
document.getElementById('2fa-confirm-code').value='';
|
||||
document.getElementById('2fa-setup-area').style.display='';
|
||||
document.getElementById('2fa-confirm-code').focus();
|
||||
} catch(e) {
|
||||
document.getElementById('2fa-status-row').textContent='';
|
||||
toast('Fehler beim Generieren des QR-Codes','error');
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
}
|
||||
|
||||
function cancel2FASetup(){
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
|
||||
async function confirm2FA(){
|
||||
const code=document.getElementById('2fa-confirm-code').value.trim();
|
||||
if(!code){toast('Code eingeben','error');return;}
|
||||
const r=await api('POST','2fa/confirm',{token:code});
|
||||
if(r.ok){
|
||||
toast('2FA aktiviert ✓','success');
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
} else {
|
||||
toast(r.error,'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showDisable2FA(){
|
||||
document.getElementById('2fa-enabled-area').style.display='none';
|
||||
document.getElementById('2fa-disable-pw').value='';
|
||||
document.getElementById('2fa-disable-code').value='';
|
||||
document.getElementById('2fa-disable-area').style.display='';
|
||||
}
|
||||
|
||||
function cancel2FADisable(){
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
}
|
||||
|
||||
async function disable2FA(){
|
||||
const pw=document.getElementById('2fa-disable-pw').value;
|
||||
const code=document.getElementById('2fa-disable-code').value.trim();
|
||||
if(!pw||!code){toast('Passwort und Code erforderlich','error');return;}
|
||||
const r=await api('POST','2fa/disable',{password:pw,token:code});
|
||||
if(r.ok){
|
||||
toast('2FA deaktiviert','success');
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
} else {
|
||||
toast(r.error,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(){
|
||||
const cp=document.getElementById('pw-current').value;
|
||||
const np=document.getElementById('pw-new').value;
|
||||
@@ -1900,18 +2030,19 @@ function chatFmtTime(ts) {
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function renderChatMsg(m, append) {
|
||||
async function renderChatMsg(m, append) {
|
||||
const el = document.getElementById('chat-msgs');
|
||||
const isOwn = m.username === chatMyUsername;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.dataset.id = m.id;
|
||||
const displayContent = await E2EE.decrypt(m.content);
|
||||
div.innerHTML = `<div class="chat-msg-meta">
|
||||
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
|
||||
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
|
||||
<button class="chat-msg-del" onclick="delChatMsg(${m.id})" title="Löschen">✕</button>
|
||||
</div>
|
||||
<div class="chat-msg-body">${esc(m.content)}</div>`;
|
||||
<div class="chat-msg-body">${esc(displayContent)}</div>`;
|
||||
if (append) {
|
||||
el.appendChild(div);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
@@ -1924,17 +2055,17 @@ async function loadChat() {
|
||||
const msgs = await api('GET', 'chat');
|
||||
const el = document.getElementById('chat-msgs');
|
||||
el.innerHTML = '';
|
||||
msgs.forEach(m => renderChatMsg(m, true));
|
||||
for (const m of msgs) await renderChatMsg(m, true);
|
||||
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
|
||||
}
|
||||
|
||||
async function pollChat() {
|
||||
try {
|
||||
const msgs = await api('GET', 'chat?after=' + chatLastId);
|
||||
msgs.forEach(m => {
|
||||
renderChatMsg(m, true);
|
||||
for (const m of msgs) {
|
||||
await renderChatMsg(m, true);
|
||||
chatLastId = Math.max(chatLastId, m.id);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
chatPollTimer = setTimeout(pollChat, 3000);
|
||||
}
|
||||
@@ -1944,9 +2075,12 @@ async function sendChatMsg() {
|
||||
const content = inp.value.trim();
|
||||
if (!content) return;
|
||||
inp.value = '';
|
||||
const r = await api('POST', 'chat', { content });
|
||||
if (r.error) { toast(r.error, 'error'); return; }
|
||||
renderChatMsg(r, true);
|
||||
let ciphertext;
|
||||
try { ciphertext = await E2EE.encrypt(content); }
|
||||
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
|
||||
const r = await api('POST', 'chat', { content: ciphertext });
|
||||
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
|
||||
await renderChatMsg(r, true);
|
||||
chatLastId = Math.max(chatLastId, r.id);
|
||||
}
|
||||
|
||||
@@ -1957,9 +2091,11 @@ async function delChatMsg(id) {
|
||||
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
|
||||
}
|
||||
|
||||
function initChat(username) {
|
||||
async function initChat(username, userId) {
|
||||
chatMyUsername = username;
|
||||
loadChat().then(() => pollChat());
|
||||
await E2EE.init(userId);
|
||||
await loadChat();
|
||||
pollChat();
|
||||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
|
||||
});
|
||||
|
||||
+34
-4
@@ -209,7 +209,8 @@ footer a:hover { color: #2563eb; }
|
||||
</div>
|
||||
|
||||
<form class="form active" id="form-login" onsubmit="doLogin(event)">
|
||||
<div class="field">
|
||||
<div id="login-step-1">
|
||||
<div class="field" style="margin-bottom:14px">
|
||||
<label for="l-user">Benutzername</label>
|
||||
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
|
||||
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
|
||||
@@ -218,8 +219,17 @@ footer a:hover { color: #2563eb; }
|
||||
<label for="l-pass">Passwort</label>
|
||||
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
|
||||
</div>
|
||||
</div>
|
||||
<div id="login-step-2" style="display:none">
|
||||
<div class="field">
|
||||
<label for="l-totp">2FA-Code</label>
|
||||
<input type="text" id="l-totp" autocomplete="one-time-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric">
|
||||
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus deiner Authenticator-App eingeben</span>
|
||||
</div>
|
||||
<button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button>
|
||||
</div>
|
||||
<div class="notice notice-red" id="login-err"></div>
|
||||
<button class="btn-submit" type="submit">Anmelden</button>
|
||||
<button class="btn-submit" type="submit" id="login-btn">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<form class="form" id="form-reg" onsubmit="doRegister(event)">
|
||||
@@ -296,12 +306,32 @@ function showErr(id, msg) {
|
||||
}
|
||||
function clearErr(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
let totpPending = false;
|
||||
|
||||
function backToStep1() {
|
||||
totpPending = false;
|
||||
document.getElementById('login-step-1').style.display = '';
|
||||
document.getElementById('login-step-2').style.display = 'none';
|
||||
document.getElementById('login-btn').textContent = 'Anmelden';
|
||||
clearErr('login-err');
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault(); clearErr('login-err');
|
||||
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }) });
|
||||
const body = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
|
||||
if (totpPending) body.totp_token = document.getElementById('l-totp').value;
|
||||
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) { showErr('login-err', d.error); return; }
|
||||
if (d.requireTotp) {
|
||||
totpPending = true;
|
||||
document.getElementById('login-step-1').style.display = 'none';
|
||||
document.getElementById('login-step-2').style.display = '';
|
||||
document.getElementById('login-btn').textContent = 'Bestätigen';
|
||||
document.getElementById('l-totp').value = '';
|
||||
document.getElementById('l-totp').focus();
|
||||
return;
|
||||
}
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -190,10 +190,35 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
public_key_jwk TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_sender_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id TEXT NOT NULL,
|
||||
kid TEXT NOT NULL,
|
||||
recipient_user_id INTEGER NOT NULL,
|
||||
distributor_user_id INTEGER NOT NULL,
|
||||
encrypted_key TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (recipient_user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (distributor_user_id) REFERENCES users(id),
|
||||
UNIQUE(group_id, kid, recipient_user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Safe migrations
|
||||
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'student'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret TEXT`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`); } catch {}
|
||||
|
||||
module.exports = db;
|
||||
|
||||
+147
-2
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { generateSecret, generateURI, verifySync } = require('otplib');
|
||||
const QRCode = require('qrcode');
|
||||
const db = require('./db');
|
||||
const { signToken, requireAuth } = require('./auth');
|
||||
const { deleteUserFiles } = require('./files');
|
||||
@@ -56,7 +58,7 @@ router.post('/register', (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/login', loginLimiter, (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const { username, password, totp_token } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' });
|
||||
@@ -73,6 +75,11 @@ router.post('/login', loginLimiter, (req, res) => {
|
||||
if (user.status !== 'active') {
|
||||
return res.status(403).json({ error: 'Dein Konto ist nicht aktiv.' });
|
||||
}
|
||||
if (user.totp_enabled) {
|
||||
if (!totp_token) return res.json({ requireTotp: true });
|
||||
const totpResult = verifySync({ token: String(totp_token), secret: user.totp_secret });
|
||||
if (!totpResult || !totpResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
|
||||
}
|
||||
const token = signToken(user);
|
||||
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
|
||||
res.json({ ok: true, username: user.username, role: user.role, subject: user.subject });
|
||||
@@ -322,7 +329,7 @@ const chatLimiter = rateLimit({
|
||||
});
|
||||
|
||||
const CLASS_ID = 'info1';
|
||||
const CHAT_MAX_LEN = 500;
|
||||
const CHAT_MAX_LEN = 2048;
|
||||
|
||||
router.get('/chat', requireAuth, (req, res) => {
|
||||
const after = parseInt(req.query.after, 10) || 0;
|
||||
@@ -368,6 +375,144 @@ router.delete('/chat/:id', requireAuth, (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- E2EE KEY MANAGEMENT ---
|
||||
|
||||
function validPubKeyJwk(str) {
|
||||
if (typeof str !== 'string' || str.length > 1000) return false;
|
||||
try {
|
||||
const j = JSON.parse(str);
|
||||
return j.kty === 'EC' && j.crv === 'P-256'
|
||||
&& typeof j.x === 'string' && typeof j.y === 'string'
|
||||
&& j.x.length <= 64 && j.y.length <= 64
|
||||
&& !j.d; // reject private key component
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function validEncryptedKey(str) {
|
||||
if (typeof str !== 'string' || str.length > 2000) return false;
|
||||
try {
|
||||
const p = JSON.parse(str);
|
||||
return typeof p.iv === 'string' && typeof p.ct === 'string'
|
||||
&& p.iv.length <= 32 && p.ct.length <= 1800;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function validKid(kid) {
|
||||
return typeof kid === 'string' && /^[a-zA-Z0-9_\-=+/]{8,128}$/.test(kid);
|
||||
}
|
||||
|
||||
router.post('/e2ee/public-key', requireAuth, (req, res) => {
|
||||
const { public_key_jwk } = req.body;
|
||||
if (!validPubKeyJwk(public_key_jwk)) return res.status(400).json({ error: 'Ungültiger Public Key' });
|
||||
db.prepare(`INSERT OR REPLACE INTO user_keys (user_id, public_key_jwk) VALUES (?, ?)`)
|
||||
.run(req.user.id, public_key_jwk);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/e2ee/public-key/:userId', requireAuth, (req, res) => {
|
||||
const row = db.prepare('SELECT public_key_jwk FROM user_keys WHERE user_id = ?').get(req.params.userId);
|
||||
if (!row) return res.status(404).json({ error: 'Schlüssel nicht gefunden' });
|
||||
res.json({ public_key_jwk: row.public_key_jwk });
|
||||
});
|
||||
|
||||
router.get('/e2ee/users', requireAuth, (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, k.public_key_jwk
|
||||
FROM users u
|
||||
JOIN user_keys k ON k.user_id = u.id
|
||||
WHERE u.status = 'active'
|
||||
ORDER BY u.id
|
||||
`).all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
router.get('/e2ee/group-key', requireAuth, (req, res) => {
|
||||
const { group_id, kid } = req.query;
|
||||
if (!group_id) return res.status(400).json({ error: 'group_id erforderlich' });
|
||||
let row;
|
||||
if (kid) {
|
||||
row = db.prepare(`
|
||||
SELECT kid, encrypted_key, distributor_user_id FROM group_sender_keys
|
||||
WHERE group_id = ? AND kid = ? AND recipient_user_id = ?
|
||||
`).get(group_id, kid, req.user.id);
|
||||
} else {
|
||||
row = db.prepare(`
|
||||
SELECT kid, encrypted_key, distributor_user_id FROM group_sender_keys
|
||||
WHERE group_id = ? AND recipient_user_id = ?
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`).get(group_id, req.user.id);
|
||||
}
|
||||
if (!row) return res.status(404).json({ error: 'Schlüssel nicht gefunden' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
router.post('/e2ee/group-keys', requireAuth, (req, res) => {
|
||||
const { group_id, kid, keys } = req.body;
|
||||
if (!group_id || !validKid(kid) || !Array.isArray(keys)) {
|
||||
return res.status(400).json({ error: 'Ungültige Anfrage' });
|
||||
}
|
||||
if (keys.length > 500) return res.status(400).json({ error: 'Zu viele Einträge' });
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO group_sender_keys
|
||||
(group_id, kid, recipient_user_id, distributor_user_id, encrypted_key)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const distribute = db.transaction(entries => {
|
||||
for (const { user_id, encrypted_key } of entries) {
|
||||
if (!Number.isInteger(user_id) || !validEncryptedKey(encrypted_key)) continue;
|
||||
const target = db.prepare(`SELECT id FROM users WHERE id = ? AND status = 'active'`).get(user_id);
|
||||
if (!target) continue;
|
||||
stmt.run(group_id, kid, user_id, req.user.id, encrypted_key);
|
||||
}
|
||||
});
|
||||
distribute(keys);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- 2FA ---
|
||||
router.post('/2fa/setup', requireAuth, async (req, res) => {
|
||||
const secret = generateSecret();
|
||||
const user = db.prepare('SELECT username, email FROM users WHERE id = ?').get(req.user.id);
|
||||
db.prepare('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?').run(secret, req.user.id);
|
||||
const otpauth = generateURI({ secret, label: user.email, issuer: 'INFO1', type: 'totp' });
|
||||
try {
|
||||
const qr = await QRCode.toDataURL(otpauth);
|
||||
res.json({ otpauth, qr, secret });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'QR-Generierung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/2fa/confirm', requireAuth, loginLimiter, (req, res) => {
|
||||
const { token } = req.body;
|
||||
const user = db.prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user.totp_secret) return res.status(400).json({ error: '2FA-Setup nicht gestartet' });
|
||||
const result = verifySync({ token: String(token), secret: user.totp_secret });
|
||||
if (!result || !result.valid) return res.status(401).json({ error: 'Ungültiger Code' });
|
||||
db.prepare('UPDATE users SET totp_enabled = 1 WHERE id = ?').run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/2fa/disable', requireAuth, loginLimiter, (req, res) => {
|
||||
const { password, token } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Passwort falsch' });
|
||||
}
|
||||
if (!user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(400).json({ error: '2FA ist nicht aktiv' });
|
||||
}
|
||||
const disableResult = verifySync({ token: String(token), secret: user.totp_secret });
|
||||
if (!disableResult || !disableResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
|
||||
db.prepare('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?').run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/2fa/status', requireAuth, (req, res) => {
|
||||
const user = db.prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id);
|
||||
res.json({ enabled: !!user.totp_enabled });
|
||||
});
|
||||
|
||||
// --- PERSONAL CRUD ---
|
||||
function crudRoutes(path, table, fields) {
|
||||
router.get(`/${path}`, requireAuth, (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user