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.core.logger import logger
from src.models.database import User from src.models.database import User
from src.utils.auth_utils import require_admin from src.utils.auth_utils import require_admin
from src.utils.ssl_utils import get_ssl_context
router = APIRouter() router = APIRouter()
@@ -118,7 +119,7 @@ async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
# 从 models.dev 获取数据 # 从 models.dev 获取数据
try: 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 = await client.get("https://models.dev/api.json")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()

View File

@@ -22,6 +22,7 @@ from src.services.model.upstream_fetcher import (
fetch_models_from_endpoints, fetch_models_from_endpoints,
) )
from src.utils.auth_utils import get_current_user 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"]) 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] 开始非流式测试...") logger.debug(f"[test-model] 开始非流式测试...")

View File

@@ -227,6 +227,11 @@ class AdminCreateUserAdapter(AdminApiAdapter):
else: else:
quota_usd = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0) 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: try:
user = UserService.create_user( user = UserService.create_user(
db=db, db=db,
@@ -235,6 +240,9 @@ class AdminCreateUserAdapter(AdminApiAdapter):
password=request.password, password=request.password,
role=role, role=role,
quota_usd=quota_usd, quota_usd=quota_usd,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
allowed_models=allowed_models,
) )
except ValueError as exc: except ValueError as exc:
raise InvalidRequestException(str(exc)) raise InvalidRequestException(str(exc))

View File

