feat: implement OAuth 2.0 login flow with auto-redirect

- Modify login page to auto-redirect to auth center
- Update auth store to use OAuth login flow
- Handle OAuth callback and token exchange
- Update logout to use OAuth logout endpoint
This commit is contained in:
Sam 2026-02-06 01:14:11 +08:00
parent 0ebe467927
commit 0003b0f1fd
3 changed files with 117 additions and 118 deletions

View File

@ -10,7 +10,8 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { ElNotification } from 'element-plus';
import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { getAccessCodesApi, getUserInfoApi, logoutApi, oauthLogoutApi } from '#/api';
import { oauthService } from '#/services/auth/oauth';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
@ -21,51 +22,105 @@ export const useAuthStore = defineStore('auth', () => {
const loginLoading = ref(false);
/**
*
* Asynchronously handle the login process
* @param params
* OAuth 2.0 -
*/
async function authLogin(
params: Recordable<any>,
function authLogin(
params?: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null;
loginLoading.value = true;
try {
// OAuth 2.0 方式:重定向到认证中心
oauthService.initiateAuthorization();
} catch (error) {
ElNotification({
message: $t('authentication.loginFailed'),
title: $t('authentication.loginFailedTitle'),
type: 'error',
});
} finally {
loginLoading.value = false;
}
}
/**
* OAuth
*/
async function handleOAuthCallback(code: string, state: string) {
try {
loginLoading.value = true;
const { accessToken } = await loginApi(params);
// 如果成功获取到 accessToken
await oauthService.handleCallback(code, state);
const accessToken = oauthService.getAccessToken();
if (accessToken) {
// 将 accessToken 存储到 accessStore 中
accessStore.setAccessToken(accessToken);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
userStore.setUserInfo(fetchUserInfoResult);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.realName) {
if (fetchUserInfoResult?.realName) {
ElNotification({
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
message: `${$t('authentication.loginSuccessDesc')}:${fetchUserInfoResult?.realName}`,
title: $t('authentication.loginSuccess'),
type: 'success',
});
}
await router.push(
fetchUserInfoResult.homePath || preferences.app.defaultHomePath,
);
}
} catch (error: any) {
ElNotification({
message: error.message || $t('authentication.loginFailed'),
title: $t('authentication.loginFailedTitle'),
type: 'error',
});
await router.replace(LOGIN_PATH);
} finally {
loginLoading.value = false;
}
}
async function logout(redirect: boolean = true) {
try {
// OAuth 登出
await oauthLogoutApi();
} catch {
// 不做任何处理
}
resetAllStores();
accessStore.setLoginExpired(false);
// OAuth 登出并重定向到认证中心
oauthService.logout();
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
return userInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
authLogin,
fetchUserInfo,
handleOAuthCallback,
loginLoading,
logout,
};
});
}
}

View File

@ -1,98 +1,43 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
import { onMounted, ref } from 'vue';
import { computed, markRaw } from 'vue';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useAuthStore } from '#/store';
import { ElCard, ElIcon } from 'element-plus';
import { Loading } from '@element-plus/icons-vue';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const loading = ref(true);
const message = ref('正在跳转到认证中心...');
const MOCK_USER_OPTIONS: BasicOption[] = [
{
label: 'Super',
value: 'vben',
},
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'jack',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: MOCK_USER_OPTIONS,
placeholder: $t('authentication.selectAccount'),
},
fieldName: 'selectAccount',
label: $t('authentication.selectAccount'),
rules: z
.string()
.min(1, { message: $t('authentication.selectAccount') })
.optional()
.default('vben'),
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
dependencies: {
trigger(values, form) {
if (values.selectAccount) {
const findUser = MOCK_USER_OPTIONS.find(
(item) => item.value === values.selectAccount,
);
if (findUser) {
form.setValues({
password: '123456',
username: findUser.value,
});
}
}
},
triggerFields: ['selectAccount'],
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: markRaw(SliderCaptcha),
fieldName: 'captcha',
rules: z.boolean().refine((value) => value, {
message: $t('authentication.verifyRequiredTip'),
}),
},
];
onMounted(() => {
// OAuth
setTimeout(() => {
window.location.href = import.meta.env.VITE_OAUTH_ISSUER + '/connect/authorize?' +
new URLSearchParams({
response_type: 'code',
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID || 'fengling-console',
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI || 'http://localhost:5777/callback',
scope: import.meta.env.VITE_OAUTH_SCOPES || 'openid profile email api',
state: Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2),
}).toString();
}, 500);
});
</script>
<template>
<AuthenticationLogin
:form-schema="formSchema"
:loading="authStore.loginLoading"
@submit="authStore.authLogin"
/>
<div class="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<el-card class="w-full max-w-md">
<template #header>
<h1 class="text-2xl font-bold text-center">Fengling Console</h1>
<p class="text-center text-sm text-gray-500">管理后台登录</p>
</template>
<div class="flex flex-col items-center justify-center py-12">
<el-icon :size="60" class="text-blue-500">
<Loading />
</el-icon>
<p class="mt-4 text-center text-gray-600">{{ message }}</p>
</div>
</el-card>
</div>
</template>

View File

@ -2,10 +2,11 @@
import { onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElCard, ElResult, ElButton } from 'element-plus'
import { oauthService } from '#/services/auth/oauth'
import { useAuthStore } from '#/store'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
@ -18,9 +19,7 @@ onMounted(async () => {
throw new Error('No authorization code found')
}
await oauthService.handleCallback(code, state)
ElMessage.success('登录成功')
router.push('/')
await authStore.handleOAuthCallback(code, state)
} catch (err: any) {
error.value = err.message || '认证失败'
ElMessage.error(error.value)