feat: sso init
							parent
							
								
									0c35370f58
								
							
						
					
					
						commit
						39732ad3b2
					
				|  | @ -47,3 +47,35 @@ export function getCaptcha(data) { | |||
| export function checkCaptcha(data) { | ||||
|   return defHttp.post({ url: Api.CheckCaptcha, data }, { isReturnNativeResponse: true }) | ||||
| } | ||||
| 
 | ||||
| // ========== OAUTH 2.0 相关 ==========
 | ||||
| 
 | ||||
| export function getAuthorize(clientId) { | ||||
|   return defHttp.get({ url: '/system/oauth2/authorize?clientId=' + clientId }) | ||||
| } | ||||
| 
 | ||||
| export function authorize(responseType, clientId, redirectUri, state, autoApprove, checkedScopes, uncheckedScopes) { | ||||
|   // 构建 scopes
 | ||||
|   const scopes = {} | ||||
|   for (const scope of checkedScopes) { | ||||
|     scopes[scope] = true | ||||
|   } | ||||
|   for (const scope of uncheckedScopes) { | ||||
|     scopes[scope] = false | ||||
|   } | ||||
|   // 发起请求
 | ||||
|   return defHttp.post({ | ||||
|     url: '/system/oauth2/authorize', | ||||
|     headers: { | ||||
|       'Content-type': 'application/x-www-form-urlencoded' | ||||
|     }, | ||||
|     params: { | ||||
|       response_type: responseType, | ||||
|       client_id: clientId, | ||||
|       redirect_uri: redirectUri, | ||||
|       state: state, | ||||
|       auto_approve: autoApprove, | ||||
|       scope: JSON.stringify(scopes) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| export default { | ||||
|   login: 'Login', | ||||
|   sso: 'SSO Login', | ||||
|   errorLogList: 'Error Log', | ||||
|   profile: 'User Center', | ||||
|   notifyMessage: 'Notify Message' | ||||
|  |  | |||
|  | @ -65,6 +65,7 @@ export default { | |||
|   }, | ||||
|   login: { | ||||
|     backSignIn: 'Back sign in', | ||||
|     ssoSignInFormTitle: 'sso login', | ||||
|     mobileSignInFormTitle: 'Mobile sign in', | ||||
|     qrSignInFormTitle: 'Qr code sign in', | ||||
|     signInFormTitle: 'Sign in', | ||||
|  | @ -82,6 +83,9 @@ export default { | |||
|     forgetPassword: 'Forget Password?', | ||||
|     otherSignIn: 'Sign in with', | ||||
| 
 | ||||
|     ssoInfoDesc: 'get your personal details and get started!', | ||||
|     ssoEditDesc: 'edit your personal details and get started!', | ||||
| 
 | ||||
|     // notify
 | ||||
|     loginSuccessTitle: 'Login successful', | ||||
|     loginSuccessDesc: 'Welcome back', | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| export default { | ||||
|   login: '登录', | ||||
|   sso: '第三方授权登录', | ||||
|   errorLogList: '错误日志列表', | ||||
|   profile: '个人中心', | ||||
|   notifyMessage: '站内信' | ||||
|  |  | |||
|  | @ -62,6 +62,7 @@ export default { | |||
|   login: { | ||||
|     backSignIn: '返回', | ||||
|     signInFormTitle: '登录', | ||||
|     ssoSignInFormTitle: '三方授权登录', | ||||
|     mobileSignInFormTitle: '手机登录', | ||||
|     qrSignInFormTitle: '二维码登录', | ||||
|     signUpFormTitle: '注册', | ||||
|  | @ -78,6 +79,9 @@ export default { | |||
|     forgetPassword: '忘记密码?', | ||||
|     otherSignIn: '其他登录方式', | ||||
| 
 | ||||
|     ssoInfoDesc: '访问您的个人详细信息开始使用!', | ||||
|     ssoEditDesc: '修改您的个人详细信息开始使用!', | ||||
| 
 | ||||
|     // notify
 | ||||
|     loginSuccessTitle: '登录成功', | ||||
|     loginSuccessDesc: '欢迎回来', | ||||
|  |  | |||
|  | @ -38,6 +38,15 @@ export const LoginRoute: AppRouteRecordRaw = { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| export const SSORoute: AppRouteRecordRaw = { | ||||
|   path: '/sso', | ||||
|   name: 'SSO', | ||||
|   component: () => import('@/views/base/login/sso.vue'), | ||||
|   meta: { | ||||
|     title: t('routes.basic.sso') | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const ProfileRoute: AppRouteRecordRaw = { | ||||
|   path: '/profile', | ||||
|   component: LAYOUT, | ||||
|  | @ -268,6 +277,7 @@ export const BpmRoute: AppRouteRecordRaw = { | |||
| // 未经许可的基本路由
 | ||||
| export const basicRoutes = [ | ||||
|   LoginRoute, | ||||
|   SSORoute, | ||||
|   RootRoute, | ||||
|   ProfileRoute, | ||||
|   CodegenRoute, | ||||
|  |  | |||
|  | @ -0,0 +1,196 @@ | |||
| <template> | ||||
|   <h2 class="mb-3 text-2xl font-bold text-center xl:text-3xl enter-x xl:text-left"> | ||||
|     {{ client.name + t('sys.login.ssoSignInFormTitle') }} | ||||
|   </h2> | ||||
|   <Form class="p-4 enter-x" :model="loginForm" ref="formRef" @keypress.enter="handleAuthorize(true)"> | ||||
|     此第三方应用请求获取以下权限: | ||||
|     <Row class="enter-x"> | ||||
|       <Col :span="12"> | ||||
|         <template v-for="scope in params.scopes" :key="scope"> | ||||
|           <FormItem> | ||||
|             <!-- No logic, you need to deal with it yourself --> | ||||
|             <Checkbox :checked="scope" size="small"> | ||||
|               <Button type="link" size="small"> | ||||
|                 {{ formatScope(scope) }} | ||||
|               </Button> | ||||
|             </Checkbox> | ||||
|           </FormItem> | ||||
|         </template> | ||||
|       </Col> | ||||
|     </Row> | ||||
| 
 | ||||
|     <FormItem class="enter-x"> | ||||
|       <Button type="primary" size="large" block @click="handleAuthorize(true)" :loading="loading"> | ||||
|         {{ t('sys.login.loginButton') }} | ||||
|       </Button> | ||||
|       <Button size="large" class="mt-4 enter-x" block @click="handleAuthorize(false)"> | ||||
|         {{ t('common.cancelText') }} | ||||
|       </Button> | ||||
|     </FormItem> | ||||
|   </Form> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { reactive, ref } from 'vue' | ||||
| import { useRoute } from 'vue-router' | ||||
| import { Checkbox, Form, Row, Col, Button } from 'ant-design-vue' | ||||
| 
 | ||||
| import { useI18n } from '@/hooks/web/useI18n' | ||||
| import { useMessage } from '@/hooks/web/useMessage' | ||||
| 
 | ||||
| import { useFormValid } from './useLogin' | ||||
| import { useDesign } from '@/hooks/web/useDesign' | ||||
| import { authorize, getAuthorize } from '@/api/base/login' | ||||
| import { onMounted } from 'vue' | ||||
| 
 | ||||
| const FormItem = Form.Item | ||||
| 
 | ||||
| const { t } = useI18n() | ||||
| const { query } = useRoute() | ||||
| const { notification, createErrorModal } = useMessage() | ||||
| const { prefixCls } = useDesign('login') | ||||
| 
 | ||||
| const formRef = ref() | ||||
| const loading = ref(false) | ||||
| 
 | ||||
| const loginForm = reactive({ | ||||
|   scopes: [] as any[] // 已选中的 scope 数组 | ||||
| }) | ||||
| 
 | ||||
| // URL 上的 client_id、scope 等参数 | ||||
| const params = reactive({ | ||||
|   responseType: undefined as any, | ||||
|   clientId: undefined as any, | ||||
|   redirectUri: undefined as any, | ||||
|   state: undefined as any, | ||||
|   scopes: [] as any[] // 优先从 query 参数获取;如果未传递,从后端获取 | ||||
| }) | ||||
| 
 | ||||
| // 客户端信息 | ||||
| let client = reactive({ | ||||
|   name: '', | ||||
|   logo: '' | ||||
| }) | ||||
| 
 | ||||
| const { validForm } = useFormValid(formRef) | ||||
| 
 | ||||
| async function init() { | ||||
|   // 解析参数 | ||||
|   // 例如说【自动授权不通过】: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 | ||||
|   params.responseType = query.response_type as any | ||||
|   params.clientId = query.client_id as any | ||||
|   params.redirectUri = query.redirect_uri as any | ||||
|   params.state = query.state as any | ||||
|   if (query.scope) { | ||||
|     params.scopes = (query.scope as any).split(' ') | ||||
|   } | ||||
| 
 | ||||
|   // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。 | ||||
|   if (params.scopes.length > 0) { | ||||
|     const res = await doAuthorize(true, params.scopes, []) | ||||
|     const href = res | ||||
|     if (!href) { | ||||
|       console.log('自动授权未通过!') | ||||
|       return | ||||
|     } | ||||
|     location.href = href | ||||
|   } | ||||
| 
 | ||||
|   // 获取授权页的基本信息 | ||||
|   const res = await getAuthorize(params.clientId) | ||||
|   client = res.client | ||||
|   // 解析 scope | ||||
|   let scopes | ||||
|   // 1.1 如果 params.scope 非空,则过滤下返回的 scopes | ||||
|   if (params.scopes.length > 0) { | ||||
|     scopes = [] | ||||
|     for (const scope of res.scopes) { | ||||
|       if (params.scopes.indexOf(scope.key) >= 0) { | ||||
|         scopes.push(scope) | ||||
|       } | ||||
|     } | ||||
|     // 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它 | ||||
|   } else { | ||||
|     scopes = res.data.scopes | ||||
|     for (const scope of scopes) { | ||||
|       params.scopes.push(scope.key) | ||||
|     } | ||||
|   } | ||||
|   // 生成已选中的 checkedScopes | ||||
|   for (const scope of scopes) { | ||||
|     if (scope.value) { | ||||
|       loginForm.scopes.push(scope.key) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function handleAuthorize(approved) { | ||||
|   const data = await validForm() | ||||
|   if (!data) return | ||||
|   try { | ||||
|     loading.value = true | ||||
|     // 计算 checkedScopes + uncheckedScopes | ||||
|     let checkedScopes | ||||
|     let uncheckedScopes | ||||
|     if (approved) { | ||||
|       // 同意授权,按照用户的选择 | ||||
|       checkedScopes = loginForm.scopes | ||||
|       uncheckedScopes = params.scopes.filter((item) => checkedScopes.indexOf(item) === -1) | ||||
|     } else { | ||||
|       // 拒绝,则都是取消 | ||||
|       checkedScopes = [] | ||||
|       uncheckedScopes = params.scopes | ||||
|     } | ||||
|     // 提交授权的请求 | ||||
|     const res = await doAuthorize(false, checkedScopes, uncheckedScopes) | ||||
|     if (res) { | ||||
|       const href = res | ||||
|       if (!href) { | ||||
|         return | ||||
|       } | ||||
|       location.href = href | ||||
|       notification.success({ | ||||
|         message: t('sys.login.loginSuccessTitle'), | ||||
|         description: `${t('sys.login.loginSuccessDesc')}`, | ||||
|         duration: 3 | ||||
|       }) | ||||
|     } | ||||
|   } catch (error) { | ||||
|     createErrorModal({ | ||||
|       title: t('sys.api.errorTip'), | ||||
|       content: (error as unknown as Error).message || t('sys.api.networkExceptionMsg'), | ||||
|       getContainer: () => document.body.querySelector(`.${prefixCls}`) || document.body | ||||
|     }) | ||||
|   } finally { | ||||
|     loading.value = false | ||||
|   } | ||||
| } | ||||
| async function doAuthorize(autoApprove, checkedScopes, uncheckedScopes) { | ||||
|   return await authorize( | ||||
|     params.responseType, | ||||
|     params.clientId, | ||||
|     params.redirectUri, | ||||
|     params.state, | ||||
|     autoApprove, | ||||
|     checkedScopes, | ||||
|     uncheckedScopes | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function formatScope(scope) { | ||||
|   // 格式化 scope 授权范围,方便用户理解。 | ||||
|   // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。 | ||||
|   switch (scope) { | ||||
|     case 'user.read': | ||||
|       return t('sys.login.ssoInfoDesc') | ||||
|     case 'user.write': | ||||
|       return t('sys.login.ssoEditDesc') | ||||
|     default: | ||||
|       return scope | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
|   init() | ||||
| }) | ||||
| </script> | ||||
|  | @ -0,0 +1,204 @@ | |||
| <template> | ||||
|   <div :class="prefixCls" class="relative w-full h-full px-4"> | ||||
|     <div class="flex items-center absolute right-4 top-4"> | ||||
|       <AppDarkModeToggle class="enter-x mr-2" v-if="!sessionTimeout" /> | ||||
|       <AppLocalePicker class="text-white enter-x xl:text-gray-600" :show-text="false" v-if="!sessionTimeout && showLocale" /> | ||||
|     </div> | ||||
| 
 | ||||
|     <span class="-enter-x xl:hidden"> | ||||
|       <AppLogo :alwaysShowTitle="true" /> | ||||
|     </span> | ||||
| 
 | ||||
|     <div class="container relative h-full py-2 mx-auto sm:px-10"> | ||||
|       <div class="flex h-full"> | ||||
|         <div class="hidden min-h-full pl-4 mr-4 xl:flex xl:flex-col xl:w-6/12"> | ||||
|           <AppLogo class="-enter-x" /> | ||||
|           <div class="my-auto"> | ||||
|             <img :alt="title" src="@/assets/svg/login-box-bg.svg" class="w-1/2 -mt-16 -enter-x" /> | ||||
|             <div class="mt-10 font-medium text-white -enter-x"> | ||||
|               <span class="inline-block mt-4 text-3xl"> {{ t('sys.login.signInTitle') }}</span> | ||||
|             </div> | ||||
|             <div class="mt-5 font-normal text-white dark:text-gray-500 -enter-x"> | ||||
|               {{ t('sys.login.signInDesc') }} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="flex w-full h-full py-5 xl:h-auto xl:py-0 xl:my-0 xl:w-6/12"> | ||||
|           <!-- eslint-disable max-len --> | ||||
|           <div | ||||
|             :class="`${prefixCls}-form`" | ||||
|             class="relative w-full px-5 py-8 mx-auto my-auto rounded-md shadow-md xl:ml-16 xl:bg-transparent sm:px-8 xl:p-4 xl:shadow-none sm:w-3/4 lg:w-2/4 xl:w-auto enter-x" | ||||
|           > | ||||
|             <SSOForm /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <script lang="ts" setup> | ||||
| import { computed } from 'vue' | ||||
| import { AppLogo } from '@/components/Application' | ||||
| import { AppLocalePicker, AppDarkModeToggle } from '@/components/Application' | ||||
| import SSOForm from './SSOForm.vue' | ||||
| import { useGlobSetting } from '@/hooks/setting' | ||||
| import { useI18n } from '@/hooks/web/useI18n' | ||||
| import { useDesign } from '@/hooks/web/useDesign' | ||||
| import { useLocaleStore } from '@/store/modules/locale' | ||||
| 
 | ||||
| defineProps({ | ||||
|   sessionTimeout: { | ||||
|     type: Boolean | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| const globSetting = useGlobSetting() | ||||
| const { prefixCls } = useDesign('login') | ||||
| const { t } = useI18n() | ||||
| const localeStore = useLocaleStore() | ||||
| const showLocale = localeStore.getShowPicker | ||||
| const title = computed(() => globSetting?.title ?? '') | ||||
| </script> | ||||
| <style lang="less"> | ||||
| @prefix-cls: ~'@{namespace}-login'; | ||||
| @logo-prefix-cls: ~'@{namespace}-app-logo'; | ||||
| @countdown-prefix-cls: ~'@{namespace}-countdown-input'; | ||||
| @dark-bg: #293146; | ||||
| 
 | ||||
| html[data-theme='dark'] { | ||||
|   .@{prefix-cls} { | ||||
|     background-color: @dark-bg; | ||||
| 
 | ||||
|     &::before { | ||||
|       background-image: url('@/assets/svg/login-bg-dark.svg'); | ||||
|     } | ||||
| 
 | ||||
|     .ant-input, | ||||
|     .ant-input-password { | ||||
|       background-color: #232a3b; | ||||
|     } | ||||
| 
 | ||||
|     .ant-btn:not(.ant-btn-link, .ant-btn-primary) { | ||||
|       border: 1px solid #4a5569; | ||||
|     } | ||||
| 
 | ||||
|     &-form { | ||||
|       background: transparent !important; | ||||
|     } | ||||
| 
 | ||||
|     .app-iconify { | ||||
|       color: #fff; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   input.fix-auto-fill, | ||||
|   .fix-auto-fill input { | ||||
|     -webkit-text-fill-color: #c9d1d9 !important; | ||||
|     box-shadow: inherit !important; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .@{prefix-cls} { | ||||
|   min-height: 100%; | ||||
|   overflow: hidden; | ||||
| 
 | ||||
|   @media (max-width: @screen-xl) { | ||||
|     background-color: #293146; | ||||
| 
 | ||||
|     .@{prefix-cls}-form { | ||||
|       background-color: #fff; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &::before { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     margin-left: -48%; | ||||
|     background-image: url('@/assets/svg/login-bg.svg'); | ||||
|     background-position: 100%; | ||||
|     background-repeat: no-repeat; | ||||
|     background-size: auto 100%; | ||||
|     content: ''; | ||||
| 
 | ||||
|     @media (max-width: @screen-xl) { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .@{logo-prefix-cls} { | ||||
|     position: absolute; | ||||
|     top: 12px; | ||||
|     height: 30px; | ||||
| 
 | ||||
|     &__title { | ||||
|       font-size: 16px; | ||||
|       color: #fff; | ||||
|     } | ||||
| 
 | ||||
|     img { | ||||
|       width: 32px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .container { | ||||
|     .@{logo-prefix-cls} { | ||||
|       display: flex; | ||||
|       width: 60%; | ||||
|       height: 80px; | ||||
| 
 | ||||
|       &__title { | ||||
|         font-size: 24px; | ||||
|         color: #fff; | ||||
|       } | ||||
| 
 | ||||
|       img { | ||||
|         width: 48px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &-sign-in-way { | ||||
|     .anticon { | ||||
|       font-size: 22px; | ||||
|       color: #888; | ||||
|       cursor: pointer; | ||||
| 
 | ||||
|       &:hover { | ||||
|         color: @primary-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   input:not([type='checkbox']) { | ||||
|     min-width: 360px; | ||||
| 
 | ||||
|     @media (max-width: @screen-xl) { | ||||
|       min-width: 320px; | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: @screen-lg) { | ||||
|       min-width: 260px; | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: @screen-md) { | ||||
|       min-width: 240px; | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: @screen-sm) { | ||||
|       min-width: 160px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .@{countdown-prefix-cls} input { | ||||
|     min-width: unset; | ||||
|   } | ||||
| 
 | ||||
|   .ant-divider-inner-text { | ||||
|     font-size: 12px; | ||||
|     color: @text-color-secondary; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
		Loading…
	
		Reference in New Issue
	
	 xingyu
						xingyu