feat: 配置导出/导入支持 LDAP 和 OAuth Provider

- 新增配置格式 v2.1,支持导出/导入 LDAP 配置和 OAuth Providers
- 导出时解密敏感字段(bind_password, client_secret, provider_ops credentials)
- 导入时加密敏感字段后存储
- 前端支持预览和显示 LDAP/OAuth 导入结果
This commit is contained in:
fawney19
2026-01-28 12:56:37 +08:00
parent 4cd196f665
commit 95fc848e59
3 changed files with 365 additions and 8 deletions

View File

@@ -1,11 +1,46 @@
import apiClient from './client'
// LDAP 配置导出结构
export interface LDAPConfigExport {
server_url: string
bind_dn: string
bind_password?: string
base_dn: string
user_search_filter?: string
username_attr?: string
email_attr?: string
display_name_attr?: string
is_enabled?: boolean
is_exclusive?: boolean
use_starttls?: boolean
connect_timeout?: number
}
// OAuth Provider 导出结构
export interface OAuthProviderExport {
provider_type: string
display_name: string
client_id: string
client_secret?: string
authorization_url_override?: string | null
token_url_override?: string | null
userinfo_url_override?: string | null
scopes?: string[] | null
redirect_uri: string
frontend_callback_url: string
attribute_mapping?: any
extra_config?: any
is_enabled?: boolean
}
// 配置导出数据结构
export interface ConfigExportData {
version: string
exported_at: string
global_models: GlobalModelExport[]
providers: ProviderExport[]
ldap_config?: LDAPConfigExport | null
oauth_providers?: OAuthProviderExport[]
}
// 用户导出数据结构
@@ -254,6 +289,8 @@ export interface ConfigImportResponse {
endpoints: { created: number; updated: number; skipped: number }
keys: { created: number; updated: number; skipped: number }
models: { created: number; updated: number; skipped: number }
ldap?: { created: number; updated: number; skipped: number }
oauth?: { created: number; updated: number; skipped: number }
errors: string[]
}
}

View File

