From 171baec8300128ab052d78db20e7dbda9bf764b5 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:49:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=202FA=20(TOTP)=20?= =?UTF-8?q?=E7=9A=84=E8=AE=BE=E7=BD=AE=E3=80=81=E9=AA=8C=E8=AF=81=E5=92=8C?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 318 ++++++++++++++++ package.json | 6 + packages/backend/src/auth/auth.controller.ts | 381 +++++++++++++++++-- packages/backend/src/auth/auth.routes.ts | 29 +- packages/frontend/src/locales/en.json | 38 +- packages/frontend/src/locales/zh.json | 38 +- packages/frontend/src/stores/auth.store.ts | 100 ++++- packages/frontend/src/views/LoginView.vue | 54 ++- packages/frontend/src/views/SettingsView.vue | 204 +++++++++- 9 files changed, 1071 insertions(+), 97 deletions(-) diff --git a/package-lock.json b/package-lock.json index fbb2163..ddb6412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index aa59b0f..8275777 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 29e140c..b4d5161 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -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 => { - 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((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 => { + 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 => { + 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((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 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 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 return; } - try { - // 1. 获取当前用户的哈希密码 const user = await new Promise((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 }); 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 return; } - // 3. 哈希新密码 const saltRounds = 10; const newHashedPassword = await bcrypt.hash(newPassword, saltRounds); const now = Math.floor(Date.now() / 1000); - // 4. 更新数据库中的密码 await new Promise((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 stmt.finalize(); }); - // 5. 返回成功响应 res.status(200).json({ message: '密码已成功修改。' }); } catch (error) { @@ -174,3 +280,194 @@ export const changePassword = async (req: Request, res: Response): Promise res.status(500).json({ message: '修改密码过程中发生内部服务器错误。' }); } }; + +/** + * 开始 2FA 设置流程 (POST /api/v1/auth/2fa/setup) + * 生成临时密钥和二维码 + */ +export const setup2FA = async (req: Request, res: Response): Promise => { + 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((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 => { + 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((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 => { + 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((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((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 }); + } +}; diff --git a/packages/backend/src/auth/auth.routes.ts b/packages/backend/src/auth/auth.routes.ts index 1717a29..08b120a 100644 --- a/packages/backend/src/auth/auth.routes.ts +++ b/packages/backend/src/auth/auth.routes.ts @@ -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); // 获取当前登录状态 diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 6e35b18..2f26ecd 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -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" } } diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index 34729ee..0858310 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -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": "取消" } } diff --git a/packages/frontend/src/stores/auth.store.ts b/packages/frontend/src/stores/auth.store.ts index 91335e5..eb9d87c 100644 --- a/packages/frontend/src/stores/auth.store.ts +++ b/packages/frontend/src/stores/auth.store.ts @@ -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'); // 清除本地状态 @@ -101,7 +146,34 @@ 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) { diff --git a/packages/frontend/src/views/LoginView.vue b/packages/frontend/src/views/LoginView.vue index c1ee25a..a916c66 100644 --- a/packages/frontend/src/views/LoginView.vue +++ b/packages/frontend/src/views/LoginView.vue @@ -1,24 +1,32 @@ @@ -26,23 +34,31 @@ const handleLogin = async () => {