1.增加谷歌验证器
This commit is contained in:
14
web/src/api/backend/auth/admin.ts
Normal file
14
web/src/api/backend/auth/admin.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
export function resetTotp(id: number) {
|
||||
return createAxios(
|
||||
{
|
||||
url: '/admin/auth.Admin/resetTotp',
|
||||
method: 'post',
|
||||
data: { id },
|
||||
},
|
||||
{
|
||||
showSuccessMessage: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,35 @@ export function login(method: 'get' | 'post', params: object = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function totpVerify(params: object = {}) {
|
||||
return createAxios({
|
||||
url: url + 'totpVerify',
|
||||
data: params,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
export function totpBindInit(params: object = {}) {
|
||||
return createAxios({
|
||||
url: url + 'totpBindInit',
|
||||
data: params,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
export function totpBindConfirm(params: object = {}) {
|
||||
return createAxios(
|
||||
{
|
||||
url: url + 'totpBindConfirm',
|
||||
data: params,
|
||||
method: 'post',
|
||||
},
|
||||
{
|
||||
showSuccessMessage: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
const adminInfo = useAdminInfo()
|
||||
return createAxios({
|
||||
|
||||
@@ -10,4 +10,9 @@ export default {
|
||||
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
|
||||
'Personal signature': 'Personal Signature',
|
||||
'Administrator login': 'Administrator Login Name',
|
||||
'Google Authenticator': 'Google Authenticator',
|
||||
Bound: 'Bound',
|
||||
'Not bound': 'Not bound',
|
||||
'Reset authenticator': 'Reset authenticator',
|
||||
'Reset authenticator confirm': 'Reset this admin\'s Google Authenticator? They must re-bind on next login.',
|
||||
}
|
||||
|
||||
@@ -3,4 +3,14 @@ export default {
|
||||
'Please input a password': 'Please enter your password',
|
||||
'Hold session': 'Keep the session',
|
||||
'Sign in': 'Sign in',
|
||||
'Google Authenticator code': 'Google Authenticator code',
|
||||
'Please enter the 6-digit code': 'Please enter the 6-digit code',
|
||||
'Verify and sign in': 'Verify and sign in',
|
||||
'Bind Google Authenticator': 'Bind Google Authenticator',
|
||||
'Scan QR code with Google Authenticator': 'Scan the QR code with Google Authenticator',
|
||||
'Or enter secret manually': 'Or enter the secret manually',
|
||||
'Confirm bind': 'Confirm bind',
|
||||
'Back to login': 'Back to login',
|
||||
'Binding...': 'Binding...',
|
||||
'Verifying...': 'Verifying...',
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ export default {
|
||||
'Please leave blank if not modified': 'Please leave blank if you do not modify',
|
||||
'Save changes': 'Save changes',
|
||||
'Operation log': 'Operation log',
|
||||
'Google Authenticator': 'Google Authenticator',
|
||||
Bound: 'Bound',
|
||||
'Not bound': 'Not bound',
|
||||
}
|
||||
|
||||
@@ -10,4 +10,9 @@ export default {
|
||||
'Please leave blank if not modified': '不修改请留空',
|
||||
'Personal signature': '个性签名',
|
||||
'Administrator login': '管理员登录名',
|
||||
'Google Authenticator': '谷歌验证器',
|
||||
Bound: '已绑定',
|
||||
'Not bound': '未绑定',
|
||||
'Reset authenticator': '重置验证器',
|
||||
'Reset authenticator confirm': '确定要重置该管理员的谷歌验证器吗?重置后该管理员下次登录需重新绑定。',
|
||||
}
|
||||
|
||||
@@ -3,4 +3,14 @@ export default {
|
||||
'Please input a password': '请输入密码',
|
||||
'Hold session': '保持会话',
|
||||
'Sign in': '登录',
|
||||
'Google Authenticator code': '谷歌验证器验证码',
|
||||
'Please enter the 6-digit code': '请输入6位验证码',
|
||||
'Verify and sign in': '验证并登录',
|
||||
'Bind Google Authenticator': '绑定谷歌验证器',
|
||||
'Scan QR code with Google Authenticator': '请使用 Google Authenticator 扫描下方二维码',
|
||||
'Or enter secret manually': '或手动输入密钥',
|
||||
'Confirm bind': '确认绑定',
|
||||
'Back to login': '返回登录',
|
||||
'Binding...': '绑定中...',
|
||||
'Verifying...': '验证中...',
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ export default {
|
||||
'Please leave blank if not modified': '不修改请留空',
|
||||
'Save changes': '保存修改',
|
||||
'Operation log': '操作日志',
|
||||
'Google Authenticator': '谷歌验证器',
|
||||
Bound: '已绑定',
|
||||
'Not bound': '未绑定',
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import Table from '/@/components/table/index.vue'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { resetTotp } from '/@/api/backend/auth/admin'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -35,10 +36,41 @@ defineOptions({
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
|
||||
const optButtons = defaultOptButtons(['edit', 'delete'])
|
||||
optButtons[1].display = (row) => {
|
||||
return row.id != adminInfo.id
|
||||
}
|
||||
const optButtons: OptButton[] = [
|
||||
{
|
||||
render: 'confirmButton',
|
||||
name: 'resetTotp',
|
||||
title: 'auth.admin.Reset authenticator',
|
||||
text: '',
|
||||
type: 'warning',
|
||||
icon: 'fa fa-refresh',
|
||||
class: 'table-row-reset-totp',
|
||||
popconfirm: {
|
||||
confirmButtonText: t('Confirm'),
|
||||
cancelButtonText: t('Cancel'),
|
||||
confirmButtonType: 'warning',
|
||||
title: t('auth.admin.Reset authenticator confirm'),
|
||||
},
|
||||
disabledTip: false,
|
||||
display: (row: TableRow) => {
|
||||
return adminInfo.super && row.id != adminInfo.id && !!row.totp_bound
|
||||
},
|
||||
click: (row: TableRow) => {
|
||||
resetTotp(row.id).then(() => {
|
||||
baTable.onTableHeaderAction('refresh', {})
|
||||
})
|
||||
},
|
||||
},
|
||||
...defaultOptButtons(['edit', 'delete']).map((btn) => {
|
||||
if (btn.name === 'delete') {
|
||||
return {
|
||||
...btn,
|
||||
display: (row: TableRow) => row.id != adminInfo.id,
|
||||
}
|
||||
}
|
||||
return btn
|
||||
}),
|
||||
]
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/auth.Admin/'),
|
||||
@@ -52,6 +84,16 @@ const baTable = new baTableClass(
|
||||
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
|
||||
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{
|
||||
label: t('auth.admin.Google Authenticator'),
|
||||
prop: 'totp_bound',
|
||||
align: 'center',
|
||||
render: 'tag',
|
||||
custom: { true: 'success', false: 'info' },
|
||||
replaceValue: { true: t('auth.admin.Bound'), false: t('auth.admin.Not bound') },
|
||||
operator: false,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.Last login'),
|
||||
prop: 'last_login_time',
|
||||
@@ -73,7 +115,7 @@ const baTable = new baTableClass(
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: '100',
|
||||
width: '140',
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
|
||||
@@ -23,7 +23,15 @@
|
||||
<div class="form">
|
||||
<img class="profile-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
|
||||
<div class="content">
|
||||
<el-form @keyup.enter="onSubmitPre()" ref="formRef" :rules="rules" size="large" :model="form">
|
||||
<!-- 账号密码登录 -->
|
||||
<el-form
|
||||
v-if="state.step === 'login'"
|
||||
@keyup.enter="onSubmitPre()"
|
||||
ref="formRef"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
:model="form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
ref="usernameRef"
|
||||
@@ -64,6 +72,84 @@
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 谷歌验证器验证码 -->
|
||||
<el-form
|
||||
v-else-if="state.step === 'totp'"
|
||||
@keyup.enter="onTotpVerify()"
|
||||
ref="totpFormRef"
|
||||
:rules="totpRules"
|
||||
size="large"
|
||||
:model="totpForm"
|
||||
>
|
||||
<div class="step-title">{{ t('login.Google Authenticator code') }}</div>
|
||||
<div class="step-desc">{{ state.username }}</div>
|
||||
<el-form-item prop="code">
|
||||
<el-input
|
||||
v-model="totpForm.code"
|
||||
maxlength="6"
|
||||
:placeholder="t('login.Please enter the 6-digit code')"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon name="fa fa-shield" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
:loading="state.submitLoading"
|
||||
class="submit-button"
|
||||
round
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="onTotpVerify()"
|
||||
>
|
||||
{{ state.submitLoading ? t('login.Verifying...') : t('login.Verify and sign in') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-button class="back-button" link type="primary" @click="resetToLogin">{{ t('login.Back to login') }}</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- 绑定谷歌验证器 -->
|
||||
<div v-else-if="state.step === 'bind'" class="bind-panel">
|
||||
<div class="step-title">{{ t('login.Bind Google Authenticator') }}</div>
|
||||
<div class="step-desc">{{ t('login.Scan QR code with Google Authenticator') }}</div>
|
||||
<div v-loading="state.bindLoading" class="qr-wrap">
|
||||
<img v-if="state.qrCode" :src="state.qrCode" alt="QR Code" class="qr-image" />
|
||||
</div>
|
||||
<div v-if="state.secret" class="secret-box">
|
||||
<span class="secret-label">{{ t('login.Or enter secret manually') }}:</span>
|
||||
<span class="secret-value">{{ state.secret }}</span>
|
||||
</div>
|
||||
<el-form @keyup.enter="onBindConfirm()" ref="bindFormRef" :rules="totpRules" size="large" :model="totpForm">
|
||||
<el-form-item prop="code">
|
||||
<el-input
|
||||
v-model="totpForm.code"
|
||||
maxlength="6"
|
||||
:placeholder="t('login.Please enter the 6-digit code')"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon name="fa fa-shield" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
:loading="state.submitLoading"
|
||||
class="submit-button"
|
||||
round
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="onBindConfirm()"
|
||||
>
|
||||
{{ state.submitLoading ? t('login.Binding...') : t('login.Confirm bind') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button class="back-button" link type="primary" @click="resetToLogin">{{ t('login.Back to login') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +164,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { editDefaultLang } from '/@/lang/index'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { login } from '/@/api/backend'
|
||||
import { login, totpVerify, totpBindInit, totpBindConfirm } from '/@/api/backend'
|
||||
import { uuid } from '/@/utils/random'
|
||||
import { buildValidatorData } from '/@/utils/validate'
|
||||
import router from '/@/router'
|
||||
@@ -86,6 +172,7 @@ import clickCaptcha from '/@/components/clickCaptcha'
|
||||
import toggleDark from '/@/utils/useDark'
|
||||
import { fullUrl } from '/@/utils/common'
|
||||
import { adminBaseRoutePath } from '/@/router/static/adminBase'
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
let timer: number
|
||||
|
||||
const config = useConfig()
|
||||
@@ -93,13 +180,22 @@ const adminInfo = useAdminInfo()
|
||||
toggleDark(config.layout.isDark)
|
||||
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const totpFormRef = useTemplateRef('totpFormRef')
|
||||
const bindFormRef = useTemplateRef('bindFormRef')
|
||||
const usernameRef = useTemplateRef('usernameRef')
|
||||
const passwordRef = useTemplateRef('passwordRef')
|
||||
|
||||
const state = reactive({
|
||||
showCaptcha: false,
|
||||
submitLoading: false,
|
||||
bindLoading: false,
|
||||
step: 'login' as 'login' | 'totp' | 'bind',
|
||||
tempToken: '',
|
||||
username: '',
|
||||
secret: '',
|
||||
qrCode: '',
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
@@ -108,14 +204,33 @@ const form = reactive({
|
||||
captchaInfo: '',
|
||||
})
|
||||
|
||||
const totpForm = reactive({
|
||||
code: '',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 表单验证规则
|
||||
const rules = reactive({
|
||||
username: [buildValidatorData({ name: 'required', message: t('login.Please enter an account') }), buildValidatorData({ name: 'account' })],
|
||||
password: [buildValidatorData({ name: 'required', message: t('login.Please input a password') }), buildValidatorData({ name: 'password' })],
|
||||
})
|
||||
|
||||
const totpRules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
code: [
|
||||
buildValidatorData({ name: 'required', message: t('login.Please enter the 6-digit code') }),
|
||||
{
|
||||
validator: (_rule, val, callback) => {
|
||||
if (!/^\d{6}$/.test(val)) {
|
||||
callback(new Error(t('login.Please enter the 6-digit code')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const focusInput = () => {
|
||||
if (form.username === '') {
|
||||
usernameRef.value?.focus()
|
||||
@@ -124,6 +239,26 @@ const focusInput = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resetToLogin = () => {
|
||||
state.step = 'login'
|
||||
state.tempToken = ''
|
||||
state.secret = ''
|
||||
state.qrCode = ''
|
||||
state.username = ''
|
||||
totpForm.code = ''
|
||||
nextTick(() => focusInput())
|
||||
}
|
||||
|
||||
const navigateAfterLogin = (userInfo: anyObj) => {
|
||||
if (!userInfo?.token) {
|
||||
return
|
||||
}
|
||||
adminInfo.dataFill(userInfo, false)
|
||||
nextTick(() => {
|
||||
router.push({ path: adminBaseRoutePath })
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = window.setTimeout(() => {
|
||||
pageBubble.init()
|
||||
@@ -161,22 +296,79 @@ const onSubmit = (captchaInfo = '') => {
|
||||
form.captchaInfo = captchaInfo
|
||||
login('post', form)
|
||||
.then((res) => {
|
||||
const userInfo = res?.data?.userInfo
|
||||
if (!userInfo?.token) {
|
||||
const data = res?.data
|
||||
if (data?.userInfo?.token) {
|
||||
navigateAfterLogin(data.userInfo)
|
||||
return
|
||||
}
|
||||
adminInfo.dataFill(userInfo, false)
|
||||
nextTick(() => {
|
||||
router.push({ path: adminBaseRoutePath })
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// 接口错误由 axios 拦截器处理
|
||||
if (data?.type === 'need_totp' && data?.tempToken) {
|
||||
state.step = 'totp'
|
||||
state.tempToken = data.tempToken
|
||||
state.username = data.username || form.username
|
||||
totpForm.code = ''
|
||||
return
|
||||
}
|
||||
if (data?.type === 'need_bind_totp' && data?.tempToken) {
|
||||
state.step = 'bind'
|
||||
state.tempToken = data.tempToken
|
||||
state.username = data.username || form.username
|
||||
totpForm.code = ''
|
||||
loadBindInfo()
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
state.submitLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
const loadBindInfo = () => {
|
||||
state.bindLoading = true
|
||||
totpBindInit({ tempToken: state.tempToken })
|
||||
.then((res) => {
|
||||
state.secret = res.data.secret
|
||||
state.qrCode = res.data.qrCode
|
||||
})
|
||||
.finally(() => {
|
||||
state.bindLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
const onTotpVerify = () => {
|
||||
totpFormRef.value?.validate((valid) => {
|
||||
if (!valid) return
|
||||
state.submitLoading = true
|
||||
totpVerify({
|
||||
tempToken: state.tempToken,
|
||||
code: totpForm.code,
|
||||
keep: form.keep,
|
||||
})
|
||||
.then((res) => {
|
||||
navigateAfterLogin(res?.data?.userInfo)
|
||||
})
|
||||
.finally(() => {
|
||||
state.submitLoading = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const onBindConfirm = () => {
|
||||
bindFormRef.value?.validate((valid) => {
|
||||
if (!valid) return
|
||||
state.submitLoading = true
|
||||
totpBindConfirm({
|
||||
tempToken: state.tempToken,
|
||||
secret: state.secret,
|
||||
code: totpForm.code,
|
||||
keep: form.keep,
|
||||
})
|
||||
.then((res) => {
|
||||
navigateAfterLogin(res?.data?.userInfo)
|
||||
})
|
||||
.finally(() => {
|
||||
state.submitLoading = false
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -193,6 +385,57 @@ const onSubmit = (captchaInfo = '') => {
|
||||
.form-item-icon {
|
||||
height: auto;
|
||||
}
|
||||
.step-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.step-desc {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.bind-panel {
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
.qr-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.secret-box {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
word-break: break-all;
|
||||
.secret-label {
|
||||
display: block;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.secret-value {
|
||||
font-family: monospace;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.back-button {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.login {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -263,7 +506,6 @@ const onSubmit = (captchaInfo = '') => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 暗黑样式
|
||||
@at-root .dark {
|
||||
.bubble {
|
||||
background: url(/@/assets/bg-dark.jpg) repeat;
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
<el-form-item :label="t('routine.adminInfo.user name')">
|
||||
<el-input disabled v-model="state.adminInfo.username"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('routine.adminInfo.Google Authenticator')">
|
||||
<el-tag :type="state.adminInfo.totp_bound ? 'success' : 'info'">
|
||||
{{ state.adminInfo.totp_bound ? t('routine.adminInfo.Bound') : t('routine.adminInfo.Not bound') }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('routine.adminInfo.User nickname')" prop="nickname">
|
||||
<el-input :placeholder="t('routine.adminInfo.Please enter a nickname')" v-model="state.adminInfo.nickname"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
Reference in New Issue
Block a user