feat: 添加谷歌二步验证功能,更新用户头像下拉菜单以支持验证操作
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
149
src/components/common/google-auth.vue
Normal file
149
src/components/common/google-auth.vue
Normal 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>
|
||||
@@ -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') {
|
||||
logout();
|
||||
} else {
|
||||
// If your other options are jumps from other routes, they will be directly supported here
|
||||
routerPushByKey(key);
|
||||
switch (key) {
|
||||
case 'logout':
|
||||
logout();
|
||||
break;
|
||||
case 'google-auth':
|
||||
googleAuth();
|
||||
break;
|
||||
default:
|
||||
routerPushByKey(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
return authClient.signIn.username({
|
||||
username,
|
||||
password
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
src/typings/api/auth.d.ts
vendored
1
src/typings/api/auth.d.ts
vendored
@@ -16,6 +16,7 @@ declare namespace Api {
|
||||
email: string;
|
||||
roles: string[];
|
||||
buttons: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
src/typings/components.d.ts
vendored
2
src/typings/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user