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