feat: 添加谷歌二步验证功能,更新用户头像下拉菜单以支持验证操作
This commit is contained in:
@@ -21,7 +21,8 @@ export default defineConfig(
|
|||||||
'vue/no-duplicate-attr-inheritance': 'off',
|
'vue/no-duplicate-attr-inheritance': 'off',
|
||||||
'unocss/order-attributify': 'off',
|
'unocss/order-attributify': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': '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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, h } from 'vue';
|
||||||
import type { VNode } from 'vue';
|
import type { VNode } from 'vue';
|
||||||
|
import { useDialog } from 'naive-ui';
|
||||||
import { useAuthStore } from '@/store/modules/auth';
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import { useSvgIcon } from '@/hooks/common/icon';
|
import { useSvgIcon } from '@/hooks/common/icon';
|
||||||
|
import { localStg } from '@/utils/storage';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
import GoogleAuth from '@/components/common/google-auth.vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'UserAvatar'
|
name: 'UserAvatar'
|
||||||
@@ -13,12 +16,13 @@ defineOptions({
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const { routerPushByKey, toLogin } = useRouterPush();
|
const { routerPushByKey, toLogin } = useRouterPush();
|
||||||
const { SvgIconVNode } = useSvgIcon();
|
const { SvgIconVNode } = useSvgIcon();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
function loginOrRegister() {
|
function loginOrRegister() {
|
||||||
toLogin();
|
toLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
type DropdownKey = 'logout';
|
type DropdownKey = 'logout' | 'google-auth';
|
||||||
|
|
||||||
type DropdownOption =
|
type DropdownOption =
|
||||||
| {
|
| {
|
||||||
@@ -33,6 +37,11 @@ type DropdownOption =
|
|||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const opts: DropdownOption[] = [
|
const opts: DropdownOption[] = [
|
||||||
|
{
|
||||||
|
label: '谷歌验证',
|
||||||
|
key: 'google-auth',
|
||||||
|
icon: SvgIconVNode({ icon: 'material-icon-theme:google', fontSize: 18 })
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: $t('common.logout'),
|
label: $t('common.logout'),
|
||||||
key: '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) {
|
function handleDropdown(key: DropdownKey) {
|
||||||
if (key === 'logout') {
|
switch (key) {
|
||||||
logout();
|
case 'logout':
|
||||||
} else {
|
logout();
|
||||||
// If your other options are jumps from other routes, they will be directly supported here
|
break;
|
||||||
routerPushByKey(key);
|
case 'google-auth':
|
||||||
|
googleAuth();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
routerPushByKey(key);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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';
|
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
|
* Login
|
||||||
*
|
*
|
||||||
@@ -7,13 +24,9 @@ import { request } from '../request';
|
|||||||
* @param password Password
|
* @param password Password
|
||||||
*/
|
*/
|
||||||
export function fetchLogin(username: string, password: string) {
|
export function fetchLogin(username: string, password: string) {
|
||||||
return request<Api.Auth.LoginToken>({
|
return authClient.signIn.username({
|
||||||
url: '/auth/sign-in/username',
|
username,
|
||||||
method: 'post',
|
password
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
password
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
|
|
||||||
const res = await fetchLogin(userName, password);
|
const res = await fetchLogin(userName, password);
|
||||||
if (!res.error) {
|
if (!res.error) {
|
||||||
const pass = await loginByToken(res.response.data);
|
const pass = await loginByToken(res.data);
|
||||||
|
|
||||||
if (pass) {
|
if (pass) {
|
||||||
// Check if the tab needs to be cleared
|
// 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;
|
email: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
buttons: 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']
|
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||||
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
||||||
FullScreen: typeof import('./../components/common/full-screen.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']
|
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||||
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-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 DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||||
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
const ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
||||||
const FullScreen: typeof import('./../components/common/full-screen.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 IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||||
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||||
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
const IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||||
|
|||||||
@@ -46,31 +46,6 @@ interface Account {
|
|||||||
userName: string;
|
userName: string;
|
||||||
password: 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,22 +71,6 @@ async function handleAccountLogin(account: Account) {
|
|||||||
<NButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit">
|
<NButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit">
|
||||||
{{ $t('common.confirm') }}
|
{{ $t('common.confirm') }}
|
||||||
</NButton>
|
</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>
|
</NSpace>
|
||||||
</NForm>
|
</NForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user