From f46eab24483d595ec7de641d97a0756ad94fc712 Mon Sep 17 00:00:00 2001 From: Kerwin Date: Sun, 29 Oct 2023 18:53:41 +0800 Subject: [PATCH] feat: support change password & enable 2FA --- package.json | 1 + pnpm-lock.yaml | 42 +++++-- service/package.json | 1 + service/pnpm-lock.yaml | 26 ++-- service/src/index.ts | 130 ++++++++++++++++++- service/src/storage/model.ts | 1 + service/src/storage/mongo.ts | 15 +++ service/src/types.ts | 13 ++ src/api/index.ts | 40 +++++- src/components/common/Setting/Password.vue | 90 +++++++++++++ src/components/common/Setting/TwoFA.vue | 139 +++++++++++++++++++++ src/components/common/Setting/User.vue | 27 +++- src/components/common/Setting/index.vue | 16 +++ src/components/common/Setting/model.ts | 21 ++++ src/locales/en-US.ts | 8 ++ src/locales/ko-KR.ts | 8 ++ src/locales/zh-CN.ts | 8 ++ src/locales/zh-TW.ts | 8 ++ src/views/chat/layout/Permission.vue | 14 ++- 19 files changed, 580 insertions(+), 28 deletions(-) create mode 100644 src/components/common/Setting/Password.vue create mode 100644 src/components/common/Setting/TwoFA.vue diff --git a/package.json b/package.json index 5f55a7fe27..fab7ed56d8 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "markdown-it": "^13.0.1", "naive-ui": "^2.34.3", "pinia": "^2.0.33", + "qrcode.vue": "^3.4.1", "vue": "^3.2.47", "vue-chartjs": "^5.2.0", "vue-i18n": "^9.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02820bae42..56a33ff59f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ dependencies: pinia: specifier: ^2.0.33 version: 2.0.33(typescript@4.9.5)(vue@3.2.47) + qrcode.vue: + specifier: ^3.4.1 + version: 3.4.1(vue@3.2.47) vue: specifier: ^3.2.47 version: 3.2.47 @@ -116,7 +119,7 @@ devDependencies: version: 4.3.0 tailwindcss: specifier: ^3.2.7 - version: 3.2.7(postcss@8.4.21)(ts-node@10.9.1) + version: 3.2.7(postcss@8.4.21) typescript: specifier: ~4.9.5 version: 4.9.5 @@ -125,7 +128,7 @@ devDependencies: version: 4.2.0(@types/node@18.14.6)(less@4.1.3) vite-plugin-pwa: specifier: ^0.14.4 - version: 0.14.4(vite@4.2.0)(workbox-build@6.5.4)(workbox-window@6.5.4) + version: 0.14.4(vite@4.2.0) vue-tsc: specifier: ^1.2.0 version: 1.2.0(typescript@4.9.5) @@ -564,6 +567,7 @@ packages: /@babel/plugin-proposal-async-generator-functions@7.20.7(@babel/core@7.21.0): resolution: {integrity: sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -579,6 +583,7 @@ packages: /@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -592,6 +597,7 @@ packages: /@babel/plugin-proposal-class-static-block@7.21.0(@babel/core@7.21.0): resolution: {integrity: sha512-XP5G9MWNUskFuP30IfFSEFB0Z6HzLIUcjYM4bYOPHXl7eiJ9HFv8tWj6TXTN5QODiEhDZAeI4hLok2iHFFV4hw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-static-block instead. peerDependencies: '@babel/core': ^7.12.0 dependencies: @@ -606,6 +612,7 @@ packages: /@babel/plugin-proposal-dynamic-import@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-dynamic-import instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -617,6 +624,7 @@ packages: /@babel/plugin-proposal-export-namespace-from@7.18.9(@babel/core@7.21.0): resolution: {integrity: sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-export-namespace-from instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -628,6 +636,7 @@ packages: /@babel/plugin-proposal-json-strings@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-json-strings instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -639,6 +648,7 @@ packages: /@babel/plugin-proposal-logical-assignment-operators@7.20.7(@babel/core@7.21.0): resolution: {integrity: sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-logical-assignment-operators instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -650,6 +660,7 @@ packages: /@babel/plugin-proposal-nullish-coalescing-operator@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -661,6 +672,7 @@ packages: /@babel/plugin-proposal-numeric-separator@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -672,6 +684,7 @@ packages: /@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.21.0): resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -686,6 +699,7 @@ packages: /@babel/plugin-proposal-optional-catch-binding@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -697,6 +711,7 @@ packages: /@babel/plugin-proposal-optional-chaining@7.21.0(@babel/core@7.21.0): resolution: {integrity: sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -709,6 +724,7 @@ packages: /@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -722,6 +738,7 @@ packages: /@babel/plugin-proposal-private-property-in-object@7.21.0(@babel/core@7.21.0): resolution: {integrity: sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -737,6 +754,7 @@ packages: /@babel/plugin-proposal-unicode-property-regex@7.18.6(@babel/core@7.21.0): resolution: {integrity: sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==} engines: {node: '>=4'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-unicode-property-regex instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -5514,7 +5532,7 @@ packages: postcss: 8.4.21 dev: true - /postcss-load-config@3.1.4(postcss@8.4.21)(ts-node@10.9.1): + /postcss-load-config@3.1.4(postcss@8.4.21): resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} peerDependencies: @@ -5528,7 +5546,6 @@ packages: dependencies: lilconfig: 2.1.0 postcss: 8.4.21 - ts-node: 10.9.1(@types/node@18.14.6)(typescript@4.9.5) yaml: 1.10.2 dev: true @@ -5596,6 +5613,14 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} dev: true + /qrcode.vue@3.4.1(vue@3.2.47): + resolution: {integrity: sha512-wq/zHsifH4FJ1GXQi8/wNxD1KfQkckIpjK1KPTc/qwYU5/Bkd4me0w4xZSg6EXk6xLBkVDE0zxVagewv5EMAVA==} + peerDependencies: + vue: ^3.0.0 + dependencies: + vue: 3.2.47 + dev: false + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true @@ -6164,7 +6189,7 @@ packages: engines: {node: '>= 0.4'} dev: true - /tailwindcss@3.2.7(postcss@8.4.21)(ts-node@10.9.1): + /tailwindcss@3.2.7(postcss@8.4.21): resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} engines: {node: '>=12.13.0'} hasBin: true @@ -6188,7 +6213,7 @@ packages: postcss: 8.4.21 postcss-import: 14.1.0(postcss@8.4.21) postcss-js: 4.0.1(postcss@8.4.21) - postcss-load-config: 3.1.4(postcss@8.4.21)(ts-node@10.9.1) + postcss-load-config: 3.1.4(postcss@8.4.21) postcss-nested: 6.0.0(postcss@8.4.21) postcss-selector-parser: 6.0.11 postcss-value-parser: 4.2.0 @@ -6489,12 +6514,10 @@ packages: vue: 3.2.47 dev: false - /vite-plugin-pwa@0.14.4(vite@4.2.0)(workbox-build@6.5.4)(workbox-window@6.5.4): + /vite-plugin-pwa@0.14.4(vite@4.2.0): resolution: {integrity: sha512-M7Ct0so8OlouMkTWgXnl8W1xU95glITSKIe7qswZf1tniAstO2idElGCnsrTJ5NPNSx1XqfTCOUj8j94S6FD7Q==} peerDependencies: vite: ^3.1.0 || ^4.0.0 - workbox-build: ^6.5.4 - workbox-window: ^6.5.4 dependencies: '@rollup/plugin-replace': 5.0.2(rollup@3.18.0) debug: 4.3.4 @@ -6505,6 +6528,7 @@ packages: workbox-build: 6.5.4 workbox-window: 6.5.4 transitivePeerDependencies: + - '@types/babel__core' - supports-color dev: true diff --git a/service/package.json b/service/package.json index d2f0d19e68..a09c4b0892 100644 --- a/service/package.json +++ b/service/package.json @@ -49,6 +49,7 @@ "eslint": "^8.35.0", "jsonwebtoken": "^9.0.0", "rimraf": "^4.3.0", + "speakeasy": "^2.0.0", "tsup": "^6.6.3", "typescript": "^4.9.5" } diff --git a/service/pnpm-lock.yaml b/service/pnpm-lock.yaml index 2b00589043..91719c9d5e 100644 --- a/service/pnpm-lock.yaml +++ b/service/pnpm-lock.yaml @@ -45,7 +45,7 @@ dependencies: version: 6.9.1 request-ip: specifier: ^3.3.0 - version: registry.npmmirror.com/request-ip@3.3.0 + version: 3.3.0 socks-proxy-agent: specifier: ^7.0.0 version: 7.0.0 @@ -72,6 +72,9 @@ devDependencies: rimraf: specifier: ^4.3.0 version: 4.3.0 + speakeasy: + specifier: ^2.0.0 + version: 2.0.0 tsup: specifier: ^6.6.3 version: 6.6.3(typescript@4.9.5) @@ -858,6 +861,10 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /base32.js@0.0.1: + resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==} + dev: true + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false @@ -3242,6 +3249,10 @@ packages: jsesc: 0.5.0 dev: true + /request-ip@3.3.0: + resolution: {integrity: sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==, registry: https://registry.npm.taobao.org/} + dev: false + /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3476,6 +3487,13 @@ packages: /spdx-license-ids@3.0.12: resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} + /speakeasy@2.0.0: + resolution: {integrity: sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==} + engines: {node: '>= 0.10.0'} + dependencies: + base32.js: 0.0.1 + dev: true + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -3924,9 +3942,3 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: false - - registry.npmmirror.com/request-ip@3.3.0: - resolution: {integrity: sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/request-ip/-/request-ip-3.3.0.tgz} - name: request-ip - version: 3.3.0 - dev: false diff --git a/service/src/index.ts b/service/src/index.ts index 9c4b6c4f6b..502559dd8a 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -3,7 +3,8 @@ import jwt from 'jsonwebtoken' import * as dotenv from 'dotenv' import { ObjectId } from 'mongodb' import { textTokens } from 'gpt-token' -import type { RequestProps } from './types' +import speakeasy from 'speakeasy' +import { type RequestProps, TwoFAConfig } from './types' import type { ChatMessage } from './chatgpt' import { abortChatProcess, chatConfig, chatReplyProcess, containsSensitiveWords, initAuditService } from './chatgpt' import { auth, getUserId } from './middleware/auth' @@ -17,6 +18,7 @@ import { deleteAllChatRooms, deleteChat, deleteChatRoom, + disableUser2FA, existsChatRoom, getChat, getChatRoom, @@ -36,9 +38,11 @@ import { updateRoomPrompt, updateRoomUsingContext, updateUser, + updateUser2FA, updateUserChatModel, updateUserInfo, updateUserPassword, + updateUserPasswordWithVerifyOld, updateUserStatus, upsertKey, verifyUser, @@ -636,7 +640,7 @@ router.post('/session', async (req, res) => { router.post('/user-login', authLimiter, async (req, res) => { try { - const { username, password } = req.body as { username: string; password: string } + const { username, password, token } = req.body as { username: string; password: string; token?: string } if (!username || !password || !isEmail(username)) throw new Error('用户名或密码为空 | Username or password is empty') @@ -649,9 +653,24 @@ router.post('/user-login', authLimiter, async (req, res) => { throw new Error('请等待管理员开通 | Please wait for the admin to activate') if (user.status !== Status.Normal) throw new Error('账户状态异常 | Account status abnormal.') + if (user.secretKey) { + if (token) { + const verified = speakeasy.totp.verify({ + secret: user.secretKey, + encoding: 'base32', + token, + }) + if (!verified) + throw new Error('验证码错误 | Two-step verification code error') + } + else { + res.send({ status: 'Success', message: '需要两步验证 | Two-step verification required', data: { need2FA: true } }) + return + } + } const config = await getCacheConfig() - const token = jwt.sign({ + const jwtToken = jwt.sign({ name: user.name ? user.name : user.email, avatar: user.avatar, description: user.description, @@ -659,7 +678,7 @@ router.post('/user-login', authLimiter, async (req, res) => { root: user.roles.includes(UserRole.Admin), config: user.config, }, config.siteConfig.loginSalt.trim()) - res.send({ status: 'Success', message: '登录成功 | Login successfully', data: { token } }) + res.send({ status: 'Success', message: '登录成功 | Login successfully', data: { token: jwtToken } }) } catch (error) { res.send({ status: 'Fail', message: error.message, data: null }) @@ -779,6 +798,109 @@ router.post('/user-edit', rootAuth, async (req, res) => { } }) +router.post('/user-password', auth, async (req, res) => { + try { + let { oldPassword, newPassword, confirmPassword } = req.body as { oldPassword: string; newPassword: string; confirmPassword: string } + if (!oldPassword || !newPassword || !confirmPassword) + throw new Error('密码不能为空 | Password cannot be empty') + if (newPassword !== confirmPassword) + throw new Error('两次密码不一致 | The two passwords are inconsistent') + if (newPassword === oldPassword) + throw new Error('新密码不能与旧密码相同 | The new password cannot be the same as the old password') + if (newPassword.length < 6) + throw new Error('密码长度不能小于6位 | The password length cannot be less than 6 digits') + + const userId = req.headers.userId.toString() + oldPassword = md5(oldPassword) + newPassword = md5(newPassword) + const result = await updateUserPasswordWithVerifyOld(userId, oldPassword, newPassword) + if (result.matchedCount <= 0) + throw new Error('旧密码错误 | Old password error') + if (result.modifiedCount <= 0) + throw new Error('更新失败 | Update error') + res.send({ status: 'Success', message: '更新成功 | Update successfully' }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.get('/user-2fa', auth, async (req, res) => { + try { + const userId = req.headers.userId.toString() + const user = await getUserById(userId) + + const data = new TwoFAConfig() + if (user.secretKey) { + data.enaled = true + } + else { + const secret = speakeasy.generateSecret({ length: 20, name: `CHATGPT-WEB:${user.email}` }) + data.otpauthUrl = secret.otpauth_url + data.enaled = false + data.secretKey = secret.base32 + data.userName = user.email + } + res.send({ status: 'Success', message: '获取成功 | Get successfully', data }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/user-2fa', auth, async (req, res) => { + try { + const { secretKey, token } = req.body as { secretKey: string; token: string } + const userId = req.headers.userId.toString() + + const verified = speakeasy.totp.verify({ + secret: secretKey, + encoding: 'base32', + token, + }) + if (!verified) + throw new Error('验证失败 | Verification failed') + await updateUser2FA(userId, secretKey) + res.send({ status: 'Success', message: '开启成功 | Enable 2FA successfully' }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/user-disable-2fa', auth, async (req, res) => { + try { + const { token } = req.body as { token: string } + const userId = req.headers.userId.toString() + const user = await getUserById(userId) + if (!user || !user.secretKey) + throw new Error('未开启 2FA | 2FA not enabled') + const verified = speakeasy.totp.verify({ + secret: user.secretKey, + encoding: 'base32', + token, + }) + if (!verified) + throw new Error('验证失败 | Verification failed') + await disableUser2FA(userId) + res.send({ status: 'Success', message: '关闭 2FA 成功 | Disable 2FA successfully' }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/user-disable-2fa-admin', rootAuth, async (req, res) => { + try { + const { userId } = req.body as { userId: string } + await disableUser2FA(userId) + res.send({ status: 'Success', message: '关闭 2FA 成功 | Disable 2FA successfully' }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + router.post('/verify', authLimiter, async (req, res) => { try { const { token } = req.body as { token: string } diff --git a/service/src/storage/model.ts b/service/src/storage/model.ts index 19aca0bdc1..c892f72e0f 100644 --- a/service/src/storage/model.ts +++ b/service/src/storage/model.ts @@ -37,6 +37,7 @@ export class UserInfo { config?: UserConfig roles?: UserRole[] remark?: string + secretKey?: string // 2fa constructor(email: string, password: string) { this.name = email this.email = email diff --git a/service/src/storage/mongo.ts b/service/src/storage/mongo.ts index d3148c67b3..b94f32ed1b 100644 --- a/service/src/storage/mongo.ts +++ b/service/src/storage/mongo.ts @@ -222,11 +222,26 @@ export async function updateUserChatModel(userId: string, chatModel: CHATMODEL) , { $set: { 'config.chatModel': chatModel } }) } +export async function updateUser2FA(userId: string, secretKey: string) { + return userCol.updateOne({ _id: new ObjectId(userId) } + , { $set: { secretKey, updateTime: new Date().toLocaleString() } }) +} + +export async function disableUser2FA(userId: string) { + return userCol.updateOne({ _id: new ObjectId(userId) } + , { $set: { secretKey: null, updateTime: new Date().toLocaleString() } }) +} + export async function updateUserPassword(userId: string, password: string) { return userCol.updateOne({ _id: new ObjectId(userId) } , { $set: { password, updateTime: new Date().toLocaleString() } }) } +export async function updateUserPasswordWithVerifyOld(userId: string, oldPassword: string, newPassword: string) { + return userCol.updateOne({ _id: new ObjectId(userId), password: oldPassword } + , { $set: { password: newPassword, updateTime: new Date().toLocaleString() } }) +} + export async function getUser(email: string): Promise { email = email.toLowerCase() const userInfo = await userCol.findOne({ email }) as UserInfo diff --git a/service/src/types.ts b/service/src/types.ts index 67c34df623..1181db9965 100644 --- a/service/src/types.ts +++ b/service/src/types.ts @@ -55,3 +55,16 @@ export interface JWT { 'azp': string 'scope': string } + +export class TwoFAConfig { + enaled: boolean + userName: string + secretKey: string + otpauthUrl: string + constructor() { + this.enaled = false + this.userName = '' + this.secretKey = '' + this.otpauthUrl = '' + } +} diff --git a/src/api/index.ts b/src/api/index.ts index bd7c37782c..591c8ec599 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,6 @@ import type { AxiosProgressEvent, GenericAbortSignal } from 'axios' import { get, post } from '@/utils/request' -import type { AuditConfig, CHATMODEL, ConfigState, KeyConfig, MailConfig, SiteConfig, Status, UserInfo } from '@/components/common/Setting/model' +import type { AuditConfig, CHATMODEL, ConfigState, KeyConfig, MailConfig, SiteConfig, Status, UserInfo, UserPassword } from '@/components/common/Setting/model' import { useAuthStore, useSettingStore } from '@/store' export function fetchChatConfig() { @@ -81,10 +81,10 @@ export function fetchVerifyAdmin(token: string) { }) } -export function fetchLogin(username: string, password: string) { +export function fetchLogin(username: string, password: string, token?: string) { return post({ url: '/user-login', - data: { username, password }, + data: { username, password, token }, }) } @@ -130,6 +130,33 @@ export function fetchGetUsers(page: number, size: number) { }) } +export function fetchGetUser2FA() { + return get({ + url: '/user-2fa', + }) +} + +export function fetchVerifyUser2FA(secretKey: string, token: string) { + return post({ + url: '/user-2fa', + data: { secretKey, token }, + }) +} + +export function fetchDisableUser2FA(token: string) { + return post({ + url: '/user-disable-2fa', + data: { token }, + }) +} + +export function fetchDisableUser2FAByAdmin(userId: string) { + return post({ + url: '/user-disable-2fa-admin', + data: { userId }, + }) +} + export function fetchUpdateUserStatus(userId: string, status: Status) { return post({ url: '/user-status', @@ -144,6 +171,13 @@ export function fetchUpdateUser(userInfo: UserInfo) { }) } +export function fetchUpdateUserPassword(pwd: UserPassword) { + return post({ + url: '/user-password', + data: pwd, + }) +} + export function fetchGetChatRooms() { return get({ url: '/chatrooms', diff --git a/src/components/common/Setting/Password.vue b/src/components/common/Setting/Password.vue new file mode 100644 index 0000000000..99dd2e241d --- /dev/null +++ b/src/components/common/Setting/Password.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/common/Setting/TwoFA.vue b/src/components/common/Setting/TwoFA.vue new file mode 100644 index 0000000000..ff8a5fa93d --- /dev/null +++ b/src/components/common/Setting/TwoFA.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/components/common/Setting/User.vue b/src/components/common/Setting/User.vue index 0d5ef87316..63fe816659 100644 --- a/src/components/common/Setting/User.vue +++ b/src/components/common/Setting/User.vue @@ -2,7 +2,7 @@ import { h, onMounted, reactive, ref } from 'vue' import { NButton, NDataTable, NInput, NModal, NSelect, NSpace, NTag, useDialog, useMessage } from 'naive-ui' import { Status, UserInfo, UserRole, userRoleOptions } from './model' -import { fetchGetUsers, fetchUpdateUser, fetchUpdateUserStatus } from '@/api' +import { fetchDisableUser2FAByAdmin, fetchGetUsers, fetchUpdateUser, fetchUpdateUserStatus } from '@/api' import { t } from '@/locales' import { useBasicLayout } from '@/hooks/useBasicLayout' @@ -113,6 +113,17 @@ const columns = [ { default: () => t('chat.verifiedUser') }, )) } + if (row.secretKey) { + actions.push(h( + NButton, + { + size: 'small', + type: 'warning', + onClick: () => handleDisable2FA(row._id), + }, + { default: () => t('chat.disable2FA') }, + )) + } return actions }, }, @@ -175,6 +186,20 @@ async function handleUpdateUserStatus(userId: string, status: Status) { } } +async function handleDisable2FA(userId: string) { + dialog.warning({ + title: t('chat.deleteUser'), + content: t('chat.deleteUserConfirm'), + positiveText: t('common.yes'), + negativeText: t('common.no'), + onPositiveClick: async () => { + const result = await fetchDisableUser2FAByAdmin(userId) + ms.success(result.message as string) + await handleGetUsers(pagination.page) + }, + }) +} + function handleNewUser() { userRef.value = new UserInfo([UserRole.User]) show.value = true diff --git a/src/components/common/Setting/index.vue b/src/components/common/Setting/index.vue index c97ec9ce23..41cb056610 100644 --- a/src/components/common/Setting/index.vue +++ b/src/components/common/Setting/index.vue @@ -10,6 +10,8 @@ import Mail from './Mail.vue' import Audit from './Audit.vue' import User from './User.vue' import Key from './Keys.vue' +import Password from './Password.vue' +import TwoFA from './TwoFA.vue' import { SvgIcon } from '@/components/common' import { useAuthStore, useUserStore } from '@/store' import { useBasicLayout } from '@/hooks/useBasicLayout' @@ -57,6 +59,20 @@ const show = computed({ + + + + + + + +