feat: 实现 2FA (TOTP) 的设置、验证和禁用流程
This commit is contained in:
Generated
+318
@@ -13,6 +13,12 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"pinia-plugin-persistedstate": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -839,6 +845,16 @@
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
|
||||
@@ -872,6 +888,16 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/speakeasy": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz",
|
||||
"integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sqlite3": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.11.tgz",
|
||||
@@ -1261,6 +1287,22 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -1340,6 +1382,13 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1658,6 +1707,16 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1711,6 +1770,38 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
@@ -1933,6 +2024,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -2018,6 +2119,13 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
@@ -2402,6 +2510,20 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
@@ -2545,6 +2667,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -3071,6 +3203,19 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -3801,6 +3946,35 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -3817,6 +3991,16 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -3833,6 +4017,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -3956,6 +4150,16 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
@@ -4066,6 +4270,24 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -4202,6 +4424,23 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -4616,6 +4855,19 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakeasy": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32.js": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
@@ -5531,6 +5783,13 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
@@ -5540,6 +5799,21 @@
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -5603,12 +5877,56 @@
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
||||
@@ -26,5 +26,11 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"pinia-plugin-persistedstate": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getDb } from '../database';
|
||||
import sqlite3, { RunResult } from 'sqlite3'; // 导入 RunResult 类型
|
||||
import speakeasy from 'speakeasy'; // 导入 speakeasy
|
||||
import qrcode from 'qrcode'; // 导入 qrcode
|
||||
|
||||
const db = getDb(); // 获取数据库实例
|
||||
|
||||
@@ -10,77 +12,192 @@ interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
hashed_password: string; // 数据库中存储的哈希密码
|
||||
two_factor_secret?: string | null; // 2FA 密钥 (数据库中可能为 NULL)
|
||||
// 其他可能的字段...
|
||||
}
|
||||
|
||||
// 扩展 SessionData 接口以包含临时的 2FA 密钥
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
tempTwoFactorSecret?: string; // 用于存储设置过程中的临时密钥
|
||||
requiresTwoFactor?: boolean; // 标记登录流程是否需要 2FA 验证
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理用户登录请求 (POST /api/v1/auth/login)
|
||||
*/
|
||||
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body; // 从请求体获取用户名和密码
|
||||
const { username, password } = req.body;
|
||||
|
||||
// 基础输入验证
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ message: '用户名和密码不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据用户名查询用户
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
// 从 users 表中选择需要的字段
|
||||
db.get('SELECT id, username, hashed_password FROM users WHERE username = ?', [username], (err, row: User) => {
|
||||
// 查询用户,包含 2FA 密钥
|
||||
db.get('SELECT id, username, hashed_password, two_factor_secret FROM users WHERE username = ?', [username], (err, row: User) => {
|
||||
if (err) {
|
||||
console.error('查询用户时出错:', err.message);
|
||||
// 返回通用错误信息,避免泄露数据库细节
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row); // 如果找到用户,则 resolve 用户对象;否则 resolve undefined
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
|
||||
// 如果未找到用户
|
||||
if (!user) {
|
||||
console.log(`登录尝试失败: 用户未找到 - ${username}`);
|
||||
// 返回 401 未授权状态码和通用错误信息
|
||||
res.status(401).json({ message: '无效的凭据。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 比较用户提交的密码和数据库中存储的哈希密码
|
||||
const isMatch = await bcrypt.compare(password, user.hashed_password);
|
||||
|
||||
// 如果密码不匹配
|
||||
if (!isMatch) {
|
||||
console.log(`登录尝试失败: 密码错误 - ${username}`);
|
||||
// 返回 401 未授权状态码和通用错误信息
|
||||
res.status(401).json({ message: '无效的凭据。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 认证成功 ---
|
||||
console.log(`登录成功: ${username}`);
|
||||
|
||||
// 在 session 中存储用户信息
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
|
||||
// 返回成功响应 (可以包含一些非敏感的用户信息)
|
||||
res.status(200).json({
|
||||
message: '登录成功。',
|
||||
user: { id: user.id, username: user.username } // 不返回密码哈希
|
||||
});
|
||||
// 检查是否启用了 2FA
|
||||
if (user.two_factor_secret) {
|
||||
console.log(`用户 ${username} 已启用 2FA,需要进行二次验证。`);
|
||||
// 不设置完整 session,只标记需要 2FA
|
||||
req.session.userId = user.id; // 临时存储 userId 以便 2FA 验证
|
||||
req.session.requiresTwoFactor = true;
|
||||
res.status(200).json({ message: '需要进行两步验证。', requiresTwoFactor: true });
|
||||
} else {
|
||||
// --- 认证成功 (未启用 2FA) ---
|
||||
console.log(`登录成功 (无 2FA): ${username}`);
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.requiresTwoFactor = false; // 明确标记不需要 2FA
|
||||
res.status(200).json({
|
||||
message: '登录成功。',
|
||||
user: { id: user.id, username: user.username }
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// 捕获数据库查询或其他异步操作中的错误
|
||||
console.error('登录时出错:', error);
|
||||
res.status(500).json({ message: '登录过程中发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
// 其他认证相关函数的占位符 (登出, 管理员设置等)
|
||||
// export const logout = ...
|
||||
// export const setupAdmin = ...
|
||||
/**
|
||||
* 获取当前用户的认证状态 (GET /api/v1/auth/status)
|
||||
*/
|
||||
export const getAuthStatus = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username;
|
||||
|
||||
if (!userId || !username || req.session.requiresTwoFactor) {
|
||||
// 如果 session 无效或 2FA 未完成,视为未认证
|
||||
res.status(401).json({ isAuthenticated: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询用户的 2FA 状态
|
||||
const user = await new Promise<{ two_factor_secret: string | null } | undefined>((resolve, reject) => {
|
||||
db.get('SELECT two_factor_secret FROM users WHERE id = ?', [userId], (err, row: { two_factor_secret: string | null }) => {
|
||||
if (err) {
|
||||
console.error(`查询用户 ${userId} 2FA 状态时出错:`, err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
|
||||
// 如果找不到用户(理论上不应发生),也视为未认证
|
||||
if (!user) {
|
||||
res.status(401).json({ isAuthenticated: false });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
id: userId,
|
||||
username: username,
|
||||
isTwoFactorEnabled: !!user.two_factor_secret // 返回 2FA 是否启用
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`获取用户 ${userId} 状态时发生内部错误:`, error);
|
||||
res.status(500).json({ message: '获取认证状态时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 处理登录时的 2FA 验证 (POST /api/v1/auth/login/2fa)
|
||||
*/
|
||||
export const verifyLogin2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const { token } = req.body;
|
||||
const userId = req.session.userId; // 获取之前临时存储的 userId
|
||||
|
||||
// 检查 session 状态
|
||||
if (!userId || !req.session.requiresTwoFactor) {
|
||||
res.status(400).json({ message: '无效的请求或会话状态。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({ message: '验证码不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户的 2FA 密钥
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
db.get('SELECT id, username, two_factor_secret FROM users WHERE id = ?', [userId], (err, row: User) => {
|
||||
if (err) {
|
||||
console.error(`查询用户 ${userId} 的 2FA 密钥时出错:`, err.message);
|
||||
return reject(new Error('数据库查询失败'));
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
|
||||
if (!user || !user.two_factor_secret) {
|
||||
console.error(`2FA 验证错误: 未找到用户 ${userId} 或未设置密钥。`);
|
||||
res.status(400).json({ message: '无法验证,请重新登录。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 TOTP 令牌
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: user.two_factor_secret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 1 // 允许前后一个时间窗口 (30秒) 的容错
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
console.log(`用户 ${user.username} 2FA 验证成功。`);
|
||||
// 验证成功,建立完整会话
|
||||
req.session.username = user.username;
|
||||
req.session.requiresTwoFactor = false; // 标记 2FA 已完成
|
||||
res.status(200).json({
|
||||
message: '登录成功。',
|
||||
user: { id: user.id, username: user.username }
|
||||
});
|
||||
} else {
|
||||
console.log(`用户 ${user.username} 2FA 验证失败: 验证码错误。`);
|
||||
res.status(401).json({ message: '验证码无效。' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`用户 ${userId} 2FA 验证时发生内部错误:`, error);
|
||||
res.status(500).json({ message: '两步验证过程中发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 处理修改密码请求 (PUT /api/v1/auth/password)
|
||||
@@ -89,9 +206,9 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.session.userId; // 从会话中获取用户 ID
|
||||
|
||||
// 检查用户是否登录
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: '用户未认证,请先登录。' });
|
||||
// 检查用户是否登录且 2FA 已完成 (如果需要)
|
||||
if (!userId || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成,请先登录。' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,8 +217,6 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
res.status(400).json({ message: '当前密码和新密码不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 可选:添加新密码复杂度要求
|
||||
if (newPassword.length < 8) {
|
||||
res.status(400).json({ message: '新密码长度至少需要 8 位。' });
|
||||
return;
|
||||
@@ -111,9 +226,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 1. 获取当前用户的哈希密码
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
db.get('SELECT id, hashed_password FROM users WHERE id = ?', [userId], (err, row: User) => {
|
||||
if (err) {
|
||||
@@ -125,13 +238,11 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// 理论上不应该发生,因为 userId 来自 session
|
||||
console.error(`修改密码错误: 未找到 ID 为 ${userId} 的用户。`);
|
||||
res.status(404).json({ message: '用户不存在。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 验证当前密码
|
||||
const isMatch = await bcrypt.compare(currentPassword, user.hashed_password);
|
||||
if (!isMatch) {
|
||||
console.log(`修改密码尝试失败: 当前密码错误 - 用户 ID ${userId}`);
|
||||
@@ -139,24 +250,20 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 哈希新密码
|
||||
const saltRounds = 10;
|
||||
const newHashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 4. 更新数据库中的密码
|
||||
await new Promise<void>((resolveUpdate, rejectUpdate) => {
|
||||
const stmt = db.prepare(
|
||||
'UPDATE users SET hashed_password = ?, updated_at = ? WHERE id = ?'
|
||||
);
|
||||
// 在回调函数中明确 this 的类型为 RunResult
|
||||
stmt.run(newHashedPassword, now, userId, function (this: RunResult, err: Error | null) {
|
||||
if (err) {
|
||||
console.error(`更新用户 ${userId} 密码时出错:`, err.message);
|
||||
return rejectUpdate(new Error('更新密码失败'));
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
// 理论上不应该发生
|
||||
console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`);
|
||||
return rejectUpdate(new Error('未找到要更新的用户'));
|
||||
}
|
||||
@@ -166,7 +273,6 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
// 5. 返回成功响应
|
||||
res.status(200).json({ message: '密码已成功修改。' });
|
||||
|
||||
} catch (error) {
|
||||
@@ -174,3 +280,194 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
res.status(500).json({ message: '修改密码过程中发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始 2FA 设置流程 (POST /api/v1/auth/2fa/setup)
|
||||
* 生成临时密钥和二维码
|
||||
*/
|
||||
export const setup2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const username = req.session.username; // 获取用户名用于 OTP URL
|
||||
|
||||
if (!userId || !username || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户是否已启用 2FA
|
||||
const existingSecret = await new Promise<string | null>((resolve, reject) => {
|
||||
db.get('SELECT two_factor_secret FROM users WHERE id = ?', [userId], (err, row: { two_factor_secret: string | null }) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.two_factor_secret : null);
|
||||
});
|
||||
});
|
||||
|
||||
if (existingSecret) {
|
||||
res.status(400).json({ message: '两步验证已启用。如需重置,请先禁用。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新的 2FA 密钥
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 20,
|
||||
name: `NexusTerminal (${username})` // 应用名称和用户名,显示在 Authenticator 应用中
|
||||
});
|
||||
|
||||
// 将临时密钥存储在 session 中,等待验证
|
||||
req.session.tempTwoFactorSecret = secret.base32;
|
||||
|
||||
// 生成 OTP Auth URL (用于生成二维码)
|
||||
if (!secret.otpauth_url) {
|
||||
throw new Error('无法生成 OTP Auth URL');
|
||||
}
|
||||
|
||||
// 生成二维码 Data URL
|
||||
qrcode.toDataURL(secret.otpauth_url, (err, data_url) => {
|
||||
if (err) {
|
||||
console.error('生成二维码时出错:', err);
|
||||
throw new Error('生成二维码失败');
|
||||
}
|
||||
// 返回密钥 (base32) 和二维码数据 URL 给前端
|
||||
res.json({
|
||||
secret: secret.base32, // 供用户手动输入
|
||||
qrCodeUrl: data_url // 用于显示二维码图片
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`用户 ${userId} 设置 2FA 时出错:`, error);
|
||||
res.status(500).json({ message: '设置两步验证时发生错误。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证并激活 2FA (POST /api/v1/auth/2fa/verify)
|
||||
*/
|
||||
export const verifyAndActivate2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const { token } = req.body;
|
||||
const userId = req.session.userId;
|
||||
const tempSecret = req.session.tempTwoFactorSecret; // 获取存储在 session 中的临时密钥
|
||||
|
||||
if (!userId || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tempSecret) {
|
||||
res.status(400).json({ message: '未找到临时密钥,请重新开始设置流程。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({ message: '验证码不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用临时密钥验证用户提交的令牌
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: tempSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 1 // 允许一定的时钟漂移
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
// 验证成功,将密钥永久存储到数据库
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await new Promise<void>((resolveUpdate, rejectUpdate) => {
|
||||
const stmt = db.prepare(
|
||||
'UPDATE users SET two_factor_secret = ?, updated_at = ? WHERE id = ?'
|
||||
);
|
||||
stmt.run(tempSecret, now, userId, function (this: RunResult, err: Error | null) {
|
||||
if (err) {
|
||||
console.error(`更新用户 ${userId} 的 2FA 密钥时出错:`, err.message);
|
||||
return rejectUpdate(new Error('激活两步验证失败'));
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
console.error(`激活 2FA 错误: 更新影响行数为 0 - 用户 ID ${userId}`);
|
||||
return rejectUpdate(new Error('未找到要更新的用户'));
|
||||
}
|
||||
console.log(`用户 ${userId} 已成功激活两步验证。`);
|
||||
resolveUpdate();
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
// 清除 session 中的临时密钥
|
||||
delete req.session.tempTwoFactorSecret;
|
||||
|
||||
res.status(200).json({ message: '两步验证已成功激活!' });
|
||||
} else {
|
||||
// 验证失败
|
||||
console.log(`用户 ${userId} 2FA 激活失败: 验证码错误。`);
|
||||
res.status(400).json({ message: '验证码无效。' });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`用户 ${userId} 验证并激活 2FA 时出错:`, error);
|
||||
res.status(500).json({ message: '验证两步验证码时发生错误。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 禁用 2FA (DELETE /api/v1/auth/2fa)
|
||||
*/
|
||||
export const disable2FA = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const { password } = req.body; // 需要验证当前密码以禁用
|
||||
|
||||
if (!userId || req.session.requiresTwoFactor) {
|
||||
res.status(401).json({ message: '用户未认证或认证未完成。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
res.status(400).json({ message: '需要提供当前密码才能禁用两步验证。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 验证当前密码
|
||||
const user = await new Promise<User | undefined>((resolve, reject) => {
|
||||
db.get('SELECT id, hashed_password FROM users WHERE id = ?', [userId], (err, row: User) => {
|
||||
if (err) reject(err); else resolve(row);
|
||||
});
|
||||
});
|
||||
if (!user) {
|
||||
res.status(404).json({ message: '用户不存在。' }); return;
|
||||
}
|
||||
const isMatch = await bcrypt.compare(password, user.hashed_password);
|
||||
if (!isMatch) {
|
||||
res.status(400).json({ message: '当前密码不正确。' }); return;
|
||||
}
|
||||
|
||||
// 2. 清除数据库中的 2FA 密钥
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await new Promise<void>((resolveUpdate, rejectUpdate) => {
|
||||
const stmt = db.prepare(
|
||||
'UPDATE users SET two_factor_secret = NULL, updated_at = ? WHERE id = ?'
|
||||
);
|
||||
stmt.run(now, userId, function (this: RunResult, err: Error | null) {
|
||||
if (err) {
|
||||
console.error(`清除用户 ${userId} 的 2FA 密钥时出错:`, err.message);
|
||||
return rejectUpdate(new Error('禁用两步验证失败'));
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
console.error(`禁用 2FA 错误: 更新影响行数为 0 - 用户 ID ${userId}`);
|
||||
return rejectUpdate(new Error('未找到要更新的用户'));
|
||||
}
|
||||
console.log(`用户 ${userId} 已成功禁用两步验证。`);
|
||||
resolveUpdate();
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
|
||||
res.status(200).json({ message: '两步验证已成功禁用。' });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`用户 ${userId} 禁用 2FA 时出错:`, error);
|
||||
res.status(500).json({ message: '禁用两步验证时发生错误。', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { login, changePassword } from './auth.controller'; // 导入 changePassword
|
||||
import { isAuthenticated } from './auth.middleware'; // 导入认证中间件
|
||||
import {
|
||||
login,
|
||||
verifyLogin2FA,
|
||||
changePassword,
|
||||
setup2FA,
|
||||
verifyAndActivate2FA,
|
||||
disable2FA,
|
||||
getAuthStatus // 导入获取状态的方法
|
||||
} from './auth.controller';
|
||||
import { isAuthenticated } from './auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,6 +18,23 @@ router.post('/login', login);
|
||||
// PUT /api/v1/auth/password - 修改密码接口 (需要认证)
|
||||
router.put('/password', isAuthenticated, changePassword);
|
||||
|
||||
// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session)
|
||||
router.post('/login/2fa', verifyLogin2FA);
|
||||
|
||||
// --- 2FA 管理接口 (都需要认证) ---
|
||||
// POST /api/v1/auth/2fa/setup - 开始 2FA 设置,生成密钥和二维码
|
||||
router.post('/2fa/setup', isAuthenticated, setup2FA);
|
||||
|
||||
// POST /api/v1/auth/2fa/verify - 验证设置时的 TOTP 码并激活
|
||||
router.post('/2fa/verify', isAuthenticated, verifyAndActivate2FA);
|
||||
|
||||
// DELETE /api/v1/auth/2fa - 禁用 2FA (需要验证当前密码,在控制器中处理)
|
||||
router.delete('/2fa', isAuthenticated, disable2FA);
|
||||
|
||||
// GET /api/v1/auth/status - 获取当前认证状态 (需要认证)
|
||||
router.get('/status', isAuthenticated, getAuthStatus);
|
||||
|
||||
|
||||
// 未来可以添加的其他认证相关路由
|
||||
// router.post('/logout', logout); // 登出
|
||||
// router.get('/status', getStatus); // 获取当前登录状态
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"password": "Password",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"error": "Login failed. Please check your username and password."
|
||||
"error": "Login failed. Please check your username and password.",
|
||||
"twoFactorPrompt": "Enter your two-factor authentication code:",
|
||||
"verifyButton": "Verify"
|
||||
},
|
||||
"connections": {
|
||||
"title": "Connection Management",
|
||||
@@ -291,9 +293,41 @@
|
||||
"passwordsDoNotMatch": "New password and confirmation do not match.",
|
||||
"generic": "Failed to change password. Please try again later."
|
||||
}
|
||||
},
|
||||
"twoFactor": {
|
||||
"title": "Two-Factor Authentication (TOTP)",
|
||||
"status": {
|
||||
"enabled": "Two-factor authentication is enabled.",
|
||||
"disabled": "Two-factor authentication is currently disabled."
|
||||
},
|
||||
"enable": {
|
||||
"button": "Enable Two-Factor Authentication"
|
||||
},
|
||||
"setup": {
|
||||
"scanQrCode": "Scan the QR code below with your authenticator app:",
|
||||
"orEnterSecret": "Or manually enter the secret key:",
|
||||
"enterCode": "Enter the 6-digit code from your authenticator app:",
|
||||
"verifyButton": "Verify & Activate"
|
||||
},
|
||||
"disable": {
|
||||
"button": "Disable Two-Factor Authentication",
|
||||
"passwordPrompt": "Enter your current password to confirm disabling:"
|
||||
},
|
||||
"success": {
|
||||
"activated": "Two-factor authentication activated successfully!",
|
||||
"disabled": "Two-factor authentication disabled successfully."
|
||||
},
|
||||
"error": {
|
||||
"setupFailed": "Failed to get two-factor setup information.",
|
||||
"codeRequired": "Please enter the verification code.",
|
||||
"verificationFailed": "Invalid or expired verification code.",
|
||||
"passwordRequiredForDisable": "Current password is required to disable.",
|
||||
"disableFailed": "Failed to disable two-factor authentication."
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"password": "密码",
|
||||
"loginButton": "登录",
|
||||
"loggingIn": "正在登录...",
|
||||
"error": "登录失败,请检查用户名或密码。"
|
||||
"error": "登录失败,请检查用户名或密码。",
|
||||
"twoFactorPrompt": "请输入两步验证码:",
|
||||
"verifyButton": "验证"
|
||||
},
|
||||
"connections": {
|
||||
"title": "连接管理",
|
||||
@@ -294,9 +296,41 @@
|
||||
"passwordsDoNotMatch": "新密码和确认密码不匹配。",
|
||||
"generic": "修改密码失败,请稍后重试。"
|
||||
}
|
||||
},
|
||||
"twoFactor": {
|
||||
"title": "两步验证 (TOTP)",
|
||||
"status": {
|
||||
"enabled": "两步验证已启用。",
|
||||
"disabled": "两步验证当前未启用。"
|
||||
},
|
||||
"enable": {
|
||||
"button": "启用两步验证"
|
||||
},
|
||||
"setup": {
|
||||
"scanQrCode": "请使用您的 Authenticator 应用扫描下方的二维码:",
|
||||
"orEnterSecret": "或者手动输入密钥:",
|
||||
"enterCode": "请输入应用生成的 6 位验证码:",
|
||||
"verifyButton": "验证并启用"
|
||||
},
|
||||
"disable": {
|
||||
"button": "禁用两步验证",
|
||||
"passwordPrompt": "请输入当前登录密码以确认禁用:"
|
||||
},
|
||||
"success": {
|
||||
"activated": "两步验证已成功激活!",
|
||||
"disabled": "两步验证已成功禁用。"
|
||||
},
|
||||
"error": {
|
||||
"setupFailed": "获取两步验证设置信息失败。",
|
||||
"codeRequired": "请输入验证码。",
|
||||
"verificationFailed": "验证码无效或已过期。",
|
||||
"passwordRequiredForDisable": "需要输入当前密码才能禁用。",
|
||||
"disableFailed": "禁用两步验证失败。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中..."
|
||||
"loading": "加载中...",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import router from '../router'; // 引入 router 用于重定向
|
||||
|
||||
// 用户信息接口 (不含敏感信息)
|
||||
// 扩展的用户信息接口,包含 2FA 状态
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
isTwoFactorEnabled?: boolean; // 后端 /status 接口会返回这个
|
||||
}
|
||||
|
||||
// Auth Store State 接口
|
||||
@@ -14,6 +15,7 @@ interface AuthState {
|
||||
user: UserInfo | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -22,6 +24,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loginRequires2FA: false, // 初始为不需要
|
||||
}),
|
||||
getters: {
|
||||
// 可以添加一些 getter,例如获取用户名
|
||||
@@ -32,32 +35,74 @@ export const useAuthStore = defineStore('auth', {
|
||||
async login(credentials: { username: string; password: string }) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.loginRequires2FA = false; // 重置 2FA 状态
|
||||
try {
|
||||
const response = await axios.post<{ message: string; user: UserInfo }>('/api/v1/auth/login', credentials);
|
||||
// 登录成功
|
||||
this.isAuthenticated = true;
|
||||
this.user = response.data.user;
|
||||
console.log('登录成功:', this.user);
|
||||
// 登录成功后重定向到连接管理页面 (或仪表盘)
|
||||
await router.push({ name: 'Connections' }); // 使用 await 确保导航完成
|
||||
return true;
|
||||
// 后端可能返回 user 或 requiresTwoFactor
|
||||
const response = await axios.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/api/v1/auth/login', credentials);
|
||||
|
||||
if (response.data.requiresTwoFactor) {
|
||||
// 需要 2FA 验证
|
||||
console.log('登录需要 2FA 验证');
|
||||
this.loginRequires2FA = true;
|
||||
// 不设置 isAuthenticated 和 user,等待 2FA 验证
|
||||
return { requiresTwoFactor: true }; // 返回特殊状态给调用者
|
||||
} else if (response.data.user) {
|
||||
// 登录成功 (无 2FA)
|
||||
this.isAuthenticated = true;
|
||||
this.user = response.data.user;
|
||||
console.log('登录成功 (无 2FA):', this.user);
|
||||
await router.push({ name: 'Connections' });
|
||||
return { success: true };
|
||||
} else {
|
||||
// 不应该发生,但作为防御性编程
|
||||
throw new Error('登录响应无效');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('登录失败:', err);
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.loginRequires2FA = false;
|
||||
this.error = err.response?.data?.message || err.message || '登录时发生未知错误。';
|
||||
return false;
|
||||
return { success: false, error: this.error };
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 登出 Action (占位符)
|
||||
async logout() {
|
||||
// 登录时的 2FA 验证 Action
|
||||
async verifyLogin2FA(token: string) {
|
||||
if (!this.loginRequires2FA) {
|
||||
throw new Error('当前登录流程不需要 2FA 验证。');
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
// TODO: 调用后端的登出 API (如果需要)
|
||||
const response = await axios.post<{ message: string; user: UserInfo }>('/api/v1/auth/login/2fa', { token });
|
||||
// 2FA 验证成功
|
||||
this.isAuthenticated = true;
|
||||
this.user = response.data.user;
|
||||
this.loginRequires2FA = false; // 重置状态
|
||||
console.log('2FA 验证成功,登录完成:', this.user);
|
||||
await router.push({ name: 'Connections' });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('2FA 验证失败:', err);
|
||||
// 不清除 isAuthenticated 或 user,因为用户可能只是输错了验证码
|
||||
this.error = err.response?.data?.message || err.message || '2FA 验证时发生未知错误。';
|
||||
return { success: false, error: this.error };
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 登出 Action
|
||||
async logout() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.loginRequires2FA = false; // 重置 2FA 状态
|
||||
try {
|
||||
// TODO: 调用后端的登出 API
|
||||
// await axios.post('/api/v1/auth/logout');
|
||||
|
||||
// 清除本地状态
|
||||
@@ -102,6 +147,33 @@ export const useAuthStore = defineStore('auth', {
|
||||
// }
|
||||
// }
|
||||
|
||||
// 新增:检查并更新认证状态 Action
|
||||
async checkAuthStatus() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await axios.get<{ isAuthenticated: boolean; user: UserInfo }>('/api/v1/auth/status');
|
||||
if (response.data.isAuthenticated && response.data.user) {
|
||||
this.isAuthenticated = true;
|
||||
this.user = response.data.user; // 更新用户信息,包含 isTwoFactorEnabled
|
||||
this.loginRequires2FA = false; // 确保重置
|
||||
console.log('认证状态已更新:', this.user);
|
||||
} else {
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.loginRequires2FA = false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 如果获取状态失败 (例如 session 过期),则认为未认证
|
||||
console.warn('检查认证状态失败:', error.response?.data?.message || error.message);
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.loginRequires2FA = false;
|
||||
// 可选:如果不是 401 错误,可以记录更详细的日志
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 修改密码 Action
|
||||
async changePassword(currentPassword: string, newPassword: string) {
|
||||
if (!this.isAuthenticated) {
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { reactive, ref } from 'vue'; // 导入 ref
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const { isLoading, error } = storeToRefs(authStore); // 获取加载和错误状态
|
||||
// 获取 loginRequires2FA 状态
|
||||
const { isLoading, error, loginRequires2FA } = storeToRefs(authStore);
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
||||
|
||||
// 处理登录提交
|
||||
const handleLogin = async () => {
|
||||
await authStore.login(credentials);
|
||||
// 登录成功会自动重定向 (在 store action 中处理)
|
||||
// 登录失败会在模板中显示错误信息
|
||||
// 处理登录或 2FA 验证提交
|
||||
const handleSubmit = async () => {
|
||||
if (loginRequires2FA.value) {
|
||||
// 如果需要 2FA,则调用 2FA 验证 action
|
||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
||||
} else {
|
||||
// 否则,调用常规登录 action
|
||||
await authStore.login(credentials);
|
||||
}
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -26,23 +34,31 @@ const handleLogin = async () => {
|
||||
<div class="login-view">
|
||||
<div class="login-form-container">
|
||||
<h2>{{ t('login.title') }}</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ t('login.username') }}:</label>
|
||||
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading" />
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- 常规登录字段 -->
|
||||
<div v-if="!loginRequires2FA">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ t('login.username') }}:</label>
|
||||
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ t('login.password') }}:</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ t('login.password') }}:</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
|
||||
|
||||
<!-- 2FA 验证码输入 -->
|
||||
<div v-if="loginRequires2FA" class="form-group">
|
||||
<label for="twoFactorToken">{{ t('login.twoFactorPrompt') }}</label>
|
||||
<input type="text" id="twoFactorToken" v-model="twoFactorToken" required :disabled="isLoading" pattern="\d{6}" title="请输入 6 位数字验证码" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<!-- 可以直接显示后端返回的错误,或者映射到特定的 i18n key -->
|
||||
{{ error }} <!-- 保持显示后端错误,或者 t('login.error') -->
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? t('login.loggingIn') : t('login.loginButton') }}
|
||||
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -17,59 +17,211 @@
|
||||
<label for="confirmPassword">{{ $t('settings.changePassword.confirmPassword') }}</label>
|
||||
<input type="password" id="confirmPassword" v-model="confirmPassword" required>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading">{{ loading ? $t('common.loading') : $t('settings.changePassword.submit') }}</button>
|
||||
<p v-if="message" :class="{ 'success-message': isSuccess, 'error-message': !isSuccess }">{{ message }}</p>
|
||||
<button type="submit" :disabled="changePasswordLoading">{{ changePasswordLoading ? $t('common.loading') : $t('settings.changePassword.submit') }}</button>
|
||||
<p v-if="changePasswordMessage" :class="{ 'success-message': changePasswordSuccess, 'error-message': !changePasswordSuccess }">{{ changePasswordMessage }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 其他设置项可以在这里添加 -->
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>{{ $t('settings.twoFactor.title') }}</h2>
|
||||
|
||||
<!-- 如果 2FA 已启用 -->
|
||||
<div v-if="twoFactorEnabled">
|
||||
<p class="success-message">{{ $t('settings.twoFactor.status.enabled') }}</p>
|
||||
<form @submit.prevent="handleDisable2FA">
|
||||
<div class="form-group">
|
||||
<label for="disablePassword">{{ $t('settings.twoFactor.disable.passwordPrompt') }}</label>
|
||||
<input type="password" id="disablePassword" v-model="disablePassword" required>
|
||||
</div>
|
||||
<button type="submit" :disabled="twoFactorLoading">{{ twoFactorLoading ? $t('common.loading') : $t('settings.twoFactor.disable.button') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 如果 2FA 未启用 -->
|
||||
<div v-else>
|
||||
<p>{{ $t('settings.twoFactor.status.disabled') }}</p>
|
||||
<!-- 如果不在设置流程中,显示启用按钮 -->
|
||||
<button v-if="!isSettingUp2FA" @click="handleSetup2FA" :disabled="twoFactorLoading">
|
||||
{{ twoFactorLoading ? $t('common.loading') : $t('settings.twoFactor.enable.button') }}
|
||||
</button>
|
||||
|
||||
<!-- 如果正在设置中 -->
|
||||
<div v-if="isSettingUp2FA && setupData">
|
||||
<p>{{ $t('settings.twoFactor.setup.scanQrCode') }}</p>
|
||||
<img :src="setupData.qrCodeUrl" alt="QR Code">
|
||||
<p>{{ $t('settings.twoFactor.setup.orEnterSecret') }} <code>{{ setupData.secret }}</code></p>
|
||||
<form @submit.prevent="handleVerifyAndActivate2FA">
|
||||
<div class="form-group">
|
||||
<label for="verificationCode">{{ $t('settings.twoFactor.setup.enterCode') }}</label>
|
||||
<input type="text" id="verificationCode" v-model="verificationCode" required pattern="\d{6}" title="请输入 6 位数字验证码">
|
||||
</div>
|
||||
<button type="submit" :disabled="twoFactorLoading">{{ twoFactorLoading ? $t('common.loading') : $t('settings.twoFactor.setup.verifyButton') }}</button>
|
||||
<button type="button" @click="cancelSetup" :disabled="twoFactorLoading" style="margin-left: 10px;">{{ $t('common.cancel') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示 2FA 操作的消息 -->
|
||||
<p v-if="twoFactorMessage" :class="{ 'success-message': twoFactorSuccess, 'error-message': !twoFactorSuccess }">{{ twoFactorMessage }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue'; // 导入 onMounted 和 computed
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios'; // 需要 axios 来调用 API
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// --- 修改密码状态 ---
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const isSuccess = ref(false);
|
||||
const changePasswordLoading = ref(false);
|
||||
const changePasswordMessage = ref('');
|
||||
const changePasswordSuccess = ref(false);
|
||||
|
||||
// --- 2FA 状态 ---
|
||||
const twoFactorEnabled = ref(false); // 用户当前的 2FA 状态
|
||||
const twoFactorLoading = ref(false);
|
||||
const twoFactorMessage = ref('');
|
||||
const twoFactorSuccess = ref(false);
|
||||
const setupData = ref<{ secret: string; qrCodeUrl: string } | null>(null); // 存储设置密钥和二维码
|
||||
const verificationCode = ref(''); // 用户输入的验证码
|
||||
const disablePassword = ref(''); // 禁用时需要输入的密码
|
||||
|
||||
// 计算属性判断当前是否处于 2FA 设置流程中
|
||||
const isSettingUp2FA = computed(() => setupData.value !== null);
|
||||
|
||||
// 获取当前用户的 2FA 状态 (理想情况下后端应提供接口,这里暂时假设从 authStore 或其他地方获取)
|
||||
const checkTwoFactorStatus = async () => {
|
||||
// 调用 store action 获取最新状态
|
||||
await authStore.checkAuthStatus();
|
||||
// 从 store 更新本地状态
|
||||
twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false;
|
||||
};
|
||||
|
||||
onMounted(async () => { // 使 onMounted 异步
|
||||
await checkTwoFactorStatus(); // 等待状态检查完成
|
||||
});
|
||||
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
message.value = ''; // 清除之前的消息
|
||||
isSuccess.value = false;
|
||||
changePasswordMessage.value = ''; // 清除之前的消息
|
||||
changePasswordSuccess.value = false;
|
||||
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
message.value = t('settings.changePassword.error.passwordsDoNotMatch');
|
||||
changePasswordMessage.value = t('settings.changePassword.error.passwordsDoNotMatch');
|
||||
return;
|
||||
}
|
||||
|
||||
// 可选:添加前端密码复杂度校验
|
||||
// 可选:添加前端密码复杂度校验
|
||||
|
||||
loading.value = true;
|
||||
changePasswordLoading.value = true;
|
||||
try {
|
||||
await authStore.changePassword(currentPassword.value, newPassword.value);
|
||||
message.value = t('settings.changePassword.success');
|
||||
isSuccess.value = true;
|
||||
changePasswordMessage.value = t('settings.changePassword.success');
|
||||
changePasswordSuccess.value = true;
|
||||
// 清空表单
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
} catch (error: any) {
|
||||
console.error('修改密码失败:', error);
|
||||
message.value = error.message || t('settings.changePassword.error.generic');
|
||||
isSuccess.value = false;
|
||||
changePasswordMessage.value = error.message || t('settings.changePassword.error.generic');
|
||||
changePasswordSuccess.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
changePasswordLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 2FA 相关方法 ---
|
||||
|
||||
// 开始设置 2FA
|
||||
const handleSetup2FA = async () => {
|
||||
twoFactorMessage.value = '';
|
||||
twoFactorSuccess.value = false;
|
||||
twoFactorLoading.value = true;
|
||||
setupData.value = null; // 清除旧数据
|
||||
verificationCode.value = ''; // 清除验证码
|
||||
|
||||
try {
|
||||
const response = await axios.post<{ secret: string; qrCodeUrl: string }>('/api/v1/auth/2fa/setup');
|
||||
setupData.value = response.data;
|
||||
} catch (error: any) {
|
||||
console.error('开始设置 2FA 失败:', error);
|
||||
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.setupFailed');
|
||||
} finally {
|
||||
twoFactorLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证并激活 2FA
|
||||
const handleVerifyAndActivate2FA = async () => {
|
||||
if (!setupData.value || !verificationCode.value) {
|
||||
twoFactorMessage.value = t('settings.twoFactor.error.codeRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
twoFactorMessage.value = '';
|
||||
twoFactorSuccess.value = false;
|
||||
twoFactorLoading.value = true;
|
||||
|
||||
try {
|
||||
await axios.post('/api/v1/auth/2fa/verify', { token: verificationCode.value });
|
||||
twoFactorMessage.value = t('settings.twoFactor.success.activated');
|
||||
twoFactorSuccess.value = true;
|
||||
twoFactorEnabled.value = true; // 更新状态
|
||||
setupData.value = null; // 清除设置数据
|
||||
verificationCode.value = '';
|
||||
} catch (error: any) {
|
||||
console.error('验证并激活 2FA 失败:', error);
|
||||
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.verificationFailed');
|
||||
} finally {
|
||||
twoFactorLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 禁用 2FA
|
||||
const handleDisable2FA = async () => {
|
||||
if (!disablePassword.value) {
|
||||
twoFactorMessage.value = t('settings.twoFactor.error.passwordRequiredForDisable');
|
||||
return;
|
||||
}
|
||||
twoFactorMessage.value = '';
|
||||
twoFactorSuccess.value = false;
|
||||
twoFactorLoading.value = true;
|
||||
|
||||
try {
|
||||
await axios.delete('/api/v1/auth/2fa', { data: { password: disablePassword.value } }); // DELETE 请求体通过 data 发送
|
||||
twoFactorMessage.value = t('settings.twoFactor.success.disabled');
|
||||
twoFactorSuccess.value = true;
|
||||
twoFactorEnabled.value = false; // 更新状态
|
||||
disablePassword.value = ''; // 清空密码
|
||||
} catch (error: any) {
|
||||
console.error('禁用 2FA 失败:', error);
|
||||
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.disableFailed');
|
||||
} finally {
|
||||
twoFactorLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 取消设置流程
|
||||
const cancelSetup = () => {
|
||||
setupData.value = null;
|
||||
verificationCode.value = '';
|
||||
twoFactorMessage.value = '';
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -93,7 +245,8 @@ label {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
input[type="password"],
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
@@ -109,6 +262,25 @@ button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #eee;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #f1f1f1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
max-width: 200px; /* 限制二维码大小 */
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: green;
|
||||
margin-top: 10px;
|
||||
|
||||
Reference in New Issue
Block a user