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 { ElNotification } from 'element-plus';
|
||||||
import { defineStore } from 'pinia';
|
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';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
@ -21,53 +22,107 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const loginLoading = ref(false);
|
const loginLoading = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步处理登录操作
|
* OAuth 2.0 登录 - 重定向到认证中心
|
||||||
* Asynchronously handle the login process
|
|
||||||
* @param params 登录表单数据
|
|
||||||
*/
|
*/
|
||||||
async function authLogin(
|
function authLogin(
|
||||||
params: Recordable<any>,
|
params?: Recordable<any>,
|
||||||
onSuccess?: () => Promise<void> | void,
|
onSuccess?: () => Promise<void> | void,
|
||||||
) {
|
) {
|
||||||
// 异步处理用户登录操作并获取 accessToken
|
loginLoading.value = true;
|
||||||
let userInfo: null | UserInfo = null;
|
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 {
|
try {
|
||||||
loginLoading.value = true;
|
loginLoading.value = true;
|
||||||
const { accessToken } = await loginApi(params);
|
|
||||||
|
|
||||||
// 如果成功获取到 accessToken
|
await oauthService.handleCallback(code, state);
|
||||||
|
|
||||||
|
const accessToken = oauthService.getAccessToken();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
// 将 accessToken 存储到 accessStore 中
|
|
||||||
accessStore.setAccessToken(accessToken);
|
accessStore.setAccessToken(accessToken);
|
||||||
|
|
||||||
// 获取用户信息并存储到 accessStore 中
|
|
||||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||||
fetchUserInfo(),
|
fetchUserInfo(),
|
||||||
getAccessCodesApi(),
|
getAccessCodesApi(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
userInfo = fetchUserInfoResult;
|
userStore.setUserInfo(fetchUserInfoResult);
|
||||||
|
|
||||||
userStore.setUserInfo(userInfo);
|
|
||||||
accessStore.setAccessCodes(accessCodes);
|
accessStore.setAccessCodes(accessCodes);
|
||||||
|
|
||||||
if (accessStore.loginExpired) {
|
if (fetchUserInfoResult?.realName) {
|
||||||
accessStore.setLoginExpired(false);
|
|
||||||
} else {
|
|
||||||
onSuccess
|
|
||||||
? await onSuccess?.()
|
|
||||||
: await router.push(
|
|
||||||
userInfo.homePath || preferences.app.defaultHomePath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userInfo?.realName) {
|
|
||||||
ElNotification({
|
ElNotification({
|
||||||
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
|
message: `${$t('authentication.loginSuccessDesc')}:${fetchUserInfoResult?.realName}`,
|
||||||
title: $t('authentication.loginSuccess'),
|
title: $t('authentication.loginSuccess'),
|
||||||
type: 'success',
|
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 {
|
} finally {
|
||||||
loginLoading.value = false;
|
loginLoading.value = false;
|
||||||
|
|||||||
@ -1,98 +1,43 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { VbenFormSchema } from '@vben/common-ui';
|
import { onMounted, ref } from 'vue';
|
||||||
import type { BasicOption } from '@vben/types';
|
|
||||||
|
|
||||||
import { computed, markRaw } from 'vue';
|
import { ElCard, ElIcon } from 'element-plus';
|
||||||
|
import { Loading } from '@element-plus/icons-vue';
|
||||||
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
|
|
||||||
import { $t } from '@vben/locales';
|
|
||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
|
||||||
|
|
||||||
defineOptions({ name: 'Login' });
|
defineOptions({ name: 'Login' });
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const loading = ref(true);
|
||||||
|
const message = ref('正在跳转到认证中心...');
|
||||||
|
|
||||||
const MOCK_USER_OPTIONS: BasicOption[] = [
|
onMounted(() => {
|
||||||
{
|
// 页面加载后自动跳转到 OAuth 认证中心
|
||||||
label: 'Super',
|
setTimeout(() => {
|
||||||
value: 'vben',
|
window.location.href = import.meta.env.VITE_OAUTH_ISSUER + '/connect/authorize?' +
|
||||||
},
|
new URLSearchParams({
|
||||||
{
|
response_type: 'code',
|
||||||
label: 'Admin',
|
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID || 'fengling-console',
|
||||||
value: 'admin',
|
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),
|
||||||
label: 'User',
|
}).toString();
|
||||||
value: 'jack',
|
}, 500);
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthenticationLogin
|
<div class="flex min-h-screen items-center justify-center bg-gray-100 p-4">
|
||||||
:form-schema="formSchema"
|
<el-card class="w-full max-w-md">
|
||||||
:loading="authStore.loginLoading"
|
<template #header>
|
||||||
@submit="authStore.authLogin"
|
<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>
|
</template>
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElCard, ElResult, ElButton } from 'element-plus'
|
import { ElMessage, ElCard, ElResult, ElButton } from 'element-plus'
|
||||||
import { oauthService } from '#/services/auth/oauth'
|
import { useAuthStore } from '#/store'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@ -18,9 +19,7 @@ onMounted(async () => {
|
|||||||
throw new Error('No authorization code found')
|
throw new Error('No authorization code found')
|
||||||
}
|
}
|
||||||
|
|
||||||
await oauthService.handleCallback(code, state)
|
await authStore.handleOAuthCallback(code, state)
|
||||||
ElMessage.success('登录成功')
|
|
||||||
router.push('/')
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message || '认证失败'
|
error.value = err.message || '认证失败'
|
||||||
ElMessage.error(error.value)
|
ElMessage.error(error.value)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user