Ver Fonte

!95 新增SSO登录
Merge pull request !95 from puhui999/dev

芋道源码 há 2 anos atrás
pai
commit
0f0ba8b8a9

+ 1 - 1
.env.dev

@@ -16,7 +16,7 @@ VITE_API_BASEPATH=/dev-api
 VITE_API_URL=/admin-api
 
 # 打包路径
-VITE_BASE_PATH=/dist-dev/
+VITE_BASE_PATH=/
 
 # 是否删除debugger
 VITE_DROP_DEBUGGER=false

+ 51 - 0
src/api/login/index.ts

@@ -1,16 +1,19 @@
 import request from '@/config/axios'
 import { getRefreshToken } from '@/utils/auth'
 import type { UserLoginVO } from './types'
+import { service } from '@/config/axios/service'
 
 export interface CodeImgResult {
   captchaOnOff: boolean
   img: string
   uuid: string
 }
+
 export interface SmsCodeVO {
   mobile: string
   scene: number
 }
+
 export interface SmsLoginVO {
   mobile: string
   code: string
@@ -71,3 +74,51 @@ export const getCode = (data) => {
 export const reqCheck = (data) => {
   return request.postOriginal({ url: 'system/captcha/check', data })
 }
+
+// ========== OAUTH 2.0 相关 ==========
+export type scopesType = string[]
+export interface paramsType {
+  responseType: string
+  clientId: string
+  redirectUri: string
+  state: string
+  scopes: scopesType
+}
+export const getAuthorize = (clientId) => {
+  return request.get({ url: '/system/oauth2/authorize?clientId=' + clientId })
+}
+
+export function authorize(
+  responseType: string,
+  clientId: string,
+  redirectUri: string,
+  state: string,
+  autoApprove: boolean,
+  checkedScopes: scopesType,
+  uncheckedScopes: scopesType
+) {
+  // 构建 scopes
+  const scopes = {}
+  for (const scope of checkedScopes) {
+    scopes[scope] = true
+  }
+  for (const scope of uncheckedScopes) {
+    scopes[scope] = false
+  }
+  // 发起请求
+  return service({
+    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)
+    },
+    method: 'post'
+  })
+}

+ 2 - 2
src/config/axios/service.ts

@@ -1,8 +1,8 @@
 import axios, {
+  AxiosError,
   AxiosInstance,
   AxiosRequestHeaders,
   AxiosResponse,
-  AxiosError,
   InternalAxiosRequestConfig
 } from 'axios'
 
@@ -230,7 +230,7 @@ const handleAuthorized = () => {
       wsCache.clear()
       removeToken()
       isRelogin.show = false
-      window.location.href = import.meta.env.VITE_BASE_PATH
+      window.location.href = '/login?redirect=/sso?' + window.location.href.split('?')[1]
     })
   }
   return Promise.reject(t('sys.api.timeoutMessage'))

+ 7 - 0
src/locales/zh-CN.ts

@@ -129,6 +129,12 @@ export default {
     btnMobile: '手机登录',
     btnQRCode: '二维码登录',
     qrcode: '扫描二维码登录',
+    sso: {
+      user: {
+        read: '访问你的个人信息',
+        write: '修改你的个人信息'
+      }
+    },
     btnRegister: '注册',
     SmsSendMsg: '验证码已发送'
   },
