Добавлена поддержка двухфакторной аутентификации (2FA) в систему. Реализованы API для настройки, включения, отключения и проверки 2FA, а также обновлены компоненты для обработки 2FA на страницах входа и профиля. Обновлены типы данных для поддержки новых функций и улучшено взаимодействие с пользователем через уведомления.
This commit is contained in:
parent
83abe3f919
commit
c4327d31d1
279
backend/package-lock.json
generated
279
backend/package-lock.json
generated
@ -29,6 +29,8 @@
|
|||||||
"multer": "^2.0.0-rc.4",
|
"multer": "^2.0.0-rc.4",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -1199,11 +1201,25 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"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/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@ -1248,6 +1264,12 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/base32.js": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@ -1449,6 +1471,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@ -1490,6 +1521,17 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/codemirror": {
|
"node_modules/codemirror": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
@ -1580,6 +1622,24 @@
|
|||||||
"@lezer/common": "^1.0.0"
|
"@lezer/common": "^1.0.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/color-support": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@ -1702,6 +1762,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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@ -1746,6 +1815,12 @@
|
|||||||
"node": ">=8"
|
"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/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@ -1778,8 +1853,7 @@
|
|||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -2018,6 +2092,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/formdata-polyfill": {
|
"node_modules/formdata-polyfill": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
@ -2109,6 +2196,15 @@
|
|||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.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/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@ -2431,7 +2527,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@ -2474,6 +2569,18 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"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/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
@ -2989,6 +3096,33 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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-map": {
|
"node_modules/p-map": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||||
@ -3004,6 +3138,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@ -3012,6 +3155,15 @@
|
|||||||
"node": ">= 0.8"
|
"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-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@ -3121,6 +3273,32 @@
|
|||||||
"once": "^1.3.1"
|
"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/qrcode/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/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
@ -3219,6 +3397,21 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/retry": {
|
"node_modules/retry": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
@ -3332,8 +3525,7 @@
|
|||||||
"node_modules/set-blocking": {
|
"node_modules/set-blocking": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@ -3549,6 +3741,18 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/speakeasy": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base32.js": "0.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sqlite3": {
|
"node_modules/sqlite3": {
|
||||||
"version": "5.1.7",
|
"version": "5.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
|
||||||
@ -3612,7 +3816,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
@ -3626,7 +3829,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
},
|
},
|
||||||
@ -3863,6 +4065,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 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/wide-align": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
@ -3872,6 +4080,20 @@
|
|||||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@ -3885,10 +4107,51 @@
|
|||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@
|
|||||||
"multer": "^2.0.0-rc.4",
|
"multer": "^2.0.0-rc.4",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -14,6 +14,8 @@ const multer = require("multer");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const https = require("https");
|
const https = require("https");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
const speakeasy = require("speakeasy");
|
||||||
|
const QRCode = require("qrcode");
|
||||||
const { encrypt, decrypt } = require("./utils/encryption");
|
const { encrypt, decrypt } = require("./utils/encryption");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -541,6 +543,70 @@ function runMigrations() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем существование колонок для 2FA
|
||||||
|
const hasTwoFactorEnabled = columns.some(
|
||||||
|
(col) => col.name === "two_factor_enabled"
|
||||||
|
);
|
||||||
|
if (!hasTwoFactorEnabled) {
|
||||||
|
db.run(
|
||||||
|
"ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER DEFAULT 0",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка добавления колонки two_factor_enabled:",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Колонка two_factor_enabled добавлена в таблицу users"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTwoFactorSecret = columns.some(
|
||||||
|
(col) => col.name === "two_factor_secret"
|
||||||
|
);
|
||||||
|
if (!hasTwoFactorSecret) {
|
||||||
|
db.run(
|
||||||
|
"ALTER TABLE users ADD COLUMN two_factor_secret TEXT",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка добавления колонки two_factor_secret:",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Колонка two_factor_secret добавлена в таблицу users"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTwoFactorBackupCodes = columns.some(
|
||||||
|
(col) => col.name === "two_factor_backup_codes"
|
||||||
|
);
|
||||||
|
if (!hasTwoFactorBackupCodes) {
|
||||||
|
db.run(
|
||||||
|
"ALTER TABLE users ADD COLUMN two_factor_backup_codes TEXT",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка добавления колонки two_factor_backup_codes:",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Колонка two_factor_backup_codes добавлена в таблицу users"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем существование колонок в таблице notes и добавляем их если нужно
|
// Проверяем существование колонок в таблице notes и добавляем их если нужно
|
||||||
@ -747,7 +813,23 @@ app.post("/api/login", async (req, res) => {
|
|||||||
return res.status(401).json({ error: "Неверный логин или пароль" });
|
return res.status(401).json({ error: "Неверный логин или пароль" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Успешный вход
|
// Проверяем, включена ли 2FA
|
||||||
|
if (user.two_factor_enabled === 1) {
|
||||||
|
// Создаем промежуточную сессию для проверки 2FA
|
||||||
|
req.session.twoFactorPending = true;
|
||||||
|
req.session.twoFactorUsername = username;
|
||||||
|
req.session.twoFactorUserId = user.id;
|
||||||
|
|
||||||
|
// НЕ создаем полную сессию, только промежуточную
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
requires2FA: true,
|
||||||
|
message: "Требуется код двухфакторной аутентификации",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если 2FA не включена, создаем полную сессию как обычно
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
req.session.username = user.username;
|
req.session.username = user.username;
|
||||||
req.session.authenticated = true;
|
req.session.authenticated = true;
|
||||||
@ -797,7 +879,7 @@ app.get("/api/user", requireApiAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sql =
|
const sql =
|
||||||
"SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled FROM users WHERE id = ?";
|
"SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled, two_factor_enabled FROM users WHERE id = ?";
|
||||||
db.get(sql, [req.session.userId], (err, user) => {
|
db.get(sql, [req.session.userId], (err, user) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка получения данных пользователя:", err.message);
|
console.error("Ошибка получения данных пользователя:", err.message);
|
||||||
@ -812,6 +894,27 @@ app.get("/api/user", requireApiAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API для получения статуса 2FA
|
||||||
|
app.get("/api/user/2fa/status", requireApiAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
const sql = "SELECT two_factor_enabled FROM users WHERE id = ?";
|
||||||
|
db.get(sql, [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка получения статуса 2FA:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: "Пользователь не найден" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
twoFactorEnabled: user.two_factor_enabled === 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Страница с заметками (требует аутентификации)
|
// Страница с заметками (требует аутентификации)
|
||||||
app.get("/notes", requireAuth, (req, res) => {
|
app.get("/notes", requireAuth, (req, res) => {
|
||||||
// Получаем цвет пользователя для предотвращения FOUC
|
// Получаем цвет пользователя для предотвращения FOUC
|
||||||
@ -2412,6 +2515,318 @@ app.put("/api/user/ai-settings", requireApiAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API для получения настроек 2FA (генерация секрета и QR-кода)
|
||||||
|
app.get("/api/user/2fa/setup", requireApiAuth, async (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
const getUserSql = "SELECT username FROM users WHERE id = ?";
|
||||||
|
db.get(getUserSql, [userId], async (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка получения пользователя:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: "Пользователь не найден" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем секретный ключ
|
||||||
|
const secret = speakeasy.generateSecret({
|
||||||
|
name: `NoteJS (${user.username})`,
|
||||||
|
issuer: "NoteJS",
|
||||||
|
length: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Генерируем QR-код
|
||||||
|
try {
|
||||||
|
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
secret: secret.base32, // Возвращаем base32 секрет для ручного ввода
|
||||||
|
qrCode: qrCodeUrl, // Возвращаем QR-код как data URL
|
||||||
|
otpauthUrl: secret.otpauth_url,
|
||||||
|
});
|
||||||
|
} catch (qrError) {
|
||||||
|
console.error("Ошибка генерации QR-кода:", qrError);
|
||||||
|
return res.status(500).json({ error: "Ошибка генерации QR-кода" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка настройки 2FA:", error);
|
||||||
|
res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для включения 2FA
|
||||||
|
app.post("/api/user/2fa/enable", requireApiAuth, async (req, res) => {
|
||||||
|
const { secret, token } = req.body;
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
if (!secret || !token) {
|
||||||
|
return res.status(400).json({ error: "Секрет и код обязательны" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем токен
|
||||||
|
const verified = speakeasy.totp.verify({
|
||||||
|
secret: secret,
|
||||||
|
encoding: "base32",
|
||||||
|
token: token,
|
||||||
|
window: 2, // Разрешаем небольшое отклонение по времени
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return res.status(400).json({ error: "Неверный код подтверждения" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Генерируем резервные коды (10 штук)
|
||||||
|
const backupCodes = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
backupCodes.push(
|
||||||
|
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хешируем резервные коды
|
||||||
|
const hashedBackupCodes = await Promise.all(
|
||||||
|
backupCodes.map((code) => bcrypt.hash(code, 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Шифруем секрет перед сохранением
|
||||||
|
const encryptedSecret = encrypt(secret);
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
const updateSql =
|
||||||
|
"UPDATE users SET two_factor_enabled = 1, two_factor_secret = ?, two_factor_backup_codes = ? WHERE id = ?";
|
||||||
|
db.run(
|
||||||
|
updateSql,
|
||||||
|
[encryptedSecret, JSON.stringify(hashedBackupCodes), userId],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка включения 2FA:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем включение 2FA
|
||||||
|
logAction(userId, "profile_update", "Включена двухфакторная аутентификация");
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "2FA успешно включена",
|
||||||
|
backupCodes: backupCodes, // Возвращаем незахешированные коды один раз
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка включения 2FA:", error);
|
||||||
|
res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для отключения 2FA
|
||||||
|
app.post("/api/user/2fa/disable", requireApiAuth, async (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return res.status(400).json({ error: "Пароль обязателен" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем пароль
|
||||||
|
const getUserSql = "SELECT password FROM users WHERE id = ?";
|
||||||
|
db.get(getUserSql, [userId], async (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка получения пользователя:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: "Пользователь не найден" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, user.password);
|
||||||
|
if (!validPassword) {
|
||||||
|
return res.status(401).json({ error: "Неверный пароль" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отключаем 2FA
|
||||||
|
const updateSql =
|
||||||
|
"UPDATE users SET two_factor_enabled = 0, two_factor_secret = NULL, two_factor_backup_codes = NULL WHERE id = ?";
|
||||||
|
db.run(updateSql, [userId], function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка отключения 2FA:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем отключение 2FA
|
||||||
|
logAction(userId, "profile_update", "Отключена двухфакторная аутентификация");
|
||||||
|
|
||||||
|
res.json({ success: true, message: "2FA успешно отключена" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка отключения 2FA:", error);
|
||||||
|
res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для проверки 2FA кода при входе
|
||||||
|
app.post("/api/user/2fa/verify", async (req, res) => {
|
||||||
|
const { username, token } = req.body;
|
||||||
|
|
||||||
|
if (!username || !token) {
|
||||||
|
return res.status(400).json({ error: "Логин и код обязательны" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, есть ли промежуточная сессия для этого пользователя
|
||||||
|
if (!req.session.twoFactorPending || req.session.twoFactorUsername !== username) {
|
||||||
|
return res.status(400).json({ error: "Сессия не найдена. Начните вход заново" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем пользователя
|
||||||
|
const getUserSql =
|
||||||
|
"SELECT id, username, two_factor_secret, two_factor_backup_codes FROM users WHERE username = ? AND two_factor_enabled = 1";
|
||||||
|
db.get(getUserSql, [username], async (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка проверки 2FA:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !user.two_factor_secret) {
|
||||||
|
return res.status(400).json({ error: "2FA не настроена для этого пользователя" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расшифровываем секрет
|
||||||
|
const secret = decrypt(user.two_factor_secret);
|
||||||
|
|
||||||
|
// Проверяем TOTP код
|
||||||
|
const verified = speakeasy.totp.verify({
|
||||||
|
secret: secret,
|
||||||
|
encoding: "base32",
|
||||||
|
token: token,
|
||||||
|
window: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
let backupCodeUsed = false;
|
||||||
|
|
||||||
|
// Если TOTP не прошел, проверяем резервные коды
|
||||||
|
if (!verified && user.two_factor_backup_codes) {
|
||||||
|
try {
|
||||||
|
const backupCodes = JSON.parse(user.two_factor_backup_codes);
|
||||||
|
let codeIndex = -1;
|
||||||
|
|
||||||
|
// Проверяем каждый резервный код
|
||||||
|
for (let i = 0; i < backupCodes.length; i++) {
|
||||||
|
const match = await bcrypt.compare(token, backupCodes[i]);
|
||||||
|
if (match) {
|
||||||
|
codeIndex = i;
|
||||||
|
backupCodeUsed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если резервный код использован, удаляем его
|
||||||
|
if (codeIndex !== -1) {
|
||||||
|
backupCodes.splice(codeIndex, 1);
|
||||||
|
const updateSql =
|
||||||
|
"UPDATE users SET two_factor_backup_codes = ? WHERE id = ?";
|
||||||
|
db.run(updateSql, [JSON.stringify(backupCodes), user.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка обновления резервных кодов:", err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error("Ошибка парсинга резервных кодов:", parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified && !backupCodeUsed) {
|
||||||
|
return res.status(401).json({ error: "Неверный код" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем полную сессию
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.username = user.username;
|
||||||
|
req.session.authenticated = true;
|
||||||
|
delete req.session.twoFactorPending;
|
||||||
|
delete req.session.twoFactorUsername;
|
||||||
|
|
||||||
|
// Логируем вход
|
||||||
|
logAction(user.id, "login", `Вход в систему${backupCodeUsed ? " (резервный код)" : ""}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Вход успешен",
|
||||||
|
backupCodeUsed: backupCodeUsed,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка проверки 2FA:", error);
|
||||||
|
res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для генерации новых резервных кодов
|
||||||
|
app.post("/api/user/2fa/backup-codes", requireApiAuth, async (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем, что 2FA включена
|
||||||
|
const getUserSql =
|
||||||
|
"SELECT two_factor_enabled FROM users WHERE id = ?";
|
||||||
|
db.get(getUserSql, [userId], async (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка получения пользователя:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !user.two_factor_enabled) {
|
||||||
|
return res.status(400).json({ error: "2FA не включена" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем новые резервные коды
|
||||||
|
const backupCodes = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
backupCodes.push(
|
||||||
|
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хешируем резервные коды
|
||||||
|
const hashedBackupCodes = await Promise.all(
|
||||||
|
backupCodes.map((code) => bcrypt.hash(code, 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
const updateSql =
|
||||||
|
"UPDATE users SET two_factor_backup_codes = ? WHERE id = ?";
|
||||||
|
db.run(updateSql, [JSON.stringify(hashedBackupCodes), userId], function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка генерации резервных кодов:", err.message);
|
||||||
|
return res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем генерацию резервных кодов
|
||||||
|
logAction(userId, "profile_update", "Сгенерированы новые резервные коды 2FA");
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
backupCodes: backupCodes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка генерации резервных кодов:", error);
|
||||||
|
res.status(500).json({ error: "Ошибка сервера" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// API для улучшения текста через AI
|
// API для улучшения текста через AI
|
||||||
app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
||||||
const { text } = req.body;
|
const { text } = req.body;
|
||||||
|
|||||||
@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "/index.html",
|
"url": "/index.html",
|
||||||
"revision": "0.5s0rolua70o"
|
"revision": "0.dlpr89s9d7"
|
||||||
}], {
|
}], {
|
||||||
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import axiosClient from "./axiosClient";
|
|||||||
import { AuthResponse } from "../types/user";
|
import { AuthResponse } from "../types/user";
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: async (username: string, password: string) => {
|
login: async (username: string, password: string): Promise<AuthResponse & { requires2FA?: boolean; message?: string }> => {
|
||||||
const { data } = await axiosClient.post("/login", { username, password });
|
const { data } = await axiosClient.post("/login", { username, password });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -40,6 +40,8 @@ axiosClient.interceptors.response.use(
|
|||||||
"/register", // Страница регистрации
|
"/register", // Страница регистрации
|
||||||
"/notes/archived/all", // Удаление всех архивных заметок
|
"/notes/archived/all", // Удаление всех архивных заметок
|
||||||
"/user/delete-account", // Удаление аккаунта
|
"/user/delete-account", // Удаление аккаунта
|
||||||
|
"/user/2fa/verify", // Проверка 2FA кода
|
||||||
|
"/user/2fa/disable", // Отключение 2FA
|
||||||
];
|
];
|
||||||
|
|
||||||
// URL, где 401 не должен обрабатываться как ошибка сессии
|
// URL, где 401 не должен обрабатываться как ошибка сессии
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import axiosClient from "./axiosClient";
|
import axiosClient from "./axiosClient";
|
||||||
import { User, AiSettings } from "../types/user";
|
import {
|
||||||
|
User,
|
||||||
|
AiSettings,
|
||||||
|
TwoFactorSetup,
|
||||||
|
TwoFactorStatus,
|
||||||
|
TwoFactorEnableResponse,
|
||||||
|
TwoFactorDisableResponse,
|
||||||
|
TwoFactorVerifyResponse,
|
||||||
|
} from "../types/user";
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
getProfile: async (): Promise<User> => {
|
getProfile: async (): Promise<User> => {
|
||||||
@ -51,4 +59,52 @@ export const userApi = {
|
|||||||
const { data } = await axiosClient.put("/user/ai-settings", settings);
|
const { data } = await axiosClient.put("/user/ai-settings", settings);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 2FA методы
|
||||||
|
get2FAStatus: async (): Promise<TwoFactorStatus> => {
|
||||||
|
const { data } = await axiosClient.get<TwoFactorStatus>("/user/2fa/status");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setup2FA: async (): Promise<TwoFactorSetup> => {
|
||||||
|
const { data } = await axiosClient.get<TwoFactorSetup>("/user/2fa/setup");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
enable2FA: async (
|
||||||
|
secret: string,
|
||||||
|
token: string
|
||||||
|
): Promise<TwoFactorEnableResponse> => {
|
||||||
|
const { data } = await axiosClient.post<TwoFactorEnableResponse>(
|
||||||
|
"/user/2fa/enable",
|
||||||
|
{ secret, token }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
disable2FA: async (password: string): Promise<TwoFactorDisableResponse> => {
|
||||||
|
const { data } = await axiosClient.post<TwoFactorDisableResponse>(
|
||||||
|
"/user/2fa/disable",
|
||||||
|
{ password }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
verify2FA: async (
|
||||||
|
username: string,
|
||||||
|
token: string
|
||||||
|
): Promise<TwoFactorVerifyResponse> => {
|
||||||
|
const { data } = await axiosClient.post<TwoFactorVerifyResponse>(
|
||||||
|
"/user/2fa/verify",
|
||||||
|
{ username, token }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateBackupCodes: async (): Promise<{ success: boolean; backupCodes: string[] }> => {
|
||||||
|
const { data } = await axiosClient.post<{ success: boolean; backupCodes: string[] }>(
|
||||||
|
"/user/2fa/backup-codes"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ interface ModalProps {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string | ReactNode;
|
message: string | ReactNode;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string | null;
|
||||||
confirmType?: "primary" | "danger";
|
confirmType?: "primary" | "danger";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,9 +18,15 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
confirmText = "OK",
|
confirmText = "OK",
|
||||||
cancelText = "Отмена",
|
cancelText,
|
||||||
confirmType = "primary",
|
confirmType = "primary",
|
||||||
}) => {
|
}) => {
|
||||||
|
// Если cancelText не передан или пустая строка, скрываем кнопку отмены
|
||||||
|
// Если передан null или пустая строка, не показываем кнопку
|
||||||
|
// Если не передан вообще (undefined), используем дефолтное значение "Отмена"
|
||||||
|
const shouldShowCancel = cancelText !== null && cancelText !== "";
|
||||||
|
const displayCancelText = cancelText === undefined ? "Отмена" : cancelText;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@ -54,9 +60,11 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-secondary" onClick={onClose}>
|
{shouldShowCancel && (
|
||||||
{cancelText}
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
</button>
|
{displayCancelText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
446
src/components/twoFactor/TwoFactorSetup.tsx
Normal file
446
src/components/twoFactor/TwoFactorSetup.tsx
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { userApi } from "../../api/userApi";
|
||||||
|
import { useNotification } from "../../hooks/useNotification";
|
||||||
|
import { Modal } from "../common/Modal";
|
||||||
|
|
||||||
|
interface TwoFactorSetupProps {
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
onStatusChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({
|
||||||
|
twoFactorEnabled,
|
||||||
|
onStatusChange,
|
||||||
|
}) => {
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [setupData, setSetupData] = useState<{
|
||||||
|
secret: string;
|
||||||
|
qrCode: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||||
|
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||||
|
const [disablePassword, setDisablePassword] = useState("");
|
||||||
|
const [isDisabling, setIsDisabling] = useState(false);
|
||||||
|
const [showBackupCodesModal, setShowBackupCodesModal] = useState(false);
|
||||||
|
const [isGeneratingBackupCodes, setIsGeneratingBackupCodes] = useState(false);
|
||||||
|
|
||||||
|
const handleSetup = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await userApi.setup2FA();
|
||||||
|
setSetupData({
|
||||||
|
secret: data.secret,
|
||||||
|
qrCode: data.qrCode,
|
||||||
|
});
|
||||||
|
setVerificationCode("");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка настройки 2FA:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка настройки 2FA",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnable = async () => {
|
||||||
|
if (!setupData || !verificationCode.trim()) {
|
||||||
|
showNotification("Введите код подтверждения", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationCode.trim().length !== 6) {
|
||||||
|
showNotification("Код должен содержать 6 цифр", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.enable2FA(setupData.secret, verificationCode.trim());
|
||||||
|
setBackupCodes(response.backupCodes);
|
||||||
|
setShowBackupCodesModal(true);
|
||||||
|
setSetupData(null);
|
||||||
|
setVerificationCode("");
|
||||||
|
onStatusChange();
|
||||||
|
showNotification("2FA успешно включена", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка включения 2FA:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка включения 2FA",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable = async () => {
|
||||||
|
if (!disablePassword.trim()) {
|
||||||
|
showNotification("Введите пароль", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDisabling(true);
|
||||||
|
try {
|
||||||
|
await userApi.disable2FA(disablePassword);
|
||||||
|
setShowDisableModal(false);
|
||||||
|
setDisablePassword("");
|
||||||
|
onStatusChange();
|
||||||
|
showNotification("2FA успешно отключена", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка отключения 2FA:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка отключения 2FA",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDisabling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateBackupCodes = async () => {
|
||||||
|
setIsGeneratingBackupCodes(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.generateBackupCodes();
|
||||||
|
setBackupCodes(response.backupCodes);
|
||||||
|
setShowBackupCodesModal(true);
|
||||||
|
showNotification("Резервные коды успешно сгенерированы", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка генерации резервных кодов:", error);
|
||||||
|
showNotification(
|
||||||
|
error.response?.data?.error || "Ошибка генерации резервных кодов",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingBackupCodes(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
showNotification("Скопировано в буфер обмена", "success");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>Двухфакторная аутентификация</h3>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}>
|
||||||
|
Двухфакторная аутентификация добавляет дополнительный уровень
|
||||||
|
безопасности к вашему аккаунту. Используйте приложение-аутентификатор
|
||||||
|
(Google Authenticator, Microsoft Authenticator и т.д.) для генерации
|
||||||
|
кодов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!twoFactorEnabled ? (
|
||||||
|
<div>
|
||||||
|
{!setupData ? (
|
||||||
|
<div>
|
||||||
|
<button className="btnSave" onClick={handleSetup} disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:loading" /> Настройка...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:shield-lock" /> Включить 2FA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "20px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 style={{ marginTop: 0 }}>Шаг 1: Отсканируйте QR-код</h4>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||||
|
Откройте приложение-аутентификатор на вашем телефоне и
|
||||||
|
отсканируйте этот QR-код:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={setupData.qrCode}
|
||||||
|
alt="QR Code"
|
||||||
|
style={{
|
||||||
|
width: "200px",
|
||||||
|
height: "200px",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: "15px" }}>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "10px" }}>
|
||||||
|
Или введите секретный ключ вручную:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "10px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "var(--bg-color)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{setupData.secret}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
className="btnSave"
|
||||||
|
onClick={() => copyToClipboard(setupData.secret)}
|
||||||
|
style={{ padding: "10px 15px" }}
|
||||||
|
title="Копировать"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:content-copy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "20px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 style={{ marginTop: 0 }}>Шаг 2: Введите код подтверждения</h4>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||||
|
Введите 6-значный код из приложения-аутентификатора:
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="000000"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||||
|
setVerificationCode(value);
|
||||||
|
}}
|
||||||
|
maxLength={6}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "24px",
|
||||||
|
letterSpacing: "8px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "10px" }}>
|
||||||
|
<button
|
||||||
|
className="btnSave"
|
||||||
|
onClick={handleEnable}
|
||||||
|
disabled={isLoading || verificationCode.length !== 6}
|
||||||
|
>
|
||||||
|
{isLoading ? "Включение..." : "Включить 2FA"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={() => {
|
||||||
|
setSetupData(null);
|
||||||
|
setVerificationCode("");
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid #28a745",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "20px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:shield-check" style={{ fontSize: "24px", color: "#28a745" }} />
|
||||||
|
<span style={{ fontWeight: "600", color: "#28a745" }}>
|
||||||
|
2FA включена
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "15px" }}>
|
||||||
|
Двухфакторная аутентификация активна для вашего аккаунта.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: "10px" }}>
|
||||||
|
<button
|
||||||
|
className="btnSave"
|
||||||
|
onClick={handleGenerateBackupCodes}
|
||||||
|
disabled={isGeneratingBackupCodes}
|
||||||
|
>
|
||||||
|
{isGeneratingBackupCodes ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:loading" /> Генерация...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:key-variant" /> Сгенерировать резервные коды
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={() => setShowDisableModal(true)}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:shield-off" /> Отключить 2FA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для отключения 2FA */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showDisableModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDisableModal(false);
|
||||||
|
setDisablePassword("");
|
||||||
|
}}
|
||||||
|
onConfirm={handleDisable}
|
||||||
|
title="Отключение двухфакторной аутентификации"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p style={{ marginBottom: "15px" }}>
|
||||||
|
Вы уверены, что хотите отключить двухфакторную аутентификацию? Это
|
||||||
|
снизит безопасность вашего аккаунта.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="disablePassword" style={{ marginBottom: "5px", display: "block" }}>
|
||||||
|
Введите пароль для подтверждения:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="disablePassword"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Пароль"
|
||||||
|
value={disablePassword}
|
||||||
|
onChange={(e) => setDisablePassword(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === "Enter" && !isDisabling && disablePassword.trim()) {
|
||||||
|
handleDisable();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText={isDisabling ? "Отключение..." : "Отключить"}
|
||||||
|
cancelText="Отмена"
|
||||||
|
confirmType="danger"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модальное окно для показа резервных кодов */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showBackupCodesModal}
|
||||||
|
onClose={() => setShowBackupCodesModal(false)}
|
||||||
|
onConfirm={() => setShowBackupCodesModal(false)}
|
||||||
|
title="Резервные коды"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#dc3545",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ ВАЖНО: Сохраните эти коды в безопасном месте!
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: "15px" }}>
|
||||||
|
Если вы потеряете доступ к приложению-аутентификатору, вы сможете
|
||||||
|
использовать эти резервные коды для входа. Каждый код можно
|
||||||
|
использовать только один раз.
|
||||||
|
</p>
|
||||||
|
{backupCodes && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-color)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "15px",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, 1fr)",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backupCodes.map((code, index) => (
|
||||||
|
<code
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p style={{ fontSize: "12px", color: "#666" }}>
|
||||||
|
Совет: Распечатайте эти коды или сохраните их в менеджере
|
||||||
|
паролей.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="Я сохранил коды"
|
||||||
|
cancelText={null}
|
||||||
|
confirmType="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -3,6 +3,7 @@ import { useNavigate, useSearchParams, Link } from "react-router-dom";
|
|||||||
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||||
import { setAuth } from "../store/slices/authSlice";
|
import { setAuth } from "../store/slices/authSlice";
|
||||||
import { authApi } from "../api/authApi";
|
import { authApi } from "../api/authApi";
|
||||||
|
import { userApi } from "../api/userApi";
|
||||||
import { useNotification } from "../hooks/useNotification";
|
import { useNotification } from "../hooks/useNotification";
|
||||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
@ -11,7 +12,9 @@ import { dbManager } from "../utils/indexedDB";
|
|||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [requires2FA, setRequires2FA] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
@ -48,53 +51,122 @@ const LoginPage: React.FC = () => {
|
|||||||
console.log("Login response:", data);
|
console.log("Login response:", data);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Получаем информацию о пользователе
|
// Проверяем, требуется ли 2FA
|
||||||
const authStatus = await authApi.checkStatus();
|
if (data.requires2FA) {
|
||||||
const newUserId = authStatus.userId!;
|
setRequires2FA(true);
|
||||||
|
showNotification("Введите код двухфакторной аутентификации", "info");
|
||||||
// Если пользователь изменился, очищаем IndexedDB
|
setIsLoading(false);
|
||||||
if (currentUserId && currentUserId !== newUserId) {
|
return;
|
||||||
console.log(`[Login] User changed from ${currentUserId} to ${newUserId}, clearing IndexedDB`);
|
|
||||||
await dbManager.clearAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(
|
// Если 2FA не требуется, продолжаем как обычно
|
||||||
setAuth({
|
await completeLogin();
|
||||||
userId: newUserId,
|
|
||||||
username: authStatus.username!,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
showNotification("Успешный вход!", "success");
|
|
||||||
navigate("/notes");
|
|
||||||
} else {
|
} else {
|
||||||
showNotification(data.error || "Ошибка входа", "error");
|
showNotification(data.error || "Ошибка входа", "error");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Login error details:", error);
|
console.error("Login error details:", error);
|
||||||
console.error("Error response:", error.response);
|
console.error("Error response:", error.response);
|
||||||
console.error("Error message:", error.message);
|
console.error("Error response data:", error.response?.data);
|
||||||
|
|
||||||
let errorMsg = "Ошибка соединения с сервером";
|
let errorMsg = "Ошибка соединения с сервером";
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
// Сервер ответил, но с ошибкой
|
// Сервер ответил, но с ошибкой (например, 401 для неверного пароля)
|
||||||
errorMsg =
|
const responseData = error.response.data;
|
||||||
error.response.data?.error || `Ошибка ${error.response.status}`;
|
console.log("Response data:", responseData);
|
||||||
|
|
||||||
|
// Если это ошибка авторизации (401), показываем сообщение от сервера
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
errorMsg = responseData?.error || "Неверный логин или пароль";
|
||||||
|
console.log("401 error message:", errorMsg);
|
||||||
|
} else {
|
||||||
|
// Для других ошибок показываем общее сообщение
|
||||||
|
errorMsg = responseData?.error || `Ошибка ${error.response.status}`;
|
||||||
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// Запрос был отправлен, но ответа нет
|
// Запрос был отправлен, но ответа нет
|
||||||
|
console.error("No response from server");
|
||||||
errorMsg =
|
errorMsg =
|
||||||
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3000";
|
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3001";
|
||||||
} else {
|
} else {
|
||||||
// Ошибка при настройке запроса
|
// Ошибка при настройке запроса
|
||||||
|
console.error("Request setup error:", error.message);
|
||||||
errorMsg = error.message || "Ошибка соединения с сервером";
|
errorMsg = error.message || "Ошибка соединения с сервером";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("Showing error notification:", errorMsg);
|
||||||
showNotification(errorMsg, "error");
|
showNotification(errorMsg, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const completeLogin = async () => {
|
||||||
|
try {
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
const authStatus = await authApi.checkStatus();
|
||||||
|
const newUserId = authStatus.userId!;
|
||||||
|
|
||||||
|
// Если пользователь изменился, очищаем IndexedDB
|
||||||
|
if (currentUserId && currentUserId !== newUserId) {
|
||||||
|
console.log(
|
||||||
|
`[Login] User changed from ${currentUserId} to ${newUserId}, clearing IndexedDB`
|
||||||
|
);
|
||||||
|
await dbManager.clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setAuth({
|
||||||
|
userId: newUserId,
|
||||||
|
username: authStatus.username!,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
showNotification("Успешный вход!", "success");
|
||||||
|
navigate("/notes");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка завершения входа:", error);
|
||||||
|
showNotification("Ошибка завершения входа", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle2FAVerify = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!twoFactorCode.trim()) {
|
||||||
|
showNotification("Введите код двухфакторной аутентификации", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await userApi.verify2FA(username, twoFactorCode.trim());
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (data.backupCodeUsed) {
|
||||||
|
showNotification("Вход выполнен с использованием резервного кода", "info");
|
||||||
|
}
|
||||||
|
await completeLogin();
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || "Неверный код", "error");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Ошибка проверки 2FA:", error);
|
||||||
|
const errorMsg =
|
||||||
|
error.response?.data?.error || "Ошибка проверки кода";
|
||||||
|
showNotification(errorMsg, "error");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setRequires2FA(false);
|
||||||
|
setTwoFactorCode("");
|
||||||
|
setPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header>
|
<header>
|
||||||
@ -112,38 +184,119 @@ const LoginPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="login-form">
|
<div className="login-form">
|
||||||
<form id="loginForm" onSubmit={handleSubmit}>
|
{!requires2FA ? (
|
||||||
<div className="form-group">
|
<form id="loginForm" onSubmit={handleSubmit}>
|
||||||
<label htmlFor="username">Логин:</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="username">Логин:</label>
|
||||||
type="text"
|
<input
|
||||||
id="username"
|
type="text"
|
||||||
name="username"
|
id="username"
|
||||||
value={username}
|
name="username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
value={username}
|
||||||
required
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Введите ваш логин"
|
required
|
||||||
/>
|
placeholder="Введите ваш логин"
|
||||||
</div>
|
disabled={isLoading}
|
||||||
<div className="form-group">
|
/>
|
||||||
<label htmlFor="password">Пароль:</label>
|
</div>
|
||||||
<input
|
<div className="form-group">
|
||||||
type="password"
|
<label htmlFor="password">Пароль:</label>
|
||||||
id="password"
|
<input
|
||||||
name="password"
|
type="password"
|
||||||
value={password}
|
id="password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
name="password"
|
||||||
required
|
value={password}
|
||||||
placeholder="Введите пароль"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
required
|
||||||
</div>
|
placeholder="Введите пароль"
|
||||||
<button type="submit" className="btnSave" disabled={isLoading}>
|
disabled={isLoading}
|
||||||
{isLoading ? "Вход..." : "Войти"}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</form>
|
<button type="submit" className="btnSave" disabled={isLoading}>
|
||||||
<p className="auth-link">
|
{isLoading ? "Вход..." : "Войти"}
|
||||||
Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link>
|
</button>
|
||||||
</p>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form id="2faForm" onSubmit={handle2FAVerify}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "20px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:shield-lock"
|
||||||
|
style={{ fontSize: "24px", color: "var(--accent-color)" }}
|
||||||
|
/>
|
||||||
|
<h3 style={{ margin: 0 }}>Двухфакторная аутентификация</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}>
|
||||||
|
Введите 6-значный код из приложения-аутентификатора или
|
||||||
|
резервный код:
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="twoFactorCode">Код:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="twoFactorCode"
|
||||||
|
name="twoFactorCode"
|
||||||
|
value={twoFactorCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Разрешаем цифры и заглавные буквы (для резервных кодов)
|
||||||
|
const value = e.target.value
|
||||||
|
.toUpperCase() // Преобразуем в заглавные буквы
|
||||||
|
.replace(/[^A-Z0-9]/g, "") // Удаляем все кроме букв и цифр
|
||||||
|
.slice(0, 10); // Ограничиваем длину
|
||||||
|
setTwoFactorCode(value);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
placeholder="000000 или резервный код"
|
||||||
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "20px",
|
||||||
|
letterSpacing: "4px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "10px" }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btnSave"
|
||||||
|
disabled={isLoading || !twoFactorCode.trim()}
|
||||||
|
>
|
||||||
|
{isLoading ? "Проверка..." : "Продолжить"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{!requires2FA && (
|
||||||
|
<p className="auth-link">
|
||||||
|
Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useNotification } from "../hooks/useNotification";
|
|||||||
import { Modal } from "../components/common/Modal";
|
import { Modal } from "../components/common/Modal";
|
||||||
import { ThemeToggle } from "../components/common/ThemeToggle";
|
import { ThemeToggle } from "../components/common/ThemeToggle";
|
||||||
import { dbManager } from "../utils/indexedDB";
|
import { dbManager } from "../utils/indexedDB";
|
||||||
|
import { TwoFactorSetup } from "../components/twoFactor/TwoFactorSetup";
|
||||||
|
|
||||||
const ProfilePage: React.FC = () => {
|
const ProfilePage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -33,12 +34,34 @@ const ProfilePage: React.FC = () => {
|
|||||||
const [deletePassword, setDeletePassword] = useState("");
|
const [deletePassword, setDeletePassword] = useState("");
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// 2FA settings
|
||||||
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||||
|
const [isLoading2FA, setIsLoading2FA] = useState(false);
|
||||||
|
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
load2FAStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const load2FAStatus = async () => {
|
||||||
|
setIsLoading2FA(true);
|
||||||
|
try {
|
||||||
|
const status = await userApi.get2FAStatus();
|
||||||
|
setTwoFactorEnabled(status.twoFactorEnabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки статуса 2FA:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading2FA(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle2FAStatusChange = async () => {
|
||||||
|
await load2FAStatus();
|
||||||
|
await loadProfile();
|
||||||
|
};
|
||||||
|
|
||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const userData = await userApi.getProfile();
|
const userData = await userApi.getProfile();
|
||||||
@ -395,6 +418,18 @@ const ProfilePage: React.FC = () => {
|
|||||||
|
|
||||||
<hr className="separator" />
|
<hr className="separator" />
|
||||||
|
|
||||||
|
<h3>Безопасность</h3>
|
||||||
|
{isLoading2FA ? (
|
||||||
|
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>
|
||||||
|
) : (
|
||||||
|
<TwoFactorSetup
|
||||||
|
twoFactorEnabled={twoFactorEnabled}
|
||||||
|
onStatusChange={handle2FAStatusChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr className="separator" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-danger"
|
className="btn-danger"
|
||||||
onClick={() => setIsDeleteModalOpen(true)}
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
|||||||
@ -6,12 +6,14 @@ export interface User {
|
|||||||
show_edit_date?: number;
|
show_edit_date?: number;
|
||||||
colored_icons?: number;
|
colored_icons?: number;
|
||||||
floating_toolbar_enabled?: number;
|
floating_toolbar_enabled?: number;
|
||||||
|
two_factor_enabled?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
requires2FA?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
@ -20,3 +22,31 @@ export interface AiSettings {
|
|||||||
openai_model: string;
|
openai_model: string;
|
||||||
ai_enabled: 0 | 1;
|
ai_enabled: 0 | 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorSetup {
|
||||||
|
success: boolean;
|
||||||
|
secret: string;
|
||||||
|
qrCode: string;
|
||||||
|
otpauthUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorStatus {
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorEnableResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
backupCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorDisableResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorVerifyResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
backupCodeUsed?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user