feat: implement SAML2 authentication with configuration options

This commit is contained in:
wxg0103 2025-11-26 11:30:49 +08:00
parent a89b1ff6d9
commit 9a474b2302
9 changed files with 334 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@ -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: '企业微信',

View File

@ -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: '次顯示驗證碼',

View File

@ -86,6 +86,10 @@ const useLoginStore = defineStore('login', {
return ok.data
})
},
async samlLogin() {
return LoginApi.samlLogin().then((ok) => {
})
}
},
})

View File

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

View File

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

View File

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