mirror of
https://github.com/1Panel-dev/MaxKB.git
synced 2025-12-26 01:33:05 +00:00
feat: implement SAML2 authentication with configuration options
This commit is contained in:
parent
a89b1ff6d9
commit
9a474b2302
|
|
@ -1,5 +1,8 @@
|
|||
# coding=utf-8
|
||||
import base64
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
|
@ -83,7 +86,17 @@ class GetUrlView(APIView):
|
|||
)
|
||||
def get(self, request: Request):
|
||||
url = request.query_params.get('url')
|
||||
response = requests.get(url)
|
||||
parsed = validate_url(url)
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=3,
|
||||
allow_redirects=False
|
||||
)
|
||||
final_host = urlparse(response.url).hostname
|
||||
if is_private_ip(final_host):
|
||||
raise ValueError("Blocked unsafe redirect to internal host")
|
||||
|
||||
# 返回状态码 响应内容大小 响应的contenttype 还有字节流
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
# 根据内容类型决定如何处理
|
||||
|
|
@ -99,3 +112,43 @@ class GetUrlView(APIView):
|
|||
'Content-Type': content_type,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
|
||||
def is_private_ip(host: str) -> bool:
|
||||
"""检测 IP 是否属于内网、环回、云 metadata 的危险地址"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(socket.gethostbyname(host))
|
||||
return (
|
||||
ip.is_private or
|
||||
ip.is_loopback or
|
||||
ip.is_reserved or
|
||||
ip.is_link_local or
|
||||
ip.is_multicast
|
||||
)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def validate_url(url: str):
|
||||
"""验证 URL 是否安全"""
|
||||
if not url:
|
||||
raise ValueError("URL is required")
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
# 仅允许 http / https
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Only http and https are allowed")
|
||||
|
||||
host = parsed.hostname
|
||||
path = parsed.path
|
||||
|
||||
# 域名不能为空
|
||||
if not host:
|
||||
raise ValueError("Invalid URL")
|
||||
|
||||
# 禁止访问内部、保留、环回、云 metadata
|
||||
if is_private_ip(host):
|
||||
raise ValueError("Access to internal IP addresses is blocked")
|
||||
|
||||
return parsed
|
||||
|
|
|
|||
|
|
@ -99,7 +99,11 @@ const postLanguage: (data: any, loading?: Ref<boolean>) => Promise<Result<User>>
|
|||
) => {
|
||||
return post('/user/language', data, undefined, loading)
|
||||
}
|
||||
|
||||
const samlLogin: (loading?: Ref<boolean>) => Promise<Result<any>> = (
|
||||
loading,
|
||||
) => {
|
||||
return get('/saml2', '', loading)
|
||||
}
|
||||
export default {
|
||||
login,
|
||||
logout,
|
||||
|
|
@ -112,5 +116,6 @@ export default {
|
|||
getDingOauth2Callback,
|
||||
getLarkCallback,
|
||||
getQrSource,
|
||||
ldapLogin
|
||||
ldapLogin,
|
||||
samlLogin
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,24 @@ export default {
|
|||
filedMappingPlaceholder: 'Please enter field mapping',
|
||||
enableAuthentication: 'Enable OAuth2 Authentication',
|
||||
},
|
||||
saml2: {
|
||||
title: 'SAML2',
|
||||
ldp: 'Idp MetaData Url',
|
||||
ldpPlaceholder: 'Please enter Idp MetaData Url',
|
||||
enableAuthnRequests: 'Enable request signature',
|
||||
enableAssertions: 'Enable assertion signatures',
|
||||
privateKey: 'SP Private Key',
|
||||
privateKeyPlaceholder: 'Please enter SP Private Key',
|
||||
certificate: 'SP Certificate',
|
||||
certificatePlaceholder: 'Please enter SP Certificate',
|
||||
filedMapping: 'Field Mapping',
|
||||
spEntityId: 'SP Entity Id',
|
||||
spEntityIdPlaceholder: 'Please enter SP Entity Id',
|
||||
spAcs: 'SP Ace',
|
||||
spAcsPlaceholder: 'Please enter SP Ace',
|
||||
filedMappingPlaceholder: 'Please enter field mapping',
|
||||
enableAuthentication: 'Enable SAML2 Authentication',
|
||||
},
|
||||
scanTheQRCode: {
|
||||
title: 'Scan the QR code',
|
||||
wecom: 'WeCom',
|
||||
|
|
@ -133,9 +151,9 @@ export default {
|
|||
type: 'Type',
|
||||
management: 'management',
|
||||
},
|
||||
default_login: 'Default Login Method',
|
||||
default_login: 'Default Login Method',
|
||||
display_code: 'Account login verification code setting',
|
||||
loginFailed: 'Login failed',
|
||||
loginFailed: 'Login failed',
|
||||
loginFailedMessage: 'Display verification code twice',
|
||||
display_codeTip: 'When the value is -1, the verification code is not displayed',
|
||||
time: 'Times',
|
||||
|
|
|
|||
|
|
@ -73,6 +73,24 @@ export default {
|
|||
filedMappingPlaceholder: '请输入字段映射',
|
||||
enableAuthentication: '启用 OAuth2 认证',
|
||||
},
|
||||
saml2: {
|
||||
title: 'SAML2',
|
||||
ldp: 'Idp MetaData Url',
|
||||
ldpPlaceholder: '请输入 Idp MetaData Url',
|
||||
enableAuthnRequests: '开启请求签名',
|
||||
enableAssertions: '开启断言签名',
|
||||
privateKey: 'SP Private Key',
|
||||
privateKeyPlaceholder: '请输入 SP Private Key',
|
||||
certificate: 'SP Certificate',
|
||||
certificatePlaceholder: '请输入 SP Certificate',
|
||||
filedMapping: '字段映射',
|
||||
spEntityId: 'SP Entity Id',
|
||||
spEntityIdPlaceholder: '请输入 SP Entity Id',
|
||||
spAcs: 'SP Ace',
|
||||
spAcsPlaceholder: '请输入 SP Ace',
|
||||
filedMappingPlaceholder: '请输入字段映射',
|
||||
enableAuthentication: '启用 SAML2 认证',
|
||||
},
|
||||
scanTheQRCode: {
|
||||
title: '扫码登录',
|
||||
wecom: '企业微信',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,25 @@ export default {
|
|||
filedMappingPlaceholder: '請輸入欄位對應',
|
||||
enableAuthentication: '啟用 OAuth2 認證',
|
||||
},
|
||||
saml2: {
|
||||
title: 'SAML2',
|
||||
ldp: 'Idp MetaData Url',
|
||||
ldpPlaceholder: '請輸入 Idp MetaData Url',
|
||||
enableAuthnRequests: '開啟請求簽名',
|
||||
enableAssertions: '開啟斷言簽名',
|
||||
privateKey: 'SP Private Key',
|
||||
privateKeyPlaceholder: '請輸入 SP Private Key',
|
||||
certificate: 'SP Certificate',
|
||||
certificatePlaceholder: '請輸入 SP Certificate',
|
||||
filedMapping: '欄位映射',
|
||||
spEntityId: 'SP Entity Id',
|
||||
spEntityIdPlaceholder: '請輸入 SP Entity Id',
|
||||
spAcs: 'SP Ace',
|
||||
spAcsPlaceholder: '請輸入 SP Ace',
|
||||
filedMappingPlaceholder: '請輸入欄位映射',
|
||||
enableAuthentication: '啟用 SAML2 認證',
|
||||
},
|
||||
|
||||
scanTheQRCode: {
|
||||
title: '掃碼登入',
|
||||
wecom: '企業微信',
|
||||
|
|
@ -133,7 +152,7 @@ export default {
|
|||
type: '類型',
|
||||
management: '管理',
|
||||
},
|
||||
default_login: '預設登入方式',
|
||||
default_login: '預設登入方式',
|
||||
display_code: '帳號登入驗證碼設定',
|
||||
loginFailed: '登入失敗',
|
||||
loginFailedMessage: '次顯示驗證碼',
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ const useLoginStore = defineStore('login', {
|
|||
return ok.data
|
||||
})
|
||||
},
|
||||
async samlLogin() {
|
||||
return LoginApi.samlLogin().then((ok) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -329,6 +329,10 @@ function redirectAuth(authType: string, needMessage: boolean = true) {
|
|||
if (config.scope) {
|
||||
url += `&scope=${config.scope}`
|
||||
}
|
||||
} else if (authType === 'SAML2') {
|
||||
loginApi.samlLogin().then((res: any) => {
|
||||
window.location.href = res.data
|
||||
})
|
||||
}
|
||||
if (!url) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
<template>
|
||||
<div class="authentication-setting__main main-calc-height">
|
||||
<el-scrollbar>
|
||||
<div class="form-container p-24" v-loading="loading">
|
||||
<el-form
|
||||
ref="authFormRef"
|
||||
:rules="rules"
|
||||
:model="form"
|
||||
label-position="top"
|
||||
require-asterisk-position="right"
|
||||
>
|
||||
<el-form-item
|
||||
:label="$t('views.system.authentication.saml2.ldp')"
|
||||
prop="config.idpMetaUrl"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.config.idpMetaUrl"
|
||||
:placeholder="$t('views.system.authentication.saml2.ldpPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.config.wantAssertionsSigned">{{
|
||||
$t('views.system.authentication.saml2.enableAuthnRequests')
|
||||
}}
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.config.wantAuthnRequestsSigned">{{
|
||||
$t('views.system.authentication.saml2.enableAssertions')
|
||||
}}
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('views.system.authentication.saml2.privateKey')"
|
||||
prop="config.privateKey"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.config.privateKey"
|
||||
type="password"
|
||||
:placeholder="$t('views.system.authentication.saml2.privateKeyPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('views.system.authentication.saml2.certificate')"
|
||||
prop="config.certificate"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.config.certificate"
|
||||
type="password"
|
||||
:placeholder="$t('views.system.authentication.saml2.certificatePlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('views.system.authentication.saml2.filedMapping')"
|
||||
prop="config.mapping">
|
||||
<el-input
|
||||
v-model="form.config.mapping"
|
||||
:placeholder="$t('views.system.authentication.saml2.filedMappingPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('views.system.authentication.saml2.spEntityId')"
|
||||
prop="config.spEntityId"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.config.spEntityId"
|
||||
:placeholder="$t('views.system.authentication.saml2.spEntityIdPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('views.system.authentication.saml2.spAcs')"
|
||||
prop="config.spAcs"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.config.spAcs"
|
||||
:placeholder="$t('views.system.authentication.saml2.spAcsPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.is_active">{{
|
||||
$t('views.system.authentication.saml2.enableAuthentication')
|
||||
}}
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div>
|
||||
<span
|
||||
v-hasPermission="
|
||||
new ComplexPermission([RoleConst.ADMIN], [PermissionConst.LOGIN_AUTH_EDIT], [], 'OR')
|
||||
"
|
||||
class="mr-12"
|
||||
>
|
||||
<el-button @click="submit(authFormRef)" type="primary" :disabled="loading">
|
||||
{{ $t('common.save') }}
|
||||
</el-button>
|
||||
</span>
|
||||
<span>
|
||||
<el-button @click="submit(authFormRef, 'test')" :disabled="loading">
|
||||
{{ $t('views.system.test') }}</el-button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {reactive, ref, watch, onMounted} from 'vue'
|
||||
import authApi from '@/api/system-settings/auth-setting'
|
||||
import type {FormInstance, FormRules} from 'element-plus'
|
||||
import {t} from '@/locales'
|
||||
import {MsgSuccess} from '@/utils/message'
|
||||
import {PermissionConst, RoleConst} from '@/utils/permission/data'
|
||||
import {ComplexPermission} from '@/utils/permission/type'
|
||||
|
||||
const form = ref<any>({
|
||||
id: '',
|
||||
auth_type: 'SAML2',
|
||||
config: {
|
||||
idpMetaUrl: '',
|
||||
wantAssertionsSigned: true,
|
||||
wantAuthnRequestsSigned: true,
|
||||
privateKey: '',
|
||||
certificate: '',
|
||||
mapping: '',
|
||||
spEntityId: window.location.origin + window.MaxKB.prefix + '/api/saml2/metadata',
|
||||
spAcs: window.location.origin + window.MaxKB.prefix + '/api/saml2/sso',
|
||||
},
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const authFormRef = ref()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const rules = reactive<FormRules<any>>({
|
||||
'config.idpMetaUrl': [
|
||||
{
|
||||
required: true,
|
||||
message: t('views.system.authentication.saml2.ldpPlaceholder'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
'config.privateKey': [
|
||||
{
|
||||
required: true,
|
||||
message: t('views.system.authentication.saml2.privateKeyPlaceholder'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
'config.certificate': [
|
||||
{
|
||||
required: true,
|
||||
message: t('views.system.authentication.saml2.certificatePlaceholder'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
'config.mapping': [
|
||||
{
|
||||
required: true,
|
||||
message: t('views.system.authentication.saml2.filedMappingPlaceholder'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const submit = async (formEl: FormInstance | undefined, test?: string) => {
|
||||
if (!formEl) return
|
||||
await formEl.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
authApi.putAuthSetting(form.value.auth_type, form.value, loading).then((res) => {
|
||||
MsgSuccess(t('common.saveSuccess'))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getDetail() {
|
||||
authApi.getAuthSetting(form.value.auth_type, loading).then((res: any) => {
|
||||
if (res.data && JSON.stringify(res.data) !== '{}') {
|
||||
form.value = res.data
|
||||
if (res.data.config.mapping) {
|
||||
form.value.config.mapping = JSON.stringify(JSON.parse(res.data.config.mapping))
|
||||
}
|
||||
if (!form.value.config.spEntityId) {
|
||||
form.value.config.spEntityId = window.location.origin + window.MaxKB.prefix + '/api/saml2/metadata'
|
||||
}
|
||||
if (!form.value.config.spAcs) {
|
||||
form.value.config.spAcs = window.location.origin + window.MaxKB.prefix + '/api/saml2/sso'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDetail()
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
|
|
@ -23,6 +23,7 @@ import CAS from './component/CAS.vue'
|
|||
import OIDC from './component/OIDC.vue'
|
||||
import SCAN from './component/SCAN.vue'
|
||||
import OAuth2 from './component/OAuth2.vue'
|
||||
import Saml2 from "./component/Saml2.vue";
|
||||
import Setting from './component/Setting.vue'
|
||||
import { t } from '@/locales'
|
||||
import useStore from '@/stores'
|
||||
|
|
@ -57,6 +58,11 @@ const tabList = [
|
|||
name: 'OAuth2',
|
||||
component: OAuth2,
|
||||
},
|
||||
{
|
||||
label: t('views.system.authentication.saml2.title'),
|
||||
name: 'SAML2',
|
||||
component: Saml2,
|
||||
},
|
||||
{
|
||||
label: t('views.system.authentication.scanTheQRCode.title'),
|
||||
name: 'SCAN',
|
||||
|
|
|
|||
Loading…
Reference in New Issue