feat: 增加 sso 单点登录

pull/75/MERGE
YunaiV 2025-04-16 18:35:03 +08:00
parent f27774c1fc
commit 54f9d0c10f
7 changed files with 222 additions and 3 deletions

View File

@ -51,6 +51,9 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
async function doRefreshToken() { async function doRefreshToken() {
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const refreshToken = accessStore.refreshToken as string; const refreshToken = accessStore.refreshToken as string;
if (!refreshToken) {
throw new Error('Refresh token is null!');
}
const resp = await refreshTokenApi(refreshToken); const resp = await refreshTokenApi(refreshToken);
const newToken = resp?.data?.data?.accessToken; const newToken = resp?.data?.data?.accessToken;
// add by 芋艿:这里一定要抛出 resp.data从而触发 authenticateResponseInterceptor 中,刷新令牌失败!!! // add by 芋艿:这里一定要抛出 resp.data从而触发 authenticateResponseInterceptor 中,刷新令牌失败!!!

View File

@ -97,6 +97,14 @@ const coreRoutes: RouteRecordRaw[] = [
title: $t('page.auth.login'), title: $t('page.auth.login'),
}, },
}, },
{
name: 'SSOLogin',
path: 'sso-login',
component: () => import('#/views/_core/authentication/sso-login.vue'),
meta: {
title: $t('page.auth.login'),
},
}
], ],
}, },
]; ];

View File

