feat: 新增两步验证功能
- 新增 MFA 配置页面和相关组件- 在登录流程中集成两步验证逻辑 - 添加后端接口支持 MFA 配置和验证
This commit is contained in:
parent
ac21dab65f
commit
891eb296b9
@ -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',
|
||||
|
||||
@ -9,6 +9,14 @@ export function login(data) {
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyTotp(data) {
|
||||
return request({
|
||||
url: 'public/verify-totp',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载ldap配置
|
||||
* @returns {*}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '重置成功',
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
165
src/views/setting/cloudSetting/mfaConfig.vue
Normal file
165
src/views/setting/cloudSetting/mfaConfig.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user