From 9a474b2302f61ec8aadeaf9cecd27ae32ebce7b3 Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Wed, 26 Nov 2025 11:30:49 +0800 Subject: [PATCH] feat: implement SAML2 authentication with configuration options --- apps/oss/views/file.py | 55 ++++- ui/src/api/user/login.ts | 9 +- ui/src/locales/lang/en-US/views/system.ts | 22 +- ui/src/locales/lang/zh-CN/views/system.ts | 18 ++ ui/src/locales/lang/zh-Hant/views/system.ts | 21 +- ui/src/stores/modules/login.ts | 4 + ui/src/views/login/index.vue | 4 + .../authentication/component/Saml2.vue | 201 ++++++++++++++++++ .../system-setting/authentication/index.vue | 6 + 9 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 ui/src/views/system-setting/authentication/component/Saml2.vue diff --git a/apps/oss/views/file.py b/apps/oss/views/file.py index b9f6ca67d..185c76c19 100644 --- a/apps/oss/views/file.py +++ b/apps/oss/views/file.py @@ -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 diff --git a/ui/src/api/user/login.ts b/ui/src/api/user/login.ts index a97ee097d..f3e2270d3 100644 --- a/ui/src/api/user/login.ts +++ b/ui/src/api/user/login.ts @@ -99,7 +99,11 @@ const postLanguage: (data: any, loading?: Ref) => Promise> ) => { return post('/user/language', data, undefined, loading) } - +const samlLogin: (loading?: Ref) => Promise> = ( + loading, +) => { + return get('/saml2', '', loading) +} export default { login, logout, @@ -112,5 +116,6 @@ export default { getDingOauth2Callback, getLarkCallback, getQrSource, - ldapLogin + ldapLogin, + samlLogin } diff --git a/ui/src/locales/lang/en-US/views/system.ts b/ui/src/locales/lang/en-US/views/system.ts index d2742ed2e..53613c74b 100644 --- a/ui/src/locales/lang/en-US/views/system.ts +++ b/ui/src/locales/lang/en-US/views/system.ts @@ -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', diff --git a/ui/src/locales/lang/zh-CN/views/system.ts b/ui/src/locales/lang/zh-CN/views/system.ts index 1cf087604..7900fb950 100644 --- a/ui/src/locales/lang/zh-CN/views/system.ts +++ b/ui/src/locales/lang/zh-CN/views/system.ts @@ -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: '企业微信', diff --git a/ui/src/locales/lang/zh-Hant/views/system.ts b/ui/src/locales/lang/zh-Hant/views/system.ts index 7f32b1e2b..aac29ec20 100644 --- a/ui/src/locales/lang/zh-Hant/views/system.ts +++ b/ui/src/locales/lang/zh-Hant/views/system.ts @@ -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: '次顯示驗證碼', diff --git a/ui/src/stores/modules/login.ts b/ui/src/stores/modules/login.ts index 1f9fcd8e5..8b7151783 100644 --- a/ui/src/stores/modules/login.ts +++ b/ui/src/stores/modules/login.ts @@ -86,6 +86,10 @@ const useLoginStore = defineStore('login', { return ok.data }) }, + async samlLogin() { + return LoginApi.samlLogin().then((ok) => { + }) + } }, }) diff --git a/ui/src/views/login/index.vue b/ui/src/views/login/index.vue index c8d36a029..f6ceec41d 100644 --- a/ui/src/views/login/index.vue +++ b/ui/src/views/login/index.vue @@ -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 diff --git a/ui/src/views/system-setting/authentication/component/Saml2.vue b/ui/src/views/system-setting/authentication/component/Saml2.vue new file mode 100644 index 000000000..368591e44 --- /dev/null +++ b/ui/src/views/system-setting/authentication/component/Saml2.vue @@ -0,0 +1,201 @@ + + + diff --git a/ui/src/views/system-setting/authentication/index.vue b/ui/src/views/system-setting/authentication/index.vue index 14cb050e1..c89d2b6ea 100644 --- a/ui/src/views/system-setting/authentication/index.vue +++ b/ui/src/views/system-setting/authentication/index.vue @@ -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',