feat(X-Pack): Add OAuth2 authentication mechanism

This commit is contained in:
fit2cloud-chenyw
2025-11-18 10:14:59 +08:00
committed by fit2cloud-chenyw
parent 6d406f8cb0
commit d1440ca1c3
9 changed files with 286 additions and 75 deletions
+6
View File
@@ -7,7 +7,13 @@
export {}
declare global {
const ElButton: typeof import('element-plus-secondary/es')['ElButton']
const ElCheckbox: typeof import('element-plus-secondary/es')['ElCheckbox']
const ElDatePicker: typeof import('element-plus-secondary/es')['ElDatePicker']
const ElInput: typeof import('element-plus-secondary/es')['ElInput']
const ElMessage: typeof import('element-plus-secondary/es')['ElMessage']
const ElMessageBox: typeof import('element-plus-secondary/es')['ElMessageBox']
const ElRadio: typeof import('element-plus-secondary/es')['ElRadio']
const ElRadioGroup: typeof import('element-plus-secondary/es')['ElRadioGroup']
const ElSelect: typeof import('element-plus-secondary/es')['ElSelect']
const LicenseGenerator: any
}
+11 -1
View File
@@ -710,7 +710,17 @@
"field_mapping": "User Attribute Mapping",
"field_mapping_placeholder": "Example: {'{'}\"account\": \"saml2Account\", \"name\": \"saml2Name\", \"email\": \"email\"{'}'}",
"incorrect_please_re_enter": "Incorrect format, please re-enter",
"in_json_format": "Please enter JSON format"
"in_json_format": "Please enter JSON format",
"authorize_url": "Authorization URL",
"client_id": "Client ID",
"client_secret": "Client Secret",
"redirect_url": "Redirect URL",
"oauth2_settings": "OAuth2 Settings",
"scope": "Scope",
"userinfo_url": "User Info URL",
"token_url": "Token URL",
"revoke_url": "Revocation URL",
"oauth2_field_mapping_placeholder": "Example: {'{'}\"account\": \"OAuth2Account\", \"name\": \"OAuth2Name\", \"email\": \"email\"{'}'}"
},
"login": {
"default_login": "Default",
+11 -1
View File
@@ -710,7 +710,17 @@
"field_mapping": "사용자 속성 매핑",
"field_mapping_placeholder": "예: {'{'}\"account\": \"saml2Account\", \"name\": \"saml2Name\", \"email\": \"email\"{'}'}",
"incorrect_please_re_enter": "형식이 잘못되었습니다. 다시 입력해 주세요",
"in_json_format": "JSON 형식으로 입력해 주세요"
"in_json_format": "JSON 형식으로 입력해 주세요",
"authorize_url": "인증 URL",
"client_id": "클라이언트 ID",
"client_secret": "클라이언트 시크릿",
"redirect_url": "리디렉션 URL",
"oauth2_settings": "OAuth2 설정",
"scope": "권한 범위",
"userinfo_url": "사용자 정보 URL",
"token_url": "토큰 URL",
"revoke_url": "취소 URL",
"oauth2_field_mapping_placeholder": "예: {'{'}\"account\": \"OAuth2Account\", \"name\": \"OAuth2Name\", \"email\": \"email\"{'}'}"
},
"login": {
"default_login": "기본값",
+11 -1
View File
@@ -710,7 +710,17 @@
"field_mapping": "用户属性映射",
"field_mapping_placeholder": "例如:{'{'}\"account\": \"saml2Account\", \"name\": \"saml2Name\", \"email\": \"email\"{'}'}",
"incorrect_please_re_enter": "格式错误,请重新填写",
"in_json_format": "请输入json格式"
"in_json_format": "请输入json格式",
"authorize_url": "授权地址",
"client_id": "客户端 ID",
"client_secret": "客户端密钥",
"redirect_url": "回调地址",
"oauth2_settings": "OAuth2 设置",
"scope": "授权范围",
"userinfo_url": "用户信息地址",
"token_url": "令牌地址",
"revoke_url": "撤销地址",
"oauth2_field_mapping_placeholder": "例如:{'{'}\"account\": \"oauth2Account\", \"name\": \"oauth2Name\", \"email\": \"email\"{'}'}"
},
"login": {
"default_login": "默认",
+5
View File
@@ -213,6 +213,11 @@ export const getQueryString = (name: string) => {
return null
}
export const getUrlParams = () => {
const urlParams = new URLSearchParams(window.location.search) as any
return Object.fromEntries(urlParams)
}
export const isLarkPlatform = () => {
return !!getQueryString('state') && !!getQueryString('code')
}
+47 -5
View File
@@ -20,8 +20,8 @@
:ldap="loginCategory.ldap"
@status-change="qrStatusChange"
/>
<Oidc v-if="loginCategory.oidc" @switch-category="switcherCategory" />
<Oauth2 v-if="loginCategory.oauth2" ref="oauth2Handler" @switch-category="switcherCategory" /> -->
<Oidc v-if="loginCategory.oidc" @switch-category="switcherCategory" /> -->
<Oauth2 v-if="loginCategory.oauth2" ref="oauth2Handler" @switch-category="switcherCategory" />
<Cas v-if="loginCategory.cas" @switch-category="switcherCategory" />
<!-- <Saml2 v-if="loginCategory.saml2" ref="saml2Handler" @switch-category="switcherCategory" /> -->
</div>
@@ -38,13 +38,14 @@ import Oidc from './Oidc.vue'
import Oauth2 from './Oauth2.vue'
import Saml2 from './Saml2.vue' */
import Cas from './Cas.vue'
import Oauth2 from './Oauth2.vue'
// import QrTab from './QrTab.vue'
import { request } from '@/utils/request'
import { useCache } from '@/utils/useCache'
import router from '@/router'
import { useUserStore } from '@/stores/user.ts'
import { getQueryString, isPlatformClient } from '@/utils/utils'
import { getQueryString, getUrlParams, isPlatformClient } from '@/utils/utils'
import { loadClient, type LoginCategory } from './PlatformClient'
// import MfaStep from './MfaStep.vue'
// import { logoutHandler } from '@/utils/logout'
@@ -150,7 +151,10 @@ const switcherCategory = (param: Categoryparam) => {
}
const nextPage = curOrigin + pathname + proxy + curLocation
if (category === 'oauth2') {
oauth2Handler?.value?.toLoginPage()
request.get('/system/authentication/login/4').then((res: any) => {
window.location.href = res
window.open(res, '_self')
})
return
}
if (category === 'saml2') {
@@ -176,9 +180,12 @@ const getCurLocation = () => {
}
const casLogin = () => {
const urlParams = getUrlParams()
const ticket = getQueryString('ticket')
/* request
.get('/system/authentication/sso/cas?ticket=' + ticket) */
request
.get('/system/authentication/sso/cas?ticket=' + ticket)
.post('/system/authentication/sso/1', urlParams)
.then((res: any) => {
const token = res.access_token
if (token && isPlatformClient()) {
@@ -207,6 +214,39 @@ const casLogin = () => {
}, 1500)
})
}
const oauth2Login = () => {
const urlParams = getUrlParams()
request
.post('/system/authentication/sso/4', urlParams)
.then((res: any) => {
const token = res.access_token
const id_token = res.id_token
if (token && isPlatformClient()) {
wsCache.set('de-platform-client', true)
}
userStore.setToken(token)
userStore.setExp(res.exp)
userStore.setTime(Date.now())
userStore.setPlatformInfo({
flag: 'oauth2',
data: id_token,
origin: 4,
})
const queryRedirectPath = getCurLocation()
router.push({ path: queryRedirectPath })
})
.catch((e: any) => {
userStore.setToken('')
setTimeout(() => {
// logoutHandler(true, true)
platformLoginMsg.value = e?.message || e
setTimeout(() => {
window.location.href =
window.location.origin + window.location.pathname + window.location.hash
}, 2000)
}, 1500)
})
}
/* const platformLogin = (origin: number) => {
const url = '/system/authentication/sso/cas'
request
@@ -384,6 +424,8 @@ onMounted(() => {
if (state?.includes('cas') && getQueryString('ticket')) {
// platformLogin(1)
casLogin()
} else if (state?.includes('oauth2')) {
oauth2Login()
} else {
updateLoading(false)
}
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="item CAS" @click="execute">
<el-icon>
<Icon name="logo_oauth"><logo_oauth class="svg-icon" /></Icon>
</el-icon>
<span class="name"> OAuth2 </span>
</div>
</template>
<script lang="ts" setup>
import logo_oauth from '@/assets/svg/logo_oauth.svg'
import { Icon } from '@/components/icon-custom'
const emits = defineEmits(['switch-category'])
const execute = () => {
emits('switch-category', { category: 'oauth2', proxy: '/#' })
}
</script>
<style lang="less" scoped>
.item {
width: 32px;
cursor: pointer;
&.qrcode,
&.account {
.ed-icon {
padding: 5px;
}
}
.ed-icon {
font-size: 32px;
border: 1px solid #dee0e3;
border-radius: 50%;
}
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-between;
.name {
margin-top: 8px;
color: #000;
text-align: center;
font-family: var(--de-custom_font, 'PingFang');
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
display: none;
}
}
</style>
@@ -1,36 +1,71 @@
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import { ref, reactive, onBeforeMount } from 'vue'
import { ElMessage, ElLoading } from 'element-plus-secondary'
import { useI18n } from 'vue-i18n'
import type { FormInstance, FormRules } from 'element-plus-secondary'
import {
type FormInstance,
type FormRules,
ElInput,
ElRadio,
ElRadioGroup,
ElSelect,
} from 'element-plus-secondary'
import { request } from '@/utils/request'
const { t } = useI18n()
const dialogVisible = ref(false)
const loadingInstance = ref<ReturnType<typeof ElLoading.service> | null>(null)
const oauth2Form = ref<FormInstance>()
interface Oauth2Form {
authEndpoint?: string
tokenEndpoint?: string
userInfoEndpoint?: string
scope?: string
clientId?: string
clientSecret?: string
redirectUri?: string
mapping?: string
authMethod?: string
}
const id = ref<number | null>(null)
const state = reactive({
form: reactive<Oauth2Form>({
tokenEndpoint: '',
userInfoEndpoint: '',
form: reactive<any>({
authorize_url: '',
token_url: '',
userinfo_url: '',
revoke_url: '',
scope: '',
clientId: '',
clientSecret: '',
redirectUri: '',
client_id: '',
client_secret: '',
redirect_url: '',
mapping: '',
authMethod: '0',
}),
})
const componentMap = {
'el-input': ElInput,
'el-select': ElSelect,
'el-radio-group': ElRadioGroup,
'el-radio': ElRadio,
} as any
const getComponent = (name: string) => {
return componentMap[name]
}
const form_config_list = ref<any[]>([
/* {
label: '自定义配置',
field: 'authMethod',
value: '',
// component: resolveComponent('ElInput') as typeof ElInput,
component: 'el-input',
attrs: {
placeholder: t('common.please_input') + 123,
},
validator: [
{
required: true,
message: t('common.require'),
trigger: 'blur',
},
{
min: 10,
max: 255,
message: t('commons.input_limit', [10, 255]),
trigger: 'blur',
},
{ required: true, pattern: /^[a-zA-Z][a-zA-Z0-9_]{3,15}$/, message: '', trigger: 'blur' },
],
}, */
])
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const validateUrl = (rule, value, callback) => {
@@ -50,12 +85,13 @@ const validateMapping = (rule, value, callback) => {
try {
JSON.parse(value)
} catch (e) {
console.error(e)
callback(new Error(t('system.in_json_format')))
}
callback()
}
const rule = reactive<FormRules>({
authEndpoint: [
authorize_url: [
{
required: true,
message: t('common.require'),
@@ -69,7 +105,7 @@ const rule = reactive<FormRules>({
},
{ required: true, validator: validateUrl, trigger: 'blur' },
],
tokenEndpoint: [
token_url: [
{
required: true,
message: t('common.require'),
@@ -83,7 +119,7 @@ const rule = reactive<FormRules>({
},
{ required: true, validator: validateUrl, trigger: 'blur' },
],
userInfoEndpoint: [
userinfo_url: [
{
required: true,
message: t('common.require'),
@@ -99,11 +135,6 @@ const rule = reactive<FormRules>({
],
scope: [
{
required: true,
message: t('common.require'),
trigger: 'blur',
},
{
min: 2,
max: 50,
@@ -111,7 +142,7 @@ const rule = reactive<FormRules>({
trigger: 'blur',
},
],
clientId: [
client_id: [
{
required: true,
message: t('common.require'),
@@ -124,7 +155,7 @@ const rule = reactive<FormRules>({
trigger: 'blur',
},
],
clientSecret: [
client_secret: [
{
required: true,
message: t('common.require'),
@@ -137,7 +168,7 @@ const rule = reactive<FormRules>({
trigger: 'blur',
},
],
redirectUri: [
redirect_url: [
{
required: true,
message: t('common.require'),
@@ -157,15 +188,16 @@ const rule = reactive<FormRules>({
const edit = () => {
showLoading()
request
.get('/setting/authentication/info/oauth2')
.get('/system/authentication/4')
.then((res) => {
const resData = res as Partial<Oauth2Form>
;(Object.keys(resData) as (keyof Oauth2Form)[]).forEach((key) => {
const value = resData[key]
if (value !== undefined) {
state.form[key] = value as any
}
})
if (!res?.config) {
return
}
id.value = res.id
const data = JSON.parse(res.config)
for (const key in data) {
state.form[key] = data[key] as any
}
})
.finally(() => {
closeLoading()
@@ -179,7 +211,15 @@ const submitForm = async (formEl: FormInstance | undefined) => {
await formEl.validate((valid) => {
if (valid) {
const param = { ...state.form }
const method = request.post('/setting/authentication/save/oauth2', param)
const data = {
id: 4,
type: 4,
config: JSON.stringify(param),
name: 'oauth2',
}
const method = id.value
? request.put('/system/authentication', data)
: request.post('/system/authentication', data)
showLoading()
method
.then((res) => {
@@ -200,6 +240,7 @@ const submitForm = async (formEl: FormInstance | undefined) => {
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.resetFields()
id.value = null
dialogVisible.value = false
}
@@ -217,16 +258,21 @@ const closeLoading = () => {
}
const validate = () => {
const url = '/setting/authentication/validate/oauth2'
const data = state.form
const url = '/system/authentication/status'
const config_data = state.form
const data = {
type: 4,
name: 'oauth2',
config: JSON.stringify(config_data),
}
showLoading()
request
.post(url, data)
.patch(url, data)
.then((res) => {
if (res === 'true') {
ElMessage.success(t('commons.test_connect') + t('report.last_status_success'))
if (res) {
ElMessage.success(t('ds.connection_success'))
} else {
ElMessage.error(t('commons.test_connect') + t('report.last_status_fail'))
ElMessage.error(t('ds.connection_failed'))
}
})
.finally(() => {
@@ -238,12 +284,21 @@ const validate = () => {
defineExpose({
edit,
})
onBeforeMount(() => {
if (form_config_list.value?.length) {
form_config_list.value.forEach((item: any) => {
rule[item.field] = item.validator
state.form[item.field] = item.value
})
}
})
</script>
<template>
<el-drawer
v-model="dialogVisible"
:title="t('system.oauth2_settings')"
:title="t('authentication.oauth2_settings')"
modal-class="platform-info-drawer"
size="600px"
direction="rtl"
@@ -256,56 +311,75 @@ defineExpose({
label-width="80px"
label-position="top"
>
<el-form-item :label="t('system.authorization_end_address')" prop="authEndpoint">
<el-input v-model="state.form.authEndpoint" :placeholder="t('common.please_input')" />
<el-form-item :label="t('authentication.authorize_url')" prop="authorize_url">
<el-input v-model="state.form.authorize_url" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('system.token_end_address')" prop="tokenEndpoint">
<el-input v-model="state.form.tokenEndpoint" :placeholder="t('common.please_input')" />
<el-form-item :label="t('authentication.token_url')" prop="token_url">
<el-input v-model="state.form.token_url" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('system.information_end_address')" prop="userInfoEndpoint">
<el-input v-model="state.form.userInfoEndpoint" :placeholder="t('common.please_input')" />
<el-form-item :label="t('authentication.userinfo_url')" prop="userinfo_url">
<el-input v-model="state.form.userinfo_url" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('authentication.revoke_url')" prop="revoke_url">
<el-input v-model="state.form.revoke_url" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('system.connection_range')" prop="scope">
<el-form-item :label="t('authentication.scope')" prop="scope">
<el-input v-model="state.form.scope" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('system.client_id')" prop="clientId">
<el-input v-model="state.form.clientId" :placeholder="t('common.please_input')" />
<el-form-item :label="t('authentication.client_id')" prop="client_id">
<el-input v-model="state.form.client_id" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('system.client_key')" prop="clientSecret">
<el-form-item :label="t('authentication.client_secret')" prop="client_secret">
<el-input
v-model="state.form.clientSecret"
v-model="state.form.client_secret"
type="password"
show-password
:placeholder="t('common.please_input')"
/>
</el-form-item>
<el-form-item :label="t('system.callback_address')" prop="redirectUri">
<el-input v-model="state.form.redirectUri" :placeholder="t('common.please_input')" />
<el-form-item :label="t('authentication.redirect_url')" prop="redirect_url">
<el-input v-model="state.form.redirect_url" :placeholder="t('common.please_input')" />
</el-form-item>
<el-form-item :label="t('system.field_mapping')" prop="mapping">
<el-input v-model="state.form.mapping" :placeholder="t('system.oauth2name')" />
<el-form-item :label="t('authentication.field_mapping')" prop="mapping">
<el-input
v-model="state.form.mapping"
:placeholder="t('authentication.oauth2_field_mapping_placeholder')"
/>
</el-form-item>
<el-form-item :label="t('datasource.auth_method')" prop="authMethod">
<!-- <el-form-item :label="t('datasource.auth_method')" prop="authMethod">
<el-radio-group v-model="state.form.authMethod">
<el-radio label="0">Authorization Code</el-radio>
<el-radio label="1">Client Secret Jwt</el-radio>
</el-radio-group>
</el-form-item> -->
<el-form-item
v-for="form_item in form_config_list"
:key="form_item.field"
:label="form_item.label"
:prop="form_item.field"
>
<component
:is="getComponent(form_item.component)"
v-model="state.form[form_item.field]"
v-bind="form_item.attrs"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button secondary @click="resetForm(oauth2Form)">{{ t('common.cancel') }}</el-button>
<el-button secondary :disabled="!state.form.clientId" @click="validate">
{{ t('commons.test_connect') }}
<el-button secondary :disabled="!state.form.client_id" @click="validate">
{{ t('ds.test_connection') }}
</el-button>
<el-button type="primary" @click="submitForm(oauth2Form)">
{{ t('commons.save') }}
{{ t('common.save') }}
</el-button>
</span>
</template>
@@ -90,7 +90,9 @@ const init = (needLoading: boolean) => {
.get(url)
.then((res) => {
if (res) {
infos.value = [...(res as CardInfo[])].filter((item) => item.name === 'cas')
infos.value = [...(res as CardInfo[])].filter(
(item) => item.name === 'cas' || item.name === 'oauth2'
)
showInfos.value = [...infos.value]
}
loading.value = false