mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 配置导出/导入支持 LDAP 和 OAuth Provider
- 新增配置格式 v2.1,支持导出/导入 LDAP 配置和 OAuth Providers - 导出时解密敏感字段(bind_password, client_secret, provider_ops credentials) - 导入时加密敏感字段后存储 - 前端支持预览和显示 LDAP/OAuth 导入结果
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 Providers(2.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()
|
||||
|
||||
# 失效缓存
|
||||
|
||||
Reference in New Issue
Block a user