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

@@ -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)