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:
parent
0ebe467927
commit
0003b0f1fd
@ -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,53 +22,107 @@ 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user