增加SSO单点登录

This commit is contained in:
puhui999 2023-04-07 01:45:29 +08:00
parent f16ea74abe
commit 2d019f1bf9
12 changed files with 277 additions and 23 deletions

View File

@ -1,16 +1,19 @@
import request from '@/config/axios' import request from '@/config/axios'
import { getRefreshToken } from '@/utils/auth' import { getRefreshToken } from '@/utils/auth'
import type { UserLoginVO } from './types' import type { UserLoginVO } from './types'
import { service } from '@/config/axios/service'
export interface CodeImgResult { export interface CodeImgResult {
captchaOnOff: boolean captchaOnOff: boolean
img: string img: string
uuid: string uuid: string
} }
export interface SmsCodeVO { export interface SmsCodeVO {
mobile: string mobile: string
scene: number scene: number
} }
export interface SmsLoginVO { export interface SmsLoginVO {
mobile: string mobile: string
code: string code: string
@ -71,3 +74,44 @@ export const getCodeApi = (data) => {
export const reqCheckApi = (data) => { export const reqCheckApi = (data) => {
return request.postOriginal({ url: 'system/captcha/check', data }) return request.postOriginal({ url: 'system/captcha/check', data })
} }
// ========== OAUTH 2.0 相关 ==========
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: any,
uncheckedScopes: any
) {
// 构建 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'
})
}

View File

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

View File

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

View File

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

View File

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

View File

@ -23,13 +23,12 @@ declare module '@vue/runtime-core' {
DictTag: typeof import('./../components/DictTag/src/DictTag.vue')['default'] DictTag: typeof import('./../components/DictTag/src/DictTag.vue')['default']
Echart: typeof import('./../components/Echart/src/Echart.vue')['default'] Echart: typeof import('./../components/Echart/src/Echart.vue')['default']
Editor: typeof import('./../components/Editor/src/Editor.vue')['default'] Editor: typeof import('./../components/Editor/src/Editor.vue')['default']
ElAutoResizer: typeof import('element-plus/es')['ElAutoResizer']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge'] ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@ -54,34 +53,23 @@ declare module '@vue/runtime-core' {
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer'] ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink'] ElLink: typeof import('element-plus/es')['ElLink']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTableV2: typeof import('element-plus/es')['ElTableV2']
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] 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'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree'] ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
Error: typeof import('./../components/Error/src/Error.vue')['default'] Error: typeof import('./../components/Error/src/Error.vue')['default']
FlowCondition: typeof import('./../components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue')['default'] FlowCondition: typeof import('./../components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue')['default']

View File

@ -52,6 +52,11 @@
<QrCodeForm class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" /> <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)" /> <RegisterForm class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)" />
<!-- 三方登录 v-if触发组件初始化 -->
<SSOLoginVue
v-if="isSSO"
class="p-20px h-auto m-auto <xl:(rounded-3xl light:bg-white)"
/>
</div> </div>
</Transition> </Transition>
</div> </div>
@ -65,12 +70,25 @@ import { useDesign } from '@/hooks/web/useDesign'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { ThemeSwitch } from '@/layout/components/ThemeSwitch' import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
import { LocaleDropdown } from '@/layout/components/LocaleDropdown' import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
import { LoginForm, MobileForm, RegisterForm, QrCodeForm } from './components'
import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
import { RouteLocationNormalizedLoaded } from 'vue-router'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const { getPrefixCls } = useDesign() const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('login') const prefixCls = getPrefixCls('login')
// =======SSO======
const isSSO = ref(false)
const router = useRouter()
//
watch(
() => router.currentRoute.value,
(route: RouteLocationNormalizedLoaded) => {
if (route.name === 'SSOLogin') isSSO.value = true
},
{ immediate: true }
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -137,7 +137,7 @@ import { useIcon } from '@/hooks/web/useIcon'
import * as authUtil from '@/utils/auth' import * as authUtil from '@/utils/auth'
import { usePermissionStore } from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission'
import * as LoginApi from '@/api/login' import * as LoginApi from '@/api/login'
import { LoginStateEnum, useLoginState, useFormValid } from './useLogin' import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
const { t } = useI18n() const { t } = useI18n()
const message = useMessage() const message = useMessage()
@ -240,7 +240,12 @@ const handleLogin = async (params) => {
if (!redirect.value) { if (!redirect.value) {
redirect.value = '/' redirect.value = '/'
} }
// 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 }) push({ path: redirect.value || permissionStore.addRouters[0].path })
}
} catch { } catch {
loginLoading.value = false loginLoading.value = false
} finally { } finally {
@ -274,6 +279,7 @@ const doSocialLogin = async (type: number) => {
watch( watch(
() => currentRoute.value, () => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => { (route: RouteLocationNormalizedLoaded) => {
if (route.name === 'SSOLogin') setLoginState(LoginStateEnum.SSO)
redirect.value = route?.query?.redirect as string redirect.value = route?.query?.redirect as string
}, },
{ {
@ -291,6 +297,7 @@ onMounted(() => {
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
} }
.login-code { .login-code {
width: 100%; width: 100%;
height: 38px; height: 38px;

View File

@ -16,7 +16,8 @@ const getFormTitle = computed(() => {
[LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'), [LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'),
[LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'), [LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'),
[LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'), [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)] return titleObj[unref(getLoginState)]
}) })

View File

@ -0,0 +1,177 @@
<template>
<!-- 表单 -->
<div class="form-cont">
<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"
:label="scope"
:key="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"
type="primary"
style="width: 60%"
@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 { authorize, getAuthorize } from '@/api/login'
const { t } = useI18n()
const ssoForm = ref() // Ref
type scopesType = string[]
interface paramsType {
responseType: string
clientId: string
redirectUri: string
state: string
scopes: scopesType
}
const loginForm = reactive<{ scopes: scopesType }>({
scopes: [] // scope
})
const params = reactive<paramsType>({
// URL client_idscope
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
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) => {
console.log(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)
}
}
})
}
init()
</script>

View File

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

View File

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