feat: 添加谷歌二步验证功能,更新用户头像下拉菜单以支持验证操作

This commit is contained in:
2026-01-20 06:29:19 +07:00
parent 9e6dbed419
commit 220b14be30
8 changed files with 214 additions and 57 deletions

View File

@@ -21,7 +21,8 @@ export default defineConfig(
'vue/no-duplicate-attr-inheritance': 'off',
'unocss/order-attributify': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'consistent-return': 'off'
'consistent-return': 'off',
'no-alert': 'off'
}
}
);

View File

@@ -0,0 +1,149 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { authClient, safeClient } from '@/service/api';
import { localStg } from '@/utils/storage';
const props = defineProps<{
password: string;
}>();
const emit = defineEmits<{
success: [];
}>();
const user = localStg.get('userinfo');
const twoFactorEnabled = computed(() => user?.twoFactorEnabled);
const qrCodeUrl = ref('');
const totpSecret = ref('');
const totpCode = ref('');
const loading = ref(false);
const verifying = ref(false);
const disabling = ref(false);
function generateQRCode(url: string) {
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(url)}`;
}
async function enableTwoFactor() {
if (twoFactorEnabled.value) {
window.$message?.warning('二步验证已开启');
return;
}
loading.value = true;
const { data } = await safeClient(
authClient.twoFactor.enable({
password: props.password,
issuer: 'my-app-name'
})
);
loading.value = false;
if (data) {
const otpauthUrl = data.value?.totpURI || '';
if (otpauthUrl) {
qrCodeUrl.value = generateQRCode(otpauthUrl);
}
}
}
async function verifyTotp() {
if (!totpCode.value) {
window.$message?.warning('请输入验证码');
return;
}
verifying.value = true;
const { data } = await safeClient(
authClient.twoFactor.verifyTotp({
code: totpCode.value,
trustDevice: false
})
);
verifying.value = false;
window.$message?.success('二步验证开启成功');
// 更新本地用户信息
const updatedUser = localStg.get('userinfo');
if (updatedUser) {
updatedUser.twoFactorEnabled = true;
localStg.set('userinfo', updatedUser);
}
emit('success');
// 重置状态
qrCodeUrl.value = '';
totpSecret.value = '';
totpCode.value = '';
}
async function disableTwoFactor() {
if (!twoFactorEnabled.value) {
window.$message?.warning('二步验证未开启');
return;
}
disabling.value = true;
const { data } = await safeClient(
authClient.twoFactor.disable({
password: props.password
})
);
disabling.value = false;
if (data?.value?.status) {
window.$message?.success('二步验证已关闭');
// 更新本地用户信息
const updatedUser = localStg.get('userinfo');
if (updatedUser) {
updatedUser.twoFactorEnabled = false;
localStg.set('userinfo', updatedUser);
}
emit('success');
}
}
</script>
<template>
<div class="google-auth">
<template v-if="!twoFactorEnabled">
<template v-if="!qrCodeUrl">
<NButton type="primary" :loading="loading" @click="enableTwoFactor">开启二步验证</NButton>
</template>
<template v-else>
<div class="qr-code-container">
<div class="mb-4 text-center">
<p class="mb-2 text-sm text-gray-600">请使用 Google Authenticator 扫描二维码</p>
<img :src="qrCodeUrl" alt="QR Code" class="mx-auto" />
</div>
<NSpace vertical>
<NInput v-model:value="totpCode" placeholder="请输入6位验证码" :maxlength="6" clearable />
<NSpace>
<NButton type="primary" :loading="verifying" @click="verifyTotp">验证并开启</NButton>
<NButton @click="qrCodeUrl = ''">取消</NButton>
</NSpace>
</NSpace>
</div>
</template>
</template>
<template v-else>
<NSpace align="center">
<div class="text-success">
<i class="i-carbon-checkmark-filled mr-1" />
二步验证已开启
</div>
<NButton type="error" :loading="disabling" @click="disableTwoFactor">关闭二步验证</NButton>
</NSpace>
</template>
</div>
</template>
<style lang="css" scoped></style>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, h } from 'vue';
import type { VNode } from 'vue';
import { useDialog } from 'naive-ui';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { useSvgIcon } from '@/hooks/common/icon';
import { localStg } from '@/utils/storage';
import { $t } from '@/locales';
import GoogleAuth from '@/components/common/google-auth.vue';
defineOptions({
name: 'UserAvatar'
@@ -13,12 +16,13 @@ defineOptions({
const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
const { SvgIconVNode } = useSvgIcon();
const dialog = useDialog();
function loginOrRegister() {
toLogin();
}
type DropdownKey = 'logout';
type DropdownKey = 'logout' | 'google-auth';
type DropdownOption =
| {
@@ -33,6 +37,11 @@ type DropdownOption =
const options = computed(() => {
const opts: DropdownOption[] = [
{
label: '谷歌验证',
key: 'google-auth',
icon: SvgIconVNode({ icon: 'material-icon-theme:google', fontSize: 18 })
},
{
label: $t('common.logout'),
key: 'logout',
@@ -54,13 +63,36 @@ function logout() {
}
});
}
function googleAuth() {
const topt = window.prompt('请输入账号登录密码:');
if (!topt) {
window.$message?.error('请输入密码');
return;
}
const d = dialog.create({
title: '谷歌验证',
content: () =>
h(GoogleAuth, {
password: topt,
onSuccess: () => {
d.destroy();
window.$message?.success('二步验证已开启');
}
})
});
}
function handleDropdown(key: DropdownKey) {
if (key === 'logout') {
switch (key) {
case 'logout':
logout();
} else {
// If your other options are jumps from other routes, they will be directly supported here
break;
case 'google-auth':
googleAuth();
break;
default:
routerPushByKey(key);
break;
}
}
</script>

View File

@@ -1,5 +1,22 @@
import { createAuthClient } from 'better-auth/client';
import { twoFactorClient, usernameClient } from 'better-auth/client/plugins';
import { getToken } from '@/store/modules/auth/shared';
import { request } from '../request';
const { VITE_SERVICE_BASE_URL } = import.meta.env;
export const authClient = createAuthClient({
baseURL: VITE_SERVICE_BASE_URL,
fetchOptions: {
credentials: 'include',
auth: {
type: 'Bearer',
token: () => getToken()
}
},
plugins: [usernameClient(), twoFactorClient()]
});
/**
* Login
*
@@ -7,13 +24,9 @@ import { request } from '../request';
* @param password Password
*/
export function fetchLogin(username: string, password: string) {
return request<Api.Auth.LoginToken>({
url: '/auth/sign-in/username',
method: 'post',
data: {
return authClient.signIn.username({
username,
password
}
});
}

View File

@@ -102,7 +102,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const res = await fetchLogin(userName, password);
if (!res.error) {
const pass = await loginByToken(res.response.data);
const pass = await loginByToken(res.data);
if (pass) {
// Check if the tab needs to be cleared

View File

@@ -16,6 +16,7 @@ declare namespace Api {
email: string;
roles: string[];
buttons: string[];
[key: string]: any;
}
}
}

View File

@@ -19,6 +19,7 @@ declare module 'vue' {
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
GoogleAuth: typeof import('./../components/common/google-auth.vue')['default']
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
@@ -114,6 +115,7 @@ declare global {
const DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
const FullScreen: typeof import('./../components/common/full-screen.vue')['default']
const GoogleAuth: typeof import('./../components/common/google-auth.vue')['default']
const IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']

View File

@@ -46,31 +46,6 @@ interface Account {
userName: string;
password: string;
}
const accounts = computed<Account[]>(() => [
{
key: 'super',
label: $t('page.login.pwdLogin.superAdmin'),
userName: 'Super',
password: '123456'
},
{
key: 'admin',
label: $t('page.login.pwdLogin.admin'),
userName: 'Admin',
password: '123456'
},
{
key: 'user',
label: $t('page.login.pwdLogin.user'),
userName: 'User',
password: '123456'
}
]);
async function handleAccountLogin(account: Account) {
await authStore.login(account.userName, account.password);
}
</script>
<template>
@@ -96,22 +71,6 @@ async function handleAccountLogin(account: Account) {
<NButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit">
{{ $t('common.confirm') }}
</NButton>
<!--
<div class="flex-y-center justify-between gap-12px">
<NButton class="flex-1" block @click="toggleLoginModule('code-login')">
{{ $t(loginModuleRecord['code-login']) }}
</NButton>
<NButton class="flex-1" block @click="toggleLoginModule('register')">
{{ $t(loginModuleRecord.register) }}
</NButton>
</div>
<NDivider class="text-14px text-#666 !m-0">{{ $t('page.login.pwdLogin.otherAccountLogin') }}</NDivider>
<div class="flex-center gap-12px">
<NButton v-for="item in accounts" :key="item.key" type="primary" @click="handleAccountLogin(item)">
{{ item.label }}
</NButton>
</div>
-->
</NSpace>
</NForm>
</template>