feat: 新增两步验证功能

- 新增 MFA 配置页面和相关组件- 在登录流程中集成两步验证逻辑
- 添加后端接口支持 MFA 配置和验证
This commit is contained in:
jmal 2025-07-12 19:02:34 +08:00
parent ac21dab65f
commit 891eb296b9
9 changed files with 288 additions and 23 deletions

View File

@ -1,6 +1,26 @@
import request from '@/utils/request'
export default {
setupMfaConfig() {
return request({
url: '/mfa/setup',
method: 'post'
})
},
enableMfaConfig(data) {
return request({
url: '/mfa/enable',
method: 'post',
data
})
},
disableMfaConfig(data) {
return request({
url: '/mfa/disable',
method: 'post',
data
})
},
getPreviewConfig(params) {
return request({
url: '/cloud/setting/preview/config',

View File

@ -9,6 +9,14 @@ export function login(data) {
})
}
export function verifyTotp(data) {
return request({
url: 'public/verify-totp',
method: 'post',
data
})
}
/**
* 加载ldap配置
* @returns {*}

View File

@ -25,6 +25,8 @@ export default {
login: {
username: 'Username',
password: 'Password',
verify: 'Verify',
mfaCode: 'MFA Verification Code',
login: 'Login',
logout: 'Logout',
create: 'Create',
@ -69,6 +71,7 @@ export default {
previewConfig: 'Preview Config',
ocrConfig: 'OCR Config',
ldapAuth: 'LDAP Auth',
mfaAuth: 'Two-Factor Authentication',
startSync: 'Start scanning? ',
confirmDeleteMenuAndRole: 'This operation will update and reset the system\'s default menus and roles (the menus and roles you have added yourself will not be affected). Are you sure?',
resetSuccessfully: 'Reset successfully',

View File

@ -25,6 +25,8 @@ export default {
login: {
username: '用户名',
password: '密码',
verify: '验证',
mfaCode: 'MFA 验证码',
login: '登录',
logout: '登出',
create: '创建',
@ -69,6 +71,7 @@ export default {
previewConfig: '预览配置',
ocrConfig: 'OCR配置',
ldapAuth: 'LDAP认证',
mfaAuth: '两步验证',
startSync: '是否开始扫描? ',
confirmDeleteMenuAndRole: '此操作会更新重置系统默认的菜单和角色(您自己添加的菜单和角色将不受影响), 确定吗? ',
resetSuccessfully: '重置成功',

View File

@ -1,4 +1,4 @@
import { login, logout, getInfo } from '@/api/user'
import { login, logout, getInfo, verifyTotp } from '@/api/user'
import fileApi from '@/api/file-api'
import menuApi from '@/api/menu'
import { setLogo } from '@/utils/logo'
@ -119,9 +119,29 @@ const actions = {
const { username, password, rememberMe } = userInfo
return new Promise((resolve, reject) => {
login({ username: username.trim(), password: password, rememberMe: rememberMe }).then(response => {
const { data } = response
if (data.mfaRequired) {
resolve(data)
} else {
commit('SET_TOKEN', data['jmal-token'])
commit('SET_NAME', username.trim())
commit('SET_USERID', data.userId)
resolve()
}
}).catch(error => {
reject(error)
})
})
},
verifyMfaCode({ commit }, loginForm) {
const { username, password, rememberMe, mfaCode, mfaToken } = loginForm
return new Promise((resolve, reject) => {
verifyTotp({ username: username.trim(), password: password, rememberMe: rememberMe, mfaCode: mfaCode, mfaToken: mfaToken }).then(response => {
const { data } = response
commit('SET_TOKEN', data['jmal-token'])
commit('SET_NAME', username.trim())
commit('SET_USERID', data.userId)
resolve()
}).catch(error => {
@ -129,6 +149,7 @@ const actions = {
})
})
},
// 验证提取码
validShareCode({commit}, {shareId, shareCode}) {
return new Promise((resolve, reject) => {

View File

@ -21,7 +21,7 @@
<h3 v-if="initialize" class="title">{{ $t('login.createAdmin') }}</h3>
</div>
<el-form-item prop="username">
<el-form-item v-show="!mfaRequired" prop="username">
<span class="svg-container">
<svg-icon icon-class="user" />
</span>
@ -36,7 +36,7 @@
/>
</el-form-item>
<el-form-item prop="password">
<el-form-item v-show="!mfaRequired" prop="password">
<span class="svg-container">
<svg-icon icon-class="password" />
</span>
@ -56,7 +56,19 @@
</span>
</el-form-item>
<el-form-item v-if="!initialize" class="remember">
<el-form-item v-show="mfaRequired" prop="mfaCode">
<el-input
ref="mfaCode"
v-model="loginForm.mfaCode"
:placeholder="$t('login.mfaCode')"
type="text"
auto-complete="off"
@keyup.enter.native="verifyMfaCode"
@input="mfaCodeInput"
/>
</el-form-item>
<el-form-item v-if="!initialize && !mfaRequired" class="remember">
<!--<el-switch v-model="loginForm.rememberMe"></el-switch>-->
<el-checkbox :label="$t('login.rememberMe')" v-model="loginForm.rememberMe"></el-checkbox>
</el-form-item>
@ -80,7 +92,8 @@
<svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" />
</span>
</el-form-item>
<el-button round :loading="loading" type="primary" style="width:100%;margin: 30px 0;" @click.native.prevent="handleLogin">{{initialize?$t('login.create'):$t('login.login')}}</el-button>
<el-button v-if="!mfaRequired" round :loading="loading" type="primary" style="width:100%;margin: 30px 0;" @click.native.prevent="handleLogin">{{initialize?$t('login.create'):$t('login.login')}}</el-button>
<el-button v-if="mfaRequired" round :loading="verifyMfaCodeLoading" type="primary" style="width:100%;margin: 30px 0;" @click.native.prevent="verifyMfaCode">{{$t('login.verify')}}</el-button>
</el-form>
</el-card>
</div>
@ -98,9 +111,9 @@
</template>
<script>
import { getWebstieRecord } from "@/api/setting-api";
import { getWebstieRecord } from "@/api/setting-api"
import { hasUser, initialization } from '@/api/user'
import Logo from "@/components/Logo";
import Logo from "@/components/Logo"
export default {
name: 'Login',
@ -142,7 +155,9 @@ export default {
username: this.$route.query.username || '',
password: this.$route.query.password || '',
rememberMe: false,
confirmPassword: ''
confirmPassword: '',
mfaCode: '',
mfaToken: ''
},
loginRules: {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
@ -150,9 +165,11 @@ export default {
confirmPassword: [{ required: true, trigger: 'blur', validator: confirmPassword }]
},
loading: false,
verifyMfaCodeLoading: false,
passwordType: 'password',
redirect: undefined,
initialize: false,
mfaRequired: false,
backgroundImg: {
background: "url("+require("@/assets/img/login-bg.png")+")",
width: '100%',
@ -233,13 +250,19 @@ export default {
}else{
// 登录
this.loading = true
this.$store.dispatch('user/login', this.loginForm).then(() => {
this.$store.dispatch('user/setMenuList').then((res) => {
this.$router.push({ path: this.redirect || '/' })
this.$store.dispatch('user/login', this.loginForm).then((data) => {
if (data && data.mfaRequired) {
this.mfaRequired = true
this.loginForm.mfaToken = data.mfaToken
this.loading = false
}).catch(() => {
this.loading = false
})
} else {
this.$store.dispatch('user/setMenuList').then(() => {
this.$router.push({ path: this.redirect || '/' })
this.loading = false
}).catch(() => {
this.loading = false
})
}
}).catch(() => {
this.loading = false
})
@ -249,6 +272,22 @@ export default {
}
})
},
mfaCodeInput(text) {
if (text.length === 6) {
this.verifyMfaCode()
}
},
verifyMfaCode() {
this.verifyMfaCodeLoading = true
this.$store.dispatch('user/verifyMfaCode', this.loginForm).then(() => {
this.$store.dispatch('user/setMenuList').then(() => {
this.$router.push({ path: this.redirect || '/' })
this.verifyMfaCodeLoading = false
}).catch(() => {
this.verifyMfaCodeLoading = false
})
})
},
validUsername() {
return true
}

View File

@ -89,6 +89,11 @@
<ldap-config></ldap-config>
</div>
</el-tab-pane>
<el-tab-pane :label="$t('app.mfaAuth')" name="7" class="setting-tab-panel">
<div v-if="activeName === '7'">
<mfa-config/>
</div>
</el-tab-pane>
<!-- <el-tab-pane :label="$t('common.lang')" name="7" class="setting-tab-panel">-->
<!-- <div v-if="activeName === '7'">-->
<!-- <language-config></language-config>-->
@ -108,6 +113,7 @@ import Logo from "@/components/Logo";
import getPageTitle from "@/utils/get-page-title";
import UploadImageInput from "@/components/input/UploadImageInput.vue";
import LanguageConfig from '@/views/setting/cloudSetting/languageConfig.vue'
import MfaConfig from '@/views/setting/cloudSetting/mfaConfig.vue'
import OcrConfig from '@/views/setting/cloudSetting/ocrConfig.vue'
import PreviewConfig from '@/views/setting/cloudSetting/previewConfig.vue'
import {mapGetters, mapState} from "vuex";
@ -118,7 +124,7 @@ import TranscodeConfig from "@/views/setting/cloudSetting/transcodeConfig.vue";
import OfficeConfig from "@/views/setting/cloudSetting/officeConfig.vue";
export default {
components: { OcrConfig, LanguageConfig, PreviewConfig, OfficeConfig, TranscodeConfig, LdapConfig, TaskProgress, UploadImageInput, Logo},
components: { MfaConfig, OcrConfig, LanguageConfig, PreviewConfig, OfficeConfig, TranscodeConfig, LdapConfig, TaskProgress, UploadImageInput, Logo},
data() {
return {
activeName: '1',

View File

@ -3,14 +3,14 @@
<el-form :rules="rules" ref="form" :model="ldapFormData" label-width="120px" size="small"
style="width: 450px" autocomplete="off">
<el-form-item label="功能状态" prop="enable">
<el-select v-model="ldapFormData.enable">
<el-option
v-for="item in ldapStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
<el-switch
v-model="ldapFormData.enable"
active-text="启用"
active-color="#13ce66"
inactive-color="#ff4949"
inactive-text="禁用">
</el-switch>
</el-form-item>
<el-form-item label="LDAP 服务器" prop="ldapServer">
<el-input placeholder="例如: ldap.test.com:389" v-model="ldapFormData.ldapServer"></el-input>

View File

@ -0,0 +1,165 @@
<template>
<div class="container">
<div class="form-instruction">
<p>两步验证是一个额外的验证步骤,可让您的登录体验更加安全; <span
style="color: #F56C6C">两步验证密码是基于当前时间生成,请确保服务器时间已同步</span></p>
</div>
<div>
<div class="description-container">当前状态:
<el-tag v-if="mfaSetupResponse.mfaEnable" type="success">开启</el-tag>
<el-tag v-else type="danger">关闭</el-tag>
</div>
</div>
<div>
<el-steps v-if="!mfaSetupResponse.mfaEnable" direction="vertical">
<el-step title="安装身份验证器App" status="process">
<template slot="description">
<div class="description-container">
<span class="step-instruction">安装身份验证器App,例如Google Authenticator、Microsoft Authenticator、Bitwarden、LastPass、Bitwarden、Authenticator等</span>
</div>
</template>
</el-step>
<el-step title="配置生成验证码" status="process">
<template slot="description">
<div class="description-container">
<div class="step-instruction">使用手机应用扫描以下二维码,获取 6 位验证码。</div>
<el-image style="width: 150px; height: 150px" :src="mfaSetupResponse.qrCodeImageUri" :fit="cover"
alt="二维码" />
<div class="step-instruction">密钥 :{{ mfaSetupResponse.secret }}
<svg-icon
class="copy-btn"
icon-class="menu-fuzhi"
@click="copySecret('.copy-btn')"
:data-clipboard-text="mfaSetupResponse.secret"
></svg-icon>
</div>
</div>
</template>
</el-step>
<el-step title="验证并开启" status="process">
<template slot="description">
<div class="description-container">
<div class="step-instruction">请输入身份验证器 App 生成的 6 位动态验证码,开启两步验证。</div>
<div class="step-instruction enable-mfa">
<el-input placeholder="6位动态验证码" size="small" class="code-input" v-model:value="enableMfaCode"></el-input>
<el-button size="small" type="primary" round :disabled="enableMfaCode.length !== 6" @click="enableMFA">开启 2FA</el-button>
</div>
</div>
</template>
</el-step>
</el-steps>
<div v-if="mfaSetupResponse.mfaEnable">
<div class="step-instruction enable-mfa">
<el-input placeholder="6位动态验证码" size="small" class="code-input" v-model:value="disableMfaCode"></el-input>
<el-button size="small" type="warning" round :disabled="disableMfaCode.length !== 6" @click="disableMFA">禁用 2FA</el-button>
</div>
</div>
</div>
</div>
</template>
<script>
import settingApi from '@/api/setting-api'
import Clipboard from 'clipboard'
export default {
name: 'mfaConfig',
props: {
data: {
type: Array,
default: [],
},
},
data() {
return {
verifyLoading: false,
mfaSetupResponse: {
mfaEnable: true,
qrCodeImageUri: '',
secret: '',
},
enableMfaCode: '',
disableMfaCode: ''
}
},
mounted() {
this.getMfaConfig()
},
methods: {
getMfaConfig() {
settingApi.setupMfaConfig().then(res => {
this.mfaSetupResponse = res.data
})
},
copySecret(className) {
let clipboard = new Clipboard(className)
clipboard.on('success', e => {
this.$message({ message: '复制成功', type: 'success', duration: 1000 })
// 释放内存
clipboard.destroy()
})
clipboard.on('error', e => {
// 不支持复制
this.$message({ message: '该浏览器不支持自动复制', type: 'warning', duration: 1000 })
clipboard.destroy()
})
},
enableMFA() {
this.verifyLoading = true
settingApi.enableMfaConfig({ code: this.enableMfaCode, secret: this.mfaSetupResponse.secret }).then(() => {
this.verifyLoading = false
this.$message.success('开启2FA成功')
this.getMfaConfig()
}).catch(() => {
this.verifyLoading = false
})
},
disableMFA() {
this.verifyLoading = true
settingApi.disableMfaConfig({ code: this.disableMfaCode }).then(() => {
this.verifyLoading = false
this.$message.success('禁用2FA成功')
this.getMfaConfig()
}).catch(() => {
this.verifyLoading = false
})
}
},
}
</script>
<style lang="scss" scoped>
@import "src/styles/setting";
.container {
padding: 0 !important;
}
.copy-btn {
font-size: 18px;
cursor: pointer;
}
.description-container {
padding: 10px 0 20px 0;
}
.enable-mfa {
padding-top: 10px;
display: flex;
justify-content: space-between;
width: 250px;
.code-input {
width: 150px;
}
}
.step-instruction {
font-size: 14px;
}
</style>