@@ -28,6 +28,7 @@ import httpx
from src.core.logger import logger from src.core.logger import logger
from src.core.headers import CORE_REDACT_HEADERS, merge_headers_with_protection, redact_headers_for_log 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]: def _redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
@@ -545,7 +546,7 @@ class HttpRequestExecutor:
try: try:
# 使用httpx进行异步请求 # 使用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( response = await client.post(
url=request.url, url=request.url,
json=request.json_body, 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.models.database import Model, Provider
from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator
from src.services.provider.transport import build_provider_url from src.services.provider.transport import build_provider_url
from src.utils.ssl_utils import get_ssl_context
router = APIRouter(tags=["System Catalog"]) router = APIRouter(tags=["System Catalog"])
@@ -281,7 +282,7 @@ async def test_connection(
is_stream=False, 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 = await client.post(url, json=provider_payload, headers=provider_headers)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()

View File

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

View File

@@ -241,6 +241,10 @@ class CreateUserRequest(BaseModel):
role: Optional[UserRole] = Field(UserRole.USER, description="用户角色") role: Optional[UserRole] = Field(UserRole.USER, description="用户角色")
quota_usd: Optional[float] = Field(default=None, description="USD配额null表示使用系统默认配额") quota_usd: Optional[float] = Field(default=None, description="USD配额null表示使用系统默认配额")
unlimited: bool = Field(default=False, description="是否无限配额") 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") @field_validator("quota_usd", mode="before")
@classmethod @classmethod

View File

@@ -7,6 +7,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
import httpx import httpx
from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo
from src.utils.ssl_utils import get_ssl_context
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.database import OAuthProvider from src.models.database import OAuthProvider
@@ -97,7 +98,7 @@ class OAuthProviderBase(ABC):
timeout_seconds: float = 5.0, timeout_seconds: float = 5.0,
headers: Optional[dict[str, str]] = None, headers: Optional[dict[str, str]] = None,
) -> httpx.Response: ) -> 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) return await client.post(url, data=data, headers=headers)
async def _http_get( async def _http_get(
@@ -107,5 +108,5 @@ class OAuthProviderBase(ABC):
timeout_seconds: float = 5.0, timeout_seconds: float = 5.0,
headers: Optional[dict[str, str]] = None, headers: Optional[dict[str, str]] = None,
) -> httpx.Response: ) -> 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) 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.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.auth.oauth.base import OAuthProviderBase from src.services.auth.oauth.base import OAuthProviderBase
from src.utils.ssl_utils import get_ssl_context
class OAuthService: class OAuthService:
@@ -785,7 +786,7 @@ class OAuthService:
async def _reachable(url: str) -> bool: async def _reachable(url: str) -> bool:
try: 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) await client.get(url)
return True return True
except Exception: except Exception:
@@ -800,7 +801,7 @@ class OAuthService:
if cfg.client_secret_encrypted: if cfg.client_secret_encrypted:
# 使用无效 code 做一次 token 请求(仅做粗略判定) # 使用无效 code 做一次 token 请求(仅做粗略判定)
try: 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( resp = await client.post(
token_url, token_url,
data={ data={
@@ -860,7 +861,7 @@ class OAuthService:
async def _reachable(url: str) -> bool: async def _reachable(url: str) -> bool:
try: 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) await client.get(url)
return True return True
except Exception: except Exception:
@@ -875,7 +876,7 @@ class OAuthService:
if client_secret: if client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定) # 使用无效 code 做一次 token 请求(仅做粗略判定)
try: 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( resp = await client.post(
token_url, token_url,
data={ data={

View File

@@ -4,7 +4,6 @@
""" """
import smtplib import smtplib
import ssl
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import Any, Optional, Tuple, Union from typing import Any, Optional, Tuple, Union
@@ -19,23 +18,13 @@ else:
AIOSMTPLIB_AVAILABLE = True AIOSMTPLIB_AVAILABLE = True
aiosmtplib = _aiosmtplib 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 sqlalchemy.orm import Session
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.utils.async_utils import run_in_executor from src.utils.async_utils import run_in_executor
from src.utils.ssl_utils import get_ssl_context
from .email_template import EmailTemplate from .email_template import EmailTemplate
@@ -224,7 +213,7 @@ class EmailSenderService:
message.attach(MIMEText(html_body, "html", "utf-8")) message.attach(MIMEText(html_body, "html", "utf-8"))
# 发送邮件 # 发送邮件
ssl_context = _create_ssl_context() ssl_context = get_ssl_context()
if config["smtp_use_ssl"]: if config["smtp_use_ssl"]:
await aiosmtplib.send( await aiosmtplib.send(
message, message,
@@ -324,7 +313,7 @@ class EmailSenderService:
# 连接 SMTP 服务器 # 连接 SMTP 服务器
server: Optional[smtplib.SMTP] = None server: Optional[smtplib.SMTP] = None
ssl_context = _create_ssl_context() ssl_context = get_ssl_context()
try: try:
if config["smtp_use_ssl"]: if config["smtp_use_ssl"]:
server = smtplib.SMTP_SSL( server = smtplib.SMTP_SSL(
@@ -392,7 +381,7 @@ class EmailSenderService:
return False, error return False, error
try: try:
ssl_context = _create_ssl_context() ssl_context = get_ssl_context()
if AIOSMTPLIB_AVAILABLE: if AIOSMTPLIB_AVAILABLE:
# 使用异步方式测试 # 使用异步方式测试
# 注意: use_tls=True 表示隐式 SSL (端口 465) # 注意: 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.headers import get_extra_headers_from_endpoint
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ProviderEndpoint from src.models.database import ProviderEndpoint
from src.utils.ssl_utils import get_ssl_context
# 并发请求限制 # 并发请求限制
MAX_CONCURRENT_REQUESTS = 5 MAX_CONCURRENT_REQUESTS = 5
@@ -148,7 +149,7 @@ async def fetch_models_from_endpoints(
logger.exception(f"获取 {api_format} 模型出错") logger.exception(f"获取 {api_format} 模型出错")
return [], f"{api_format}: error", False 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]) results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
for models, error, success in results: for models, error, success in results:
all_models.extend(models) all_models.extend(models)

View File

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

View File

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

View File

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

View File

@@ -617,7 +617,7 @@ class ProviderOpsService:
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials) saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
sensitive_fields = [ sensitive_fields = [
"api_key", "password", "session_token", "cookie_string", "cookies", "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: for field in sensitive_fields:
@@ -704,6 +704,8 @@ class ProviderOpsService:
""" """
import httpx import httpx
from src.utils.ssl_utils import get_ssl_context
# 移除 base_url 末尾的斜杠 # 移除 base_url 末尾的斜杠
base_url = base_url.rstrip("/") base_url = base_url.rstrip("/")
@@ -726,9 +728,14 @@ class ProviderOpsService:
) )
try: 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) response = await client.get(verify_endpoint, headers=headers)
logger.debug(
f"验证响应: status={response.status_code}, "
f"content_type={response.headers.get('content-type')}"
)
# 尝试解析 JSON # 尝试解析 JSON
try: try:
data = response.json() data = response.json()

View File

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