feat: 统一 SSL 证书配置并支持用户创建时设置访问权限

- 新增 ssl_utils.py 模块,统一使用 certifi 证书
- 将所有 httpx 客户端的 verify 参数改为使用 get_ssl_context()
- 管理员创建用户时支持设置 allowed_providers/api_formats/models
- 修复 provider_ops 敏感字段列表缺失 session_cookie
This commit is contained in:
fawney19
2026-01-19 17:56:02 +08:00
parent 6bc9cdc69d
commit 0265849ed3
17 changed files with 88 additions and 35 deletions

View File

@@ -14,6 +14,7 @@ from src.clients import get_redis_client
from src.core.logger import logger
from src.models.database import User
from src.utils.auth_utils import require_admin
from src.utils.ssl_utils import get_ssl_context
router = APIRouter()
@@ -118,7 +119,7 @@ async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
# 从 models.dev 获取数据
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
response = await client.get("https://models.dev/api.json")
response.raise_for_status()
data = response.json()

View File

@@ -22,6 +22,7 @@ from src.services.model.upstream_fetcher import (
fetch_models_from_endpoints,
)
from src.utils.auth_utils import get_current_user
from src.utils.ssl_utils import get_ssl_context
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
@@ -302,7 +303,7 @@ async def test_model(
}
# 发送测试请求
async with httpx.AsyncClient(timeout=endpoint_config["timeout"]) as client:
async with httpx.AsyncClient(timeout=endpoint_config["timeout"], verify=get_ssl_context()) as client:
# 非流式测试
logger.debug(f"[test-model] 开始非流式测试...")

View File

@@ -227,6 +227,11 @@ class AdminCreateUserAdapter(AdminApiAdapter):
else:
quota_usd = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
# 处理访问权限字段:空数组转为 None表示无限制
allowed_providers = request.allowed_providers if request.allowed_providers else None
allowed_api_formats = request.allowed_api_formats if request.allowed_api_formats else None
allowed_models = request.allowed_models if request.allowed_models else None
try:
user = UserService.create_user(
db=db,
@@ -235,6 +240,9 @@ class AdminCreateUserAdapter(AdminApiAdapter):
password=request.password,
role=role,
quota_usd=quota_usd,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
allowed_models=allowed_models,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))

View File

@@ -28,6 +28,7 @@ import httpx
from src.core.logger import logger
from src.core.headers import CORE_REDACT_HEADERS, merge_headers_with_protection, redact_headers_for_log
from src.utils.ssl_utils import get_ssl_context
def _redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
@@ -545,7 +546,7 @@ class HttpRequestExecutor:
try:
# 使用httpx进行异步请求
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with httpx.AsyncClient(timeout=self.timeout, verify=get_ssl_context()) as client:
response = await client.post(
url=request.url,
json=request.json_body,

View File

@@ -20,6 +20,7 @@ from src.database.database import get_pool_status
from src.models.database import Model, Provider
from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator
from src.services.provider.transport import build_provider_url
from src.utils.ssl_utils import get_ssl_context
router = APIRouter(tags=["System Catalog"])
@@ -281,7 +282,7 @@ async def test_connection(
is_stream=False,
)
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
resp = await client.post(url, json=provider_payload, headers=provider_headers)
resp.raise_for_status()
return resp.json()

View File

@@ -19,6 +19,7 @@ import httpx
from src.config import config
from src.core.logger import logger
from src.utils.ssl_utils import get_ssl_context
# 模块级锁,避免类属性延迟初始化的竞态条件
_proxy_clients_lock = asyncio.Lock()
@@ -130,7 +131,7 @@ class HTTPClientPool:
if cls._default_client is None:
cls._default_client = httpx.AsyncClient(
http2=False, # 暂时禁用HTTP/2以提高兼容性
verify=True, # 启用SSL验证
verify=get_ssl_context(), # 使用 certifi 证书
timeout=httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
@@ -163,7 +164,7 @@ class HTTPClientPool:
if cls._default_client is None:
cls._default_client = httpx.AsyncClient(
http2=False, # 暂时禁用HTTP/2以提高兼容性
verify=True, # 启用SSL验证
verify=get_ssl_context(), # 使用 certifi 证书
timeout=httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
@@ -200,7 +201,7 @@ class HTTPClientPool:
# 合并默认配置和自定义配置
default_config = {
"http2": False,
"verify": True,
"verify": get_ssl_context(),
"timeout": httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
@@ -281,7 +282,7 @@ class HTTPClientPool:
# 创建新客户端(使用默认超时,请求时可覆盖)
client_config: Dict[str, Any] = {
"http2": False,
"verify": True,
"verify": get_ssl_context(),
"follow_redirects": True,
"limits": httpx.Limits(
max_connections=config.http_max_connections,
@@ -350,7 +351,7 @@ class HTTPClientPool:
"""
default_config = {
"http2": False,
"verify": True,
"verify": get_ssl_context(),
"timeout": httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
@@ -388,7 +389,7 @@ class HTTPClientPool:
"""
client_config: Dict[str, Any] = {
"http2": False,
"verify": True,
"verify": get_ssl_context(),
"follow_redirects": True,
}

View File

@@ -241,6 +241,10 @@ class CreateUserRequest(BaseModel):
role: Optional[UserRole] = Field(UserRole.USER, description="用户角色")
quota_usd: Optional[float] = Field(default=None, description="USD配额null表示使用系统默认配额")
unlimited: bool = Field(default=False, description="是否无限配额")
# 访问限制字段
allowed_providers: Optional[List[str]] = Field(default=None, description="允许使用的提供商ID列表null表示无限制")
allowed_api_formats: Optional[List[str]] = Field(default=None, description="允许使用的API格式列表null表示无限制")
allowed_models: Optional[List[str]] = Field(default=None, description="允许使用的模型名称列表null表示无限制")
@field_validator("quota_usd", mode="before")
@classmethod

View File

@@ -7,6 +7,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
import httpx
from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo
from src.utils.ssl_utils import get_ssl_context
if TYPE_CHECKING:
from src.models.database import OAuthProvider
@@ -97,7 +98,7 @@ class OAuthProviderBase(ABC):
timeout_seconds: float = 5.0,
headers: Optional[dict[str, str]] = None,
) -> httpx.Response:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds), verify=get_ssl_context()) as client:
return await client.post(url, data=data, headers=headers)
async def _http_get(
@@ -107,5 +108,5 @@ class OAuthProviderBase(ABC):
timeout_seconds: float = 5.0,
headers: Optional[dict[str, str]] = None,
) -> httpx.Response:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds)) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds), verify=get_ssl_context()) as client:
return await client.get(url, headers=headers)