@@ -352,6 +358,7 @@ export default {
     login: {
       backSignIn: '返回',
       signInFormTitle: '登录',
+      ssoFormTitle: '三方授权',
       mobileSignInFormTitle: '手机登录',
       qrSignInFormTitle: '二维码登录',
       signUpFormTitle: '注册',

+ 2 - 2
src/router/index.ts

@@ -1,11 +1,11 @@
 import type { App } from 'vue'
 import type { RouteRecordRaw } from 'vue-router'
-import { createRouter, createWebHashHistory } from 'vue-router'
+import { createRouter, createWebHistory } from 'vue-router'
 import remainingRouter from './modules/remaining'
 
 // 创建路由实例
 const router = createRouter({
-  history: createWebHashHistory(), // createWebHashHistory URL带#,createWebHistory URL不带#
+  history: createWebHistory(), // createWebHashHistory URL带#,createWebHistory URL不带#
   strict: true,
   routes: remainingRouter as RouteRecordRaw[],
   scrollBehavior: () => ({ left: 0, top: 0 })

+ 10 - 0
src/router/modules/remaining.ts

@@ -185,6 +185,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
       noTagsView: true
     }
   },
+  {
+    path: '/sso',
+    component: () => import('@/views/Login/Login.vue'),
+    name: 'SSOLogin',
+    meta: {
+      hidden: true,
+      title: t('router.login'),
+      noTagsView: true
+    }
+  },
   {
     path: '/403',
     component: () => import('@/views/Error/403.vue'),

+ 1 - 6
src/types/auto-components.d.ts

@@ -21,15 +21,14 @@ declare module '@vue/runtime-core' {
     Descriptions: typeof import('./../components/Descriptions/src/Descriptions.vue')['default']
     Dialog: typeof import('./../components/Dialog/src/Dialog.vue')['default']
     DictTag: typeof import('./../components/DictTag/src/DictTag.vue')['default']
-    DocAlert: typeof import('./../components/DocAlert/index.vue')['default']
     Echart: typeof import('./../components/Echart/src/Echart.vue')['default']
     Editor: typeof import('./../components/Editor/src/Editor.vue')['default']
-    ElAlert: typeof import('element-plus/es')['ElAlert']
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -74,12 +73,8 @@ declare module '@vue/runtime-core' {
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
-    ElTag: typeof import('element-plus/es')['ElTag']
-    ElTimeline: typeof import('element-plus/es')['ElTimeline']
-    ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElTree: typeof import('element-plus/es')['ElTree']
-    ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     Error: typeof import('./../components/Error/src/Error.vue')['default']
     FlowCondition: typeof import('./../components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue')['default']

+ 11 - 8
src/views/Login/Login.vue

@@ -9,19 +9,19 @@
       >
         <!-- 左上角的 logo + 系统标题 -->
         <div class="flex items-center relative text-white">
-          <img src="@/assets/imgs/logo.png" alt="" class="w-48px h-48px mr-10px" />
+          <img alt="" class="w-48px h-48px mr-10px" src="@/assets/imgs/logo.png" />
           <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
         </div>
         <!-- 左边的背景图 + 欢迎语 -->
         <div class="flex justify-center items-center h-[calc(100%-60px)]">
           <TransitionGroup
             appear
-            tag="div"
             enter-active-class="animate__animated animate__bounceInLeft"
+            tag="div"
           >
-            <img src="@/assets/svgs/login-box-bg.svg" key="1" alt="" class="w-350px" />
-            <div class="text-3xl text-white" key="2">{{ t('login.welcome') }}</div>
-            <div class="mt-5 font-normal text-white text-14px" key="3">
+            <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" />
+            <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
+            <div key="3" class="mt-5 font-normal text-white text-14px">
               {{ t('login.message') }}
             </div>
           </TransitionGroup>
@@ -31,7 +31,7 @@
         <!-- 右上角的主题、语言选择 -->
         <div class="flex justify-between items-center text-white @2xl:justify-end @xl:justify-end">
           <div class="flex items-center @2xl:hidden @xl:hidden">
-            <img src="@/assets/imgs/logo.png" alt="" class="w-48px h-48px mr-10px" />
+            <img alt="" class="w-48px h-48px mr-10px" src="@/assets/imgs/logo.png" />
             <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
           </div>
           <div class="flex justify-end items-center space-x-10px">
@@ -52,20 +52,23 @@
             <QrCodeForm class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
             <!-- 注册 -->
             <RegisterForm class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
+            <!-- 三方登录 -->
+            <SSOLoginVue class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
           </div>
         </Transition>
       </div>
     </div>
   </div>
 </template>
-<script setup lang="ts">
+<script lang="ts" setup>
 import { underlineToHump } from '@/utils'
 
 import { useDesign } from '@/hooks/web/useDesign'
 import { useAppStore } from '@/store/modules/app'
 import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
 import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
-import { LoginForm, MobileForm, RegisterForm, QrCodeForm } from './components'
+
+import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
 
 const { t } = useI18n()
 const appStore = useAppStore()

+ 8 - 2
src/views/Login/components/LoginForm.vue

@@ -137,7 +137,7 @@ import { useIcon } from '@/hooks/web/useIcon'
 import * as authUtil from '@/utils/auth'
 import { usePermissionStore } from '@/store/modules/permission'
 import * as LoginApi from '@/api/login'
-import { LoginStateEnum, useLoginState, useFormValid } from './useLogin'
+import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
 
 const { t } = useI18n()
 const message = useMessage()
@@ -240,7 +240,12 @@ const handleLogin = async (params) => {
     if (!redirect.value) {
       redirect.value = '/'
     }
-    push({ path: redirect.value || permissionStore.addRouters[0].path })
+    // 判断是否为SSO登录
+    if (redirect.value.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect.value || permissionStore.addRouters[0].path })
+    }
   } catch {
     loginLoading.value = false
   } finally {
@@ -291,6 +296,7 @@ onMounted(() => {
     color: var(--el-color-primary) !important;
   }
 }
+
 .login-code {
   width: 100%;
   height: 38px;

+ 2 - 1
src/views/Login/components/LoginFormTitle.vue

@@ -16,7 +16,8 @@ const getFormTitle = computed(() => {
     [LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'),
     [LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'),
     [LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'),
-    [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle')
+    [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle'),
+    [LoginStateEnum.SSO]: t('sys.login.ssoFormTitle')
   }
   return titleObj[unref(getLoginState)]
 })

+ 187 - 0
src/views/Login/components/SSOLogin.vue

@@ -0,0 +1,187 @@
+<template>
+  <!-- 表单 -->
+  <div v-show="getShow" class="form-cont">
+    <!--    <LoginFormTitle style="width: 100%" />-->
+    <el-tabs class="form" style="float: none" value="uname">
+      <el-tab-pane :label="'三方授权(' + client.name + ')'" name="uname" />
+    </el-tabs>
+    <div>
+      <el-form ref="ssoForm" :model="loginForm" class="login-form">
+        <!-- 授权范围的选择 -->
+        此第三方应用请求获得以下权限:
+        <el-form-item prop="scopes">
+          <el-checkbox-group v-model="loginForm.scopes">
+            <el-checkbox
+              v-for="scope in params.scopes"
+              :key="scope"
+              :label="scope"
+              style="display: block; margin-bottom: -10px"
+              >{{ formatScope(scope) }}
+            </el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        <!-- 下方的登录按钮 -->
+        <el-form-item style="width: 100%">
+          <el-button
+            :loading="loading"
+            size="small"
+            style="width: 60%"
+            type="primary"
+            @click.prevent="handleAuthorize(true)"
+          >
+            <span v-if="!loading">同意授权</span>
+            <span v-else>授 权 中...</span>
+          </el-button>
+          <el-button size="small" style="width: 36%" @click.prevent="handleAuthorize(false)"
+            >拒绝
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+<script lang="ts" name="SSOLogin" setup>
+// import LoginFormTitle from './LoginFormTitle.vue' // TODO 艿艿你看看要不要这个表头
+import { authorize, getAuthorize, paramsType, scopesType } from '@/api/login'
+import { LoginStateEnum, useLoginState } from './useLogin'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+
+const { t } = useI18n()
+const ssoForm = ref() // 表单Ref
+const { getLoginState, setLoginState } = useLoginState()
+const getShow = computed(() => unref(getLoginState) === LoginStateEnum.SSO)
+const loginForm = reactive<{ scopes: scopesType }>({
+  scopes: [] // 已选中的 scope 数组
+})
+const params = reactive<paramsType>({
+  // URL 上的 client_id、scope 等参数
+  responseType: '',
+  clientId: '',
+  redirectUri: '',
+  state: '',
+  scopes: [] // 优先从 query 参数获取;如果未传递,从后端获取
+}) // 表单Ref
+const client = ref({
+  // 客户端信息
+  name: '',
+  logo: ''
+})
+const loading = ref(false)
+const handleAuthorize = (approved) => {
+  ssoForm.value.validate((valid) => {
+    if (!valid) {
+      return
+    }
+    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
+    }
+    // 提交授权的请求
+    doAuthorize(false, checkedScopes, uncheckedScopes)
+      .then((res) => {
+        const href = res.data
+        if (!href) {
+          return
+        }
+        location.href = href
+      })
+      .finally(() => {
+        loading.value = false
+      })
+  })
+}
+const doAuthorize = (autoApprove, checkedScopes, uncheckedScopes) => {
+  return authorize(
+    params.responseType,
+    params.clientId,
+    params.redirectUri,
+    params.state,
+    autoApprove,
+    checkedScopes,
+    uncheckedScopes
+  )
+}
+const formatScope = (scope) => {
+  // 格式化 scope 授权范围,方便用户理解。
+  // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。
+  // TODO 这个之做了中文部分
+  return t(`login.sso.${scope}`)
+}
+const route = useRoute()
+const init = () => {
+  // 防止在没有登录的情况下循环弹窗
+  if (typeof route.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
+  params.responseType = route.query.response_type as string
+  params.clientId = route.query.client_id as string
+  params.redirectUri = route.query.redirect_uri as string
+  params.state = route.query.state as string
+  if (route.query.scope) {
+    params.scopes = (route.query.scope as string).split(' ')
+  }
+
+  // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。
+  if (params.scopes.length > 0) {
+    doAuthorize(true, params.scopes, []).then((res) => {
+      if (!res) {
+        console.log('自动授权未通过!')
+        return
+      }
+      location.href = res.data
+    })
+  }
+
+  // 获取授权页的基本信息
+  getAuthorize(params.clientId).then((res) => {
+    client.value = 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.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)
+      }
+    }
+  })
+}
+// =======SSO======
+const { currentRoute } = useRouter()
+// 监听当前路由
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    if (route.name === 'SSOLogin') {
+      setLoginState(LoginStateEnum.SSO)
+      init()
+    }
+  },
+  { immediate: true }
+)
+init()
+</script>

+ 2 - 1
src/views/Login/components/index.ts

@@ -3,5 +3,6 @@ import MobileForm from './MobileForm.vue'
 import LoginFormTitle from './LoginFormTitle.vue'
 import RegisterForm from './RegisterForm.vue'
 import QrCodeForm from './QrCodeForm.vue'
+import SSOLoginVue from './SSOLogin.vue'
 
-export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm }
+export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue }

+ 2 - 1
src/views/Login/components/useLogin.ts

@@ -5,7 +5,8 @@ export enum LoginStateEnum {
   REGISTER,
   RESET_PASSWORD,
   MOBILE,
-  QR_CODE
+  QR_CODE,
+  SSO
 }
 
 const currentState = ref(LoginStateEnum.LOGIN)