feat: sso init

pull/21/MERGE
xingyu 2023-06-08 14:45:25 +08:00
parent 0c35370f58
commit 39732ad3b2
8 changed files with 452 additions and 0 deletions

View File

@ -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)
}
})
}

View File

@ -1,5 +1,6 @@
export default {
login: 'Login',
sso: 'SSO Login',
errorLogList: 'Error Log',
profile: 'User Center',
notifyMessage: 'Notify Message'

View File

@ -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',

View File

@ -1,5 +1,6 @@
export default {
login: '登录',
sso: '第三方授权登录',
errorLogList: '错误日志列表',
profile: '个人中心',
notifyMessage: '站内信'

View File

@ -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: '欢迎回来',

View File

@ -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,

View File

@ -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_idscope
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>

View File

@ -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>