feat: 加密并隐藏系统敏感配置

This commit is contained in:
2026-07-10 11:49:21 +08:00
parent aedecb3aab
commit 9fb3dad5bd
7 changed files with 92 additions and 17 deletions

View File

@@ -13,6 +13,7 @@ from app.core.config import get_settings
from app.models.ai_config import SystemConfig
from app.services.external_errors import ExternalServiceError
from app.services.knowledge_service import KnowledgeScope
from app.services.secret_service import SecretService
if TYPE_CHECKING:
from app.services.rag_service import RetrievedChunk
@@ -812,7 +813,7 @@ def _feishu_retrieval_config(db: Session | None) -> FeishuRetrievalConfig:
return FeishuRetrievalConfig(
search_url=_config_text(db, "feishu_search_url", settings.feishu_search_url),
app_id=_config_text(db, "feishu_app_id", settings.feishu_app_id),
app_secret=_config_text(db, "feishu_app_secret", settings.feishu_app_secret),
app_secret=SecretService.decrypt(_config_text(db, "feishu_app_secret", settings.feishu_app_secret)),
timeout_seconds=_config_int(db, "feishu_timeout_seconds", settings.feishu_timeout_seconds, minimum=1, maximum=120),
retry_count=_config_int(db, "feishu_retry_count", settings.feishu_retry_count, minimum=0, maximum=10),
)

View File

@@ -13,6 +13,7 @@ from app.core.config import get_settings
from app.models.ai_config import ModelConfig, SystemConfig
from app.services.external_errors import ExternalServiceError
from app.services.rag_service import NO_HIT_ANSWER, RagResult
from app.services.secret_service import SecretService
@dataclass(frozen=True)
@@ -343,7 +344,7 @@ def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str:
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
headers = {"Authorization": f"Bearer {model.api_key}", "Content-Type": "application/json"}
headers = {"Authorization": f"Bearer {_model_api_key(model)}", "Content-Type": "application/json"}
try:
response = httpx.post(base_url, json=payload, headers=headers, timeout=model.timeout_second)
@@ -398,16 +399,16 @@ def _resolve_gemini_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
query = urlencode({"key": model.api_key})
query = urlencode({"key": _model_api_key(model)})
return f"{base_url}/models/{model.model_name}:generateContent?{query}"
def _auth_headers(model: ModelConfig) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if model.auth_type == "api_key":
headers["x-api-key"] = model.api_key
headers["x-api-key"] = _model_api_key(model)
else:
headers["Authorization"] = f"Bearer {model.api_key}"
headers["Authorization"] = f"Bearer {_model_api_key(model)}"
if model.api_version:
headers["api-version"] = model.api_version
return headers
@@ -416,9 +417,9 @@ def _auth_headers(model: ModelConfig) -> dict[str, str]:
def _anthropic_headers(model: ModelConfig) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if model.auth_type == "bearer":
headers["Authorization"] = f"Bearer {model.api_key}"
headers["Authorization"] = f"Bearer {_model_api_key(model)}"
else:
headers["x-api-key"] = model.api_key
headers["x-api-key"] = _model_api_key(model)
if model.api_version:
headers["anthropic-version"] = model.api_version
elif model.provider == "anthropic":
@@ -430,6 +431,10 @@ def _decimal_to_float(value: Any) -> float | None:
return float(value) if value is not None else None
def _model_api_key(model: ModelConfig) -> str:
return SecretService.decrypt(model.api_key)
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
if value is not None:
target[key] = value

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import get_settings
ENCRYPTED_PREFIX = "enc:v1:"
MASKED_SECRET = "******"
SENSITIVE_CONFIG_KEYS = {"aliyun_sms_access_key_secret", "feishu_app_secret"}
class SecretService:
@staticmethod
def encrypt(value: str) -> str:
if not value or value.startswith(ENCRYPTED_PREFIX):
return value
token = SecretService._fernet().encrypt(value.encode("utf-8")).decode("ascii")
return f"{ENCRYPTED_PREFIX}{token}"
@staticmethod
def decrypt(value: str | None) -> str:
if not value:
return ""
if not value.startswith(ENCRYPTED_PREFIX):
return value
try:
return SecretService._fernet().decrypt(value[len(ENCRYPTED_PREFIX) :].encode("ascii")).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError("敏感配置无法解密,请检查 CONFIG_ENCRYPTION_KEY 是否与保存时一致") from exc
@staticmethod
def masked(value: str | None) -> str:
return MASKED_SECRET if value else ""
@staticmethod
def validate_production_key() -> None:
settings = get_settings()
if settings.app_env.strip().lower() in {"local", "dev", "development", "docker", "test", "testing"}:
return
if not settings.config_encryption_key.strip():
raise RuntimeError("生产环境必须配置 CONFIG_ENCRYPTION_KEY")
SecretService._fernet()
@staticmethod
def _fernet() -> Fernet:
settings = get_settings()
configured = settings.config_encryption_key.strip()
if configured:
try:
return Fernet(configured.encode("ascii"))
except (ValueError, TypeError) as exc:
raise RuntimeError("CONFIG_ENCRYPTION_KEY 格式无效,必须是 Fernet 密钥") from exc
fallback = base64.urlsafe_b64encode(hashlib.sha256(settings.jwt_secret_key.encode("utf-8")).digest())
return Fernet(fallback)

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.ai_config import SystemConfig
from app.services.redis_client import get_sync_redis_client
from app.services.secret_service import SecretService
logger = logging.getLogger(__name__)
@@ -102,10 +103,8 @@ def _load_sms_config(db: Session) -> SmsProviderConfig:
"aliyun_sms_access_key_id",
settings.aliyun_sms_access_key_id,
),
aliyun_sms_access_key_secret=_config_text(
values,
"aliyun_sms_access_key_secret",
settings.aliyun_sms_access_key_secret,
aliyun_sms_access_key_secret=SecretService.decrypt(
_config_text(values, "aliyun_sms_access_key_secret", settings.aliyun_sms_access_key_secret)
),
aliyun_sms_sign_name=_config_text(values, "aliyun_sms_sign_name", settings.aliyun_sms_sign_name),
aliyun_sms_template_code=_config_text(