@@ -538,6 +538,10 @@
<li>
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.api_keys?.length || 0), 0) }}
</li>
<li v-if="importPreview.ldap_config">LDAP 配置: 1 </li>
<li v-if="importPreview.oauth_providers?.length">
OAuth Providers: {{ importPreview.oauth_providers.length }}
</li>
</ul>
</div>
@@ -655,6 +659,26 @@
跳过: {{ importResult.stats.models.skipped }}
</p>
</div>
<div v-if="importResult.stats.ldap">
<p class="font-medium">
LDAP 配置
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.ldap.created }},
更新: {{ importResult.stats.ldap.updated }},
跳过: {{ importResult.stats.ldap.skipped }}
</p>
</div>
<div v-if="importResult.stats.oauth">
<p class="font-medium">
OAuth Providers
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.oauth.created }},
更新: {{ importResult.stats.oauth.updated }},
跳过: {{ importResult.stats.oauth.skipped }}
</p>
</div>
</div>
<div
@@ -1293,8 +1317,8 @@ function handleConfigFileSelect(event: Event) {
const content = e.target?.result as string
const data = JSON.parse(content) as ConfigExportData
// 验证版本
if (data.version !== '2.0') {
// 验证版本(支持 2.0 和 2.1
if (!['2.0', '2.1'].includes(data.version)) {
error(`不支持的配置版本: ${data.version}`)
return
}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Optional
@@ -12,6 +13,7 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger
from src.database import get_db
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User
@@ -731,6 +733,38 @@ class AdminGetApiFormatsAdapter(AdminApiAdapter):
class AdminExportConfigAdapter(AdminApiAdapter):
"""导出提供商和模型配置"""
# Provider Ops 中需要解密的敏感字段
SENSITIVE_CREDENTIALS = {
"api_key", "password", "session_token", "session_cookie",
"token_cookie", "auth_cookie", "cookie_string", "cookie"
}
def _decrypt_provider_config(self, config: dict, crypto_service) -> dict:
"""解密 Provider config 中的 provider_ops credentials"""
if not config:
return config
decrypted_config = copy.deepcopy(config)
# 解密 provider_ops.connector.credentials 中的敏感字段
provider_ops = decrypted_config.get("provider_ops")
if provider_ops and isinstance(provider_ops, dict):
connector = provider_ops.get("connector")
if connector and isinstance(connector, dict):
credentials = connector.get("credentials")
if credentials and isinstance(credentials, dict):
for field in self.SENSITIVE_CREDENTIALS:
if field in credentials and isinstance(credentials[field], str):
try:
credentials[field] = crypto_service.decrypt(credentials[field])
except Exception as e:
# 解密失败保持原值(可能本来就是明文)
logger.debug(f"解密 provider_ops credential '{field}' 失败: {e}")
return decrypted_config
async def handle(self, context): # type: ignore[override]
"""导出提供商和模型配置(解密数据)"""
from datetime import datetime, timezone
@@ -841,6 +875,11 @@ class AdminExportConfigAdapter(AdminApiAdapter):
}
)
# 解密 Provider config 中的 credentials
decrypted_provider_config = self._decrypt_provider_config(
provider.config, crypto_service
)
providers_data.append(
{
"name": provider.name,
@@ -854,18 +893,77 @@ class AdminExportConfigAdapter(AdminApiAdapter):
"concurrent_limit": provider.concurrent_limit,
"max_retries": provider.max_retries,
"proxy": provider.proxy,
"config": provider.config,
"config": decrypted_provider_config,
"endpoints": endpoints_data,
"api_keys": keys_data,
"models": models_data,
}
)
# 导出 LDAP 配置
from src.models.database import LDAPConfig
ldap_config = db.query(LDAPConfig).first()
ldap_data = None
if ldap_config:
# 解密绑定密码
bind_password = ""
if ldap_config.bind_password_encrypted:
try:
bind_password = crypto_service.decrypt(ldap_config.bind_password_encrypted)
except Exception as e:
logger.debug(f"解密 LDAP bind_password 失败: {e}")
ldap_data = {
"server_url": ldap_config.server_url,
"bind_dn": ldap_config.bind_dn,
"bind_password": bind_password,
"base_dn": ldap_config.base_dn,
"user_search_filter": ldap_config.user_search_filter,
"username_attr": ldap_config.username_attr,
"email_attr": ldap_config.email_attr,
"display_name_attr": ldap_config.display_name_attr,
"is_enabled": ldap_config.is_enabled,
"is_exclusive": ldap_config.is_exclusive,
"use_starttls": ldap_config.use_starttls,
"connect_timeout": ldap_config.connect_timeout,
}
# 导出 OAuth Providers 配置
from src.models.database import OAuthProvider
oauth_providers = db.query(OAuthProvider).all()
oauth_data = []
for oauth in oauth_providers:
# 解密 client secret
client_secret = ""
if oauth.client_secret_encrypted:
try:
client_secret = crypto_service.decrypt(oauth.client_secret_encrypted)
except Exception as e:
logger.debug(f"解密 OAuth '{oauth.provider_type}' client_secret 失败: {e}")
oauth_data.append({
"provider_type": oauth.provider_type,
"display_name": oauth.display_name,
"client_id": oauth.client_id,
"client_secret": client_secret,
"authorization_url_override": oauth.authorization_url_override,
"token_url_override": oauth.token_url_override,
"userinfo_url_override": oauth.userinfo_url_override,
"scopes": oauth.scopes,
"redirect_uri": oauth.redirect_uri,
"frontend_callback_url": oauth.frontend_callback_url,
"attribute_mapping": oauth.attribute_mapping,
"extra_config": oauth.extra_config,
"is_enabled": oauth.is_enabled,
})
return {
"version": "2.0",
"version": "2.1",
"exported_at": datetime.now(timezone.utc).isoformat(),
"global_models": global_models_data,
"providers": providers_data,
"ldap_config": ldap_data,
"oauth_providers": oauth_data,
}
@@ -873,6 +971,36 @@ MAX_IMPORT_SIZE = 10 * 1024 * 1024 # 10MB
class AdminImportConfigAdapter(AdminApiAdapter):
"""导入提供商和模型配置"""
# Provider Ops 中需要加密的敏感字段
SENSITIVE_CREDENTIALS = {
"api_key", "password", "session_token", "session_cookie",
"token_cookie", "auth_cookie", "cookie_string", "cookie"
}
def _encrypt_provider_config(self, config: dict, crypto_service) -> dict:
"""加密 Provider config 中的 provider_ops credentials"""
if not config:
return config
encrypted_config = copy.deepcopy(config)
# 加密 provider_ops.connector.credentials 中的敏感字段
provider_ops = encrypted_config.get("provider_ops")
if provider_ops and isinstance(provider_ops, dict):
connector = provider_ops.get("connector")
if connector and isinstance(connector, dict):
credentials = connector.get("credentials")
if credentials and isinstance(credentials, dict):
for field in self.SENSITIVE_CREDENTIALS:
if field in credentials and isinstance(credentials[field], str):
value = credentials[field]
if value: # 只加密非空值
credentials[field] = crypto_service.encrypt(value)
return encrypted_config
async def handle(self, context): # type: ignore[override]
"""导入提供商和模型配置"""
import uuid
@@ -889,15 +1017,17 @@ class AdminImportConfigAdapter(AdminApiAdapter):
db = context.db
payload = context.ensure_json_body()
# 验证配置版本
# 验证配置版本(支持 2.0 和 2.1
version = payload.get("version")
if version != "2.0":
if version not in ("2.0", "2.1"):
raise InvalidRequestException(f"不支持的配置版本: {version}")
# 获取导入选项
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
global_models_data = payload.get("global_models", [])
providers_data = payload.get("providers", [])
ldap_data = payload.get("ldap_config") # 2.1 新增
oauth_data = payload.get("oauth_providers", []) # 2.1 新增
stats = {
"global_models": {"created": 0, "updated": 0, "skipped": 0},
@@ -905,6 +1035,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
"endpoints": {"created": 0, "updated": 0, "skipped": 0},
"keys": {"created": 0, "updated": 0, "skipped": 0},
"models": {"created": 0, "updated": 0, "skipped": 0},
"ldap": {"created": 0, "updated": 0, "skipped": 0},
"oauth": {"created": 0, "updated": 0, "skipped": 0},
"errors": [],
}
@@ -1006,7 +1138,10 @@ class AdminImportConfigAdapter(AdminApiAdapter):
"max_retries", existing_provider.max_retries
)
existing_provider.proxy = prov_data.get("proxy", existing_provider.proxy)
existing_provider.config = prov_data.get("config")
# 加密 provider_ops credentials 后再保存
existing_provider.config = self._encrypt_provider_config(
prov_data.get("config"), crypto_service
)
existing_provider.updated_at = datetime.now(timezone.utc)
stats["providers"]["updated"] += 1
else:
@@ -1015,6 +1150,11 @@ class AdminImportConfigAdapter(AdminApiAdapter):
if prov_data.get("billing_type"):
billing_type = ProviderBillingType(prov_data["billing_type"])
# 加密 provider_ops credentials 后再保存
encrypted_config = self._encrypt_provider_config(
prov_data.get("config"), crypto_service
)
new_provider = Provider(
id=str(uuid.uuid4()),
name=prov_data["name"],
@@ -1028,7 +1168,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
concurrent_limit=prov_data.get("concurrent_limit"),
max_retries=prov_data.get("max_retries"),
proxy=prov_data.get("proxy"),
config=prov_data.get("config"),
config=encrypted_config,
)
db.add(new_provider)
db.flush()
@@ -1280,6 +1420,162 @@ class AdminImportConfigAdapter(AdminApiAdapter):
db.add(new_model)
stats["models"]["created"] += 1
# 导入 LDAP 配置2.1 新增)
if ldap_data:
from src.models.database import LDAPConfig
# 校验必填字段
required_ldap_fields = ["server_url", "bind_dn", "base_dn"]
missing = [f for f in required_ldap_fields if not ldap_data.get(f)]
if missing:
raise InvalidRequestException(f"LDAP 配置缺少必填字段: {', '.join(missing)}")
existing_ldap = db.query(LDAPConfig).first()
if existing_ldap:
if merge_mode == "skip":
stats["ldap"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException("LDAP 配置已存在")
elif merge_mode == "overwrite":
existing_ldap.server_url = ldap_data.get("server_url", existing_ldap.server_url)
existing_ldap.bind_dn = ldap_data.get("bind_dn", existing_ldap.bind_dn)
# 加密绑定密码
if ldap_data.get("bind_password"):
existing_ldap.bind_password_encrypted = crypto_service.encrypt(
ldap_data["bind_password"]
)
existing_ldap.base_dn = ldap_data.get("base_dn", existing_ldap.base_dn)
existing_ldap.user_search_filter = ldap_data.get(
"user_search_filter", existing_ldap.user_search_filter
)
existing_ldap.username_attr = ldap_data.get(
"username_attr", existing_ldap.username_attr
)
existing_ldap.email_attr = ldap_data.get("email_attr", existing_ldap.email_attr)
existing_ldap.display_name_attr = ldap_data.get(
"display_name_attr", existing_ldap.display_name_attr
)
existing_ldap.is_enabled = ldap_data.get("is_enabled", existing_ldap.is_enabled)
existing_ldap.is_exclusive = ldap_data.get(
"is_exclusive", existing_ldap.is_exclusive
)
existing_ldap.use_starttls = ldap_data.get(
"use_starttls", existing_ldap.use_starttls
)
existing_ldap.connect_timeout = ldap_data.get(
"connect_timeout", existing_ldap.connect_timeout
)
existing_ldap.updated_at = datetime.now(timezone.utc)
stats["ldap"]["updated"] += 1
else:
# 创建新的 LDAP 配置
new_ldap = LDAPConfig(
server_url=ldap_data["server_url"],
bind_dn=ldap_data["bind_dn"],
bind_password_encrypted=(
crypto_service.encrypt(ldap_data["bind_password"])
if ldap_data.get("bind_password") else None
),
base_dn=ldap_data["base_dn"],
user_search_filter=ldap_data.get("user_search_filter", "(uid={username})"),
username_attr=ldap_data.get("username_attr", "uid"),
email_attr=ldap_data.get("email_attr", "mail"),
display_name_attr=ldap_data.get("display_name_attr", "cn"),
is_enabled=ldap_data.get("is_enabled", False),
is_exclusive=ldap_data.get("is_exclusive", False),
use_starttls=ldap_data.get("use_starttls", False),
connect_timeout=ldap_data.get("connect_timeout", 10),
)
db.add(new_ldap)
stats["ldap"]["created"] += 1
# 导入 OAuth Providers2.1 新增)
if oauth_data:
from src.models.database import OAuthProvider
for oauth_item in oauth_data:
provider_type = oauth_item.get("provider_type")
if not provider_type:
stats["errors"].append("跳过无 provider_type 的 OAuth 配置")
continue
existing_oauth = (
db.query(OAuthProvider)
.filter(OAuthProvider.provider_type == provider_type)
.first()
)
if existing_oauth:
if merge_mode == "skip":
stats["oauth"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"OAuth Provider '{provider_type}' 已存在"
)
elif merge_mode == "overwrite":
existing_oauth.display_name = oauth_item.get(
"display_name", existing_oauth.display_name
)
existing_oauth.client_id = oauth_item.get(
"client_id", existing_oauth.client_id
)
# 加密 client_secret
if oauth_item.get("client_secret"):
existing_oauth.client_secret_encrypted = crypto_service.encrypt(
oauth_item["client_secret"]
)
existing_oauth.authorization_url_override = oauth_item.get(
"authorization_url_override"
)
existing_oauth.token_url_override = oauth_item.get("token_url_override")
existing_oauth.userinfo_url_override = oauth_item.get(
"userinfo_url_override"
)
existing_oauth.scopes = oauth_item.get("scopes")
existing_oauth.redirect_uri = oauth_item.get(
"redirect_uri", existing_oauth.redirect_uri
)
existing_oauth.frontend_callback_url = oauth_item.get(
"frontend_callback_url", existing_oauth.frontend_callback_url
)
existing_oauth.attribute_mapping = oauth_item.get("attribute_mapping")
existing_oauth.extra_config = oauth_item.get("extra_config")
existing_oauth.is_enabled = oauth_item.get(
"is_enabled", existing_oauth.is_enabled
)
existing_oauth.updated_at = datetime.now(timezone.utc)
stats["oauth"]["updated"] += 1
else:
# 创建新的 OAuth Provider - 校验必填字段
required_oauth_fields = ["client_id", "redirect_uri", "frontend_callback_url"]
missing = [f for f in required_oauth_fields if not oauth_item.get(f)]
if missing:
stats["errors"].append(
f"OAuth Provider '{provider_type}' 缺少必填字段: {', '.join(missing)}"
)
continue
new_oauth = OAuthProvider(
provider_type=provider_type,
display_name=oauth_item.get("display_name", provider_type),
client_id=oauth_item["client_id"],
client_secret_encrypted=(
crypto_service.encrypt(oauth_item["client_secret"])
if oauth_item.get("client_secret") else None
),
authorization_url_override=oauth_item.get("authorization_url_override"),
token_url_override=oauth_item.get("token_url_override"),
userinfo_url_override=oauth_item.get("userinfo_url_override"),
scopes=oauth_item.get("scopes"),
redirect_uri=oauth_item["redirect_uri"],
frontend_callback_url=oauth_item["frontend_callback_url"],
attribute_mapping=oauth_item.get("attribute_mapping"),
extra_config=oauth_item.get("extra_config"),
is_enabled=oauth_item.get("is_enabled", False),
)
db.add(new_oauth)
stats["oauth"]["created"] += 1
db.commit()
# 失效缓存