Добавлена поддержка двухфакторной аутентификации (2FA) в систему. Реализованы API для настройки, включения, отключения и проверки 2FA, а также обновлены компоненты для обработки 2FA на страницах входа и профиля. Обновлены типы данных для поддержки новых функций и улучшено взаимодействие с пользователем через уведомления.

This commit is contained in:
Fovway 2025-11-08 14:44:16 +07:00
parent 83abe3f919
commit c4327d31d1
12 changed files with 1482 additions and 72 deletions

View File

@ -29,6 +29,8 @@
"multer": "^2.0.0-rc.4",
"node-fetch": "^3.3.2",
"pngjs": "^7.0.0",
"qrcode": "^1.5.3",
"speakeasy": "^2.0.0",
"sqlite3": "^5.1.7"
},
"devDependencies": {
@ -1199,11 +1201,25 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"optional": true,
"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/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@ -1248,6 +1264,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"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": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -1449,6 +1471,15 @@
"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": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -1490,6 +1521,17 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
@ -1580,6 +1622,24 @@
"@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": {
"version": "1.1.3",
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@ -1746,6 +1815,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/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@ -1778,8 +1853,7 @@
"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==",
"optional": true
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
@ -2018,6 +2092,19 @@
"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": {
"version": "4.0.10",
"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_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",
@ -2431,7 +2527,6 @@
"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==",
"optional": true,
"engines": {
"node": ">=8"
}
@ -2474,6 +2569,18 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -2989,6 +3096,33 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
@ -3004,6 +3138,15 @@
"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": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -3012,6 +3155,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-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@ -3121,6 +3273,32 @@
"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": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -3219,6 +3397,21 @@
"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": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@ -3332,8 +3525,7 @@
"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==",
"optional": true
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/setprototypeof": {
"version": "1.2.0",
@ -3549,6 +3741,18 @@
"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": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
@ -3612,7 +3816,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@ -3626,7 +3829,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -3863,6 +4065,12 @@
"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": {
"version": "1.1.5",
"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"
}
},
"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",
@ -3885,10 +4107,51 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"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"
}
}
}
}

View File

@ -35,6 +35,8 @@
"multer": "^2.0.0-rc.4",
"node-fetch": "^3.3.2",
"pngjs": "^7.0.0",
"qrcode": "^1.5.3",
"speakeasy": "^2.0.0",
"sqlite3": "^5.1.7"
},
"devDependencies": {

View File

@ -14,6 +14,8 @@ const multer = require("multer");
const fs = require("fs");
const https = require("https");
const http = require("http");
const speakeasy = require("speakeasy");
const QRCode = require("qrcode");
const { encrypt, decrypt } = require("./utils/encryption");
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 и добавляем их если нужно
@ -747,7 +813,23 @@ app.post("/api/login", async (req, res) => {
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.username = user.username;
req.session.authenticated = true;
@ -797,7 +879,7 @@ app.get("/api/user", requireApiAuth, (req, res) => {
}
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) => {
if (err) {
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) => {
// Получаем цвет пользователя для предотвращения 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
app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
const { text } = req.body;

View File

@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "/index.html",
"revision": "0.5s0rolua70o"
"revision": "0.dlpr89s9d7"
}], {
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
});

View File

@ -2,7 +2,7 @@ import axiosClient from "./axiosClient";
import { AuthResponse } from "../types/user";
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 });
return data;
},

View File

@ -40,6 +40,8 @@ axiosClient.interceptors.response.use(
"/register", // Страница регистрации
"/notes/archived/all", // Удаление всех архивных заметок
"/user/delete-account", // Удаление аккаунта
"/user/2fa/verify", // Проверка 2FA кода
"/user/2fa/disable", // Отключение 2FA
];
// URL, где 401 не должен обрабатываться как ошибка сессии

View File