View File

@@ -26,6 +26,7 @@ from src.services.auth.service import AuthService
from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService
from src.services.auth.oauth.base import OAuthProviderBase
from src.utils.ssl_utils import get_ssl_context
class OAuthService:
@@ -785,7 +786,7 @@ class OAuthService:
async def _reachable(url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False, verify=get_ssl_context()) as client:
await client.get(url)
return True
except Exception:
@@ -800,7 +801,7 @@ class OAuthService:
if cfg.client_secret_encrypted:
# 使用无效 code 做一次 token 请求(仅做粗略判定)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), verify=get_ssl_context()) as client:
resp = await client.post(
token_url,
data={
@@ -860,7 +861,7 @@ class OAuthService:
async def _reachable(url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), follow_redirects=False, verify=get_ssl_context()) as client:
await client.get(url)
return True
except Exception:
@@ -875,7 +876,7 @@ class OAuthService:
if client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), verify=get_ssl_context()) as client:
resp = await client.post(
token_url,
data={

View File

@@ -4,7 +4,6 @@
"""
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any, Optional, Tuple, Union
@@ -19,23 +18,13 @@ else:
AIOSMTPLIB_AVAILABLE = True
aiosmtplib = _aiosmtplib
def _create_ssl_context() -> ssl.SSLContext:
"""创建 SSL 上下文,使用 certifi 证书或系统默认证书"""
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
return context
from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.services.system.config import SystemConfigService
from src.utils.async_utils import run_in_executor
from src.utils.ssl_utils import get_ssl_context
from .email_template import EmailTemplate
@@ -224,7 +213,7 @@ class EmailSenderService:
message.attach(MIMEText(html_body, "html", "utf-8"))
# 发送邮件
ssl_context = _create_ssl_context()
ssl_context = get_ssl_context()
if config["smtp_use_ssl"]:
await aiosmtplib.send(
message,
@@ -324,7 +313,7 @@ class EmailSenderService:
# 连接 SMTP 服务器
server: Optional[smtplib.SMTP] = None
ssl_context = _create_ssl_context()
ssl_context = get_ssl_context()
try:
if config["smtp_use_ssl"]:
server = smtplib.SMTP_SSL(
@@ -392,7 +381,7 @@ class EmailSenderService:
return False, error
try:
ssl_context = _create_ssl_context()
ssl_context = get_ssl_context()
if AIOSMTPLIB_AVAILABLE:
# 使用异步方式测试
# 注意: use_tls=True 表示隐式 SSL (端口 465)

View File

@@ -15,6 +15,7 @@ from src.core.enums import APIFormat
from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger
from src.models.database import ProviderEndpoint
from src.utils.ssl_utils import get_ssl_context
# 并发请求限制
MAX_CONCURRENT_REQUESTS = 5
@@ -148,7 +149,7 @@ async def fetch_models_from_endpoints(
logger.exception(f"获取 {api_format} 模型出错")
return [], f"{api_format}: error", False
async with httpx.AsyncClient(timeout=timeout) as client:
async with httpx.AsyncClient(timeout=timeout, verify=get_ssl_context()) as client:
results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
for models, error, success in results:
all_models.extend(models)

View File

@@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
import httpx
from src.core.logger import logger
from src.utils.ssl_utils import get_ssl_context
from src.services.provider_ops.actions import AnyrouterBalanceAction, ProviderAction
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
@@ -193,7 +194,7 @@ async def _get_acw_cookie(base_url: str, timeout: float = 10) -> Optional[str]:
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
"""
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with httpx.AsyncClient(timeout=timeout, verify=get_ssl_context()) as client:
resp = await client.get(
base_url,
headers={

View File

@@ -11,6 +11,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional, Type
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.utils.ssl_utils import get_ssl_context
from src.services.provider_ops.types import (
ConnectorAuthType,
ConnectorState,
@@ -125,6 +126,7 @@ class ProviderConnector(ABC):
timeout=self._timeout,
transport=transport,
event_hooks={"request": [self._auth_hook]},
verify=get_ssl_context(),
) as client:
yield client

View File

@@ -20,6 +20,7 @@ from src.services.provider_ops.architectures.base import (
VerifyResult,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.utils.ssl_utils import get_ssl_context
def _extract_cookies(cookie_string: str) -> Dict[str, str]:
@@ -197,6 +198,7 @@ class YesCodeArchitecture(ProviderArchitecture):
async with httpx.AsyncClient(
headers={"Cookie": cookie_header},
timeout=10.0,
verify=get_ssl_context(),
) as client:
combined_data = await fetch_yescode_combined_data(client, base_url)
extra_config["_combined_data"] = combined_data

View File

@@ -617,7 +617,7 @@ class ProviderOpsService:
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
sensitive_fields = [
"api_key", "password", "session_token", "cookie_string", "cookies",
"token_cookie", "auth_cookie", # Cookie 认证字段
"token_cookie", "auth_cookie", "session_cookie", # Cookie 认证字段
]
for field in sensitive_fields:
@@ -704,6 +704,8 @@ class ProviderOpsService:
"""
import httpx
from src.utils.ssl_utils import get_ssl_context
# 移除 base_url 末尾的斜杠
base_url = base_url.rstrip("/")
@@ -726,9 +728,14 @@ class ProviderOpsService:
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
response = await client.get(verify_endpoint, headers=headers)
logger.debug(
f"验证响应: status={response.status_code}, "
f"content_type={response.headers.get('content-type')}"
)
# 尝试解析 JSON
try:
data = response.json()

View File

@@ -31,6 +31,9 @@ class UserService:
role: UserRole = UserRole.USER,
quota_usd: Optional[float] = 10.0,
email_verified: bool = False,
allowed_providers: Optional[List[str]] = None,
allowed_api_formats: Optional[List[str]] = None,
allowed_models: Optional[List[str]] = None,
) -> User:
"""创建新用户quota_usd 为 None 表示无限制email 为 None 表示无邮箱"""
@@ -64,6 +67,9 @@ class UserService:
role=role,
quota_usd=quota_usd,
is_active=True,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
allowed_models=allowed_models,
)
user.set_password(password)

26
src/utils/ssl_utils.py Normal file
View File

@@ -0,0 +1,26 @@
"""
SSL 工具函数
提供统一的 SSL 上下文创建功能
"""
import ssl
try:
import certifi
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
except ImportError:
_SSL_CONTEXT = ssl.create_default_context()
def get_ssl_context() -> ssl.SSLContext:
"""
获取 SSL 上下文
优先使用 certifi 证书包,如果未安装则使用系统默认证书。
返回模块级缓存的 SSL 上下文实例。
Returns:
ssl.SSLContext: SSL 上下文
"""
return _SSL_CONTEXT