@ -108,7 +108,7 @@ export const useAuthStore = defineStore('auth', () => {
let authPermissionInfo: AuthPermissionInfo | null = null; let authPermissionInfo: AuthPermissionInfo | null = null;
authPermissionInfo = await getAuthPermissionInfoApi(); authPermissionInfo = await getAuthPermissionInfoApi();
// userStore // userStore
userStore.setUserInfo(authPermissionInfo.user); // TODO @芋艿:这里有报错 userStore.setUserInfo(authPermissionInfo.user);
userStore.setUserRoles(authPermissionInfo.roles); userStore.setUserRoles(authPermissionInfo.roles);
// accessStore // accessStore
accessStore.setAccessMenus(authPermissionInfo.menus); accessStore.setAccessMenus(authPermissionInfo.menus);

View File

@ -16,7 +16,7 @@ import { getTenantSimpleList, getTenantByWebsite } from '#/api/core/auth';
const { tenantEnable, captchaEnable } = useAppConfig(import.meta.env, import.meta.env.PROD); const { tenantEnable, captchaEnable } = useAppConfig(import.meta.env, import.meta.env.PROD);
defineOptions({ name: 'Login' }); defineOptions({ name: 'SocialLogin' });
const authStore = useAuthStore(); const authStore = useAuthStore();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -34,6 +34,7 @@ const fetchTenantList = async () => {
if (!tenantEnable) { if (!tenantEnable) {
return; return;
} }
try { try {
// //
const websiteTenantPromise = getTenantByWebsite(window.location.hostname); const websiteTenantPromise = getTenantByWebsite(window.location.hostname);

View File

@ -0,0 +1,205 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import { AuthenticationAuthTitle, VbenButton } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { computed, reactive, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { authorize, getAuthorize } from '#/api/system/oauth2/open';
defineOptions({ name: 'SSOLogin' });
const { query } = useRoute(); //
const client = ref({
name: '',
logo: ''
}); //
const queryParams = reactive({
responseType: '',
clientId: '',
redirectUri: '',
state: '',
scopes: [] as string[] // query
}); // URL client_idscope
const loading = ref(false); //
/** 初始化授权信息 */
const init = async () => {
//
if (typeof query.client_id === 'undefined') {
return;
}
//
// client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
// client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
queryParams.responseType = query.response_type as string;
queryParams.clientId = query.client_id as string;
queryParams.redirectUri = query.redirect_uri as string;
queryParams.state = query.state as string;
if (query.scope) {
queryParams.scopes = (query.scope as string).split(' ');
}
// scope
if (queryParams.scopes.length > 0) {
const data = await doAuthorize(true, queryParams.scopes, []);
if (data) {
location.href = data;
return;
}
}
// 1.1
const data = await getAuthorize(queryParams.clientId);
client.value = data.client;
// 1.2 scope
let scopes;
// params.scope scopes
if (queryParams.scopes.length > 0) {
scopes = data.scopes.filter(scope => queryParams.scopes.includes(scope.key));
// params.scope 使 scopes
} else {
scopes = data.scopes;
queryParams.scopes = scopes.map(scope => scope.key);
}
// 2.
formApi.setFieldValue('scopes', scopes.filter(scope => scope.value).map(scope => scope.key));
};
/** 处理授权的提交 */
const handleSubmit = async (approved: boolean) => {
// checkedScopes + uncheckedScopes
let checkedScopes: string[];
let uncheckedScopes: string[];
if (approved) {
//
checkedScopes = (await formApi.getValues()).scopes;
uncheckedScopes = queryParams.scopes.filter((item) => checkedScopes.indexOf(item) === -1);
} else {
//
checkedScopes = [];
uncheckedScopes = queryParams.scopes;
}
//
loading.value = true;
try {
const data = await doAuthorize(false, checkedScopes, uncheckedScopes);
if (!data) {
return;
}
//
location.href = data;
} finally {
loading.value = false;
}
};
/** 调用授权 API 接口 */
const doAuthorize = (autoApprove: boolean, checkedScopes: string[], uncheckedScopes: string[]) => {
return authorize(
queryParams.responseType,
queryParams.clientId,
queryParams.redirectUri,
queryParams.state,
autoApprove,
checkedScopes,
uncheckedScopes
);
};
/** 格式化 scope 文本 */
const formatScope = (scope: string) => {
// scope 便
// demo "system_oauth2_scope" scope
switch (scope) {
case 'user.read':
return '访问你的个人信息';
case 'user.write':
return '修改你的个人信息';
default:
return scope;
}
};
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'scopes',
label: '授权范围',
component: 'CheckboxGroup',
componentProps: {
options: queryParams.scopes.map(scope => ({
label: formatScope(scope),
value: scope
})),
class: 'flex flex-col gap-2'
},
},
];
});
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
hideLabel: true,
hideRequiredMark: true,
},
schema: formSchema,
showDefaultActions: false,
}),
);
/** 初始化 */
onMounted(() => {
init();
})
</script>
<template>
<div @keydown.enter.prevent="handleSubmit(true)">
<AuthenticationAuthTitle>
<slot name="title">
{{ `${client.name} 👋🏻` }}
</slot>
<template #desc>
<span class="text-muted-foreground">
此第三方应用请求获得以下权限
</span>
</template>
</AuthenticationAuthTitle>
<Form />
<div class="flex gap-2">
<VbenButton
:class="{
'cursor-wait': loading,
}"
:loading="loading"
aria-label="login"
class="w-2/3"
@click="handleSubmit(true)"
>
同意授权
</VbenButton>
<VbenButton
:class="{
'cursor-wait': loading,
}"
:loading="loading"
aria-label="login"
class="w-1/3"
variant="outline"
@click="handleSubmit(false)"
>
拒绝
</VbenButton>
</div>
</div>
</template>

View File

@ -5,4 +5,5 @@ export { default as AuthenticationLogin } from './login.vue';
export { default as AuthenticationQrCodeLogin } from './qrcode-login.vue'; export { default as AuthenticationQrCodeLogin } from './qrcode-login.vue';
export { default as AuthenticationRegister } from './register.vue'; export { default as AuthenticationRegister } from './register.vue';
export { default as DocLink } from './doc-link.vue'; export { default as DocLink } from './doc-link.vue';
export { default as AuthenticationAuthTitle } from './auth-title.vue';
export type { AuthenticationProps } from './types'; export type { AuthenticationProps } from './types';

View File

@ -52,7 +52,8 @@ const props = withDefaults(defineProps<Props>(), {
const router = useRouter(); const router = useRouter();
const text = ref('https://vben.vvbin.cn'); // const text = ref('https://vben.vvbin.cn');
const text = ref('https://t.zsxq.com/FUtQd');
const qrcode = useQRCode(text, { const qrcode = useQRCode(text, {
errorCorrectionLevel: 'H', errorCorrectionLevel: 'H',