@ -1,5 +1,13 @@
import axiosClient from "./axiosClient";
import { User, AiSettings } from "../types/user";
import {
User,
AiSettings,
TwoFactorSetup,
TwoFactorStatus,
TwoFactorEnableResponse,
TwoFactorDisableResponse,
TwoFactorVerifyResponse,
} from "../types/user";
export const userApi = {
getProfile: async (): Promise<User> => {
@ -51,4 +59,52 @@ export const userApi = {
const { data } = await axiosClient.put("/user/ai-settings", settings);
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;
},
};

View File

@ -7,7 +7,7 @@ interface ModalProps {
title: string;
message: string | ReactNode;
confirmText?: string;
cancelText?: string;
cancelText?: string | null;
confirmType?: "primary" | "danger";
}
@ -18,9 +18,15 @@ export const Modal: React.FC<ModalProps> = ({
title,
message,
confirmText = "OK",
cancelText = "Отмена",
cancelText,
confirmType = "primary",
}) => {
// Если cancelText не передан или пустая строка, скрываем кнопку отмены
// Если передан null или пустая строка, не показываем кнопку
// Если не передан вообще (undefined), используем дефолтное значение "Отмена"
const shouldShowCancel = cancelText !== null && cancelText !== "";
const displayCancelText = cancelText === undefined ? "Отмена" : cancelText;
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@ -54,9 +60,11 @@ export const Modal: React.FC<ModalProps> = ({
>
{confirmText}
</button>
<button className="btn-secondary" onClick={onClose}>
{cancelText}
</button>
{shouldShowCancel && (
<button className="btn-secondary" onClick={onClose}>
{displayCancelText}
</button>
)}
</div>
</div>
</div>

View 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>
);
};

View File

@ -3,6 +3,7 @@ import { useNavigate, useSearchParams, Link } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { setAuth } from "../store/slices/authSlice";
import { authApi } from "../api/authApi";
import { userApi } from "../api/userApi";
import { useNotification } from "../hooks/useNotification";
import { ThemeToggle } from "../components/common/ThemeToggle";
import { Icon } from "@iconify/react";
@ -11,7 +12,9 @@ import { dbManager } from "../utils/indexedDB";
const LoginPage: React.FC = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [twoFactorCode, setTwoFactorCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [requires2FA, setRequires2FA] = useState(false);
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { showNotification } = useNotification();
@ -48,53 +51,122 @@ const LoginPage: React.FC = () => {
console.log("Login response:", data);
if (data.success) {
// Получаем информацию о пользователе
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();
// Проверяем, требуется ли 2FA
if (data.requires2FA) {
setRequires2FA(true);
showNotification("Введите код двухфакторной аутентификации", "info");
setIsLoading(false);
return;
}
dispatch(
setAuth({
userId: newUserId,
username: authStatus.username!,
})
);
showNotification("Успешный вход!", "success");
navigate("/notes");
// Если 2FA не требуется, продолжаем как обычно
await completeLogin();
} else {
showNotification(data.error || "Ошибка входа", "error");
}
} catch (error: any) {
console.error("Login error details:", error);
console.error("Error response:", error.response);
console.error("Error message:", error.message);
console.error("Error response data:", error.response?.data);
let errorMsg = "Ошибка соединения с сервером";
if (error.response) {
// Сервер ответил, но с ошибкой
errorMsg =
error.response.data?.error || `Ошибка ${error.response.status}`;
// Сервер ответил, но с ошибкой (например, 401 для неверного пароля)
const responseData = error.response.data;
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) {
// Запрос был отправлен, но ответа нет
console.error("No response from server");
errorMsg =
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3000";
"Сервер не отвечает. Проверьте, запущен ли backend на порту 3001";
} else {
// Ошибка при настройке запроса
console.error("Request setup error:", error.message);
errorMsg = error.message || "Ошибка соединения с сервером";
}
console.log("Showing error notification:", errorMsg);
showNotification(errorMsg, "error");
} finally {
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 (
<div className="container">
<header>
@ -112,38 +184,119 @@ const LoginPage: React.FC = () => {
</div>
</header>
<div className="login-form">
<form id="loginForm" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Логин:</label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
placeholder="Введите ваш логин"
/>
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Введите пароль"
/>
</div>
<button type="submit" className="btnSave" disabled={isLoading}>
{isLoading ? "Вход..." : "Войти"}
</button>
</form>
<p className="auth-link">
Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link>
</p>
{!requires2FA ? (
<form id="loginForm" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Логин:</label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
placeholder="Введите ваш логин"
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Введите пароль"
disabled={isLoading}
/>
</div>
<button type="submit" className="btnSave" disabled={isLoading}>
{isLoading ? "Вход..." : "Войти"}
</button>
</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>
);

View File

@ -12,6 +12,7 @@ import { useNotification } from "../hooks/useNotification";
import { Modal } from "../components/common/Modal";
import { ThemeToggle } from "../components/common/ThemeToggle";
import { dbManager } from "../utils/indexedDB";
import { TwoFactorSetup } from "../components/twoFactor/TwoFactorSetup";
const ProfilePage: React.FC = () => {
const navigate = useNavigate();
@ -33,12 +34,34 @@ const ProfilePage: React.FC = () => {
const [deletePassword, setDeletePassword] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
// 2FA settings
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const [isLoading2FA, setIsLoading2FA] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
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 () => {
try {
const userData = await userApi.getProfile();
@ -395,6 +418,18 @@ const ProfilePage: React.FC = () => {
<hr className="separator" />
<h3>Безопасность</h3>
{isLoading2FA ? (
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>
) : (
<TwoFactorSetup
twoFactorEnabled={twoFactorEnabled}
onStatusChange={handle2FAStatusChange}
/>
)}
<hr className="separator" />
<button
className="btn-danger"
onClick={() => setIsDeleteModalOpen(true)}

View File

@ -6,12 +6,14 @@ export interface User {
show_edit_date?: number;
colored_icons?: number;
floating_toolbar_enabled?: number;
two_factor_enabled?: number;
}
export interface AuthResponse {
authenticated: boolean;
userId?: number;
username?: string;
requires2FA?: boolean;
}
export interface AiSettings {
@ -20,3 +22,31 @@ export interface AiSettings {
openai_model: string;
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;
}