feat: 加密并隐藏系统敏感配置
This commit is contained in:
@@ -22,6 +22,7 @@ from app.services.feishu_service import FeishuKnowledgeService
|
|||||||
from app.services.knowledge_service import KnowledgeScope
|
from app.services.knowledge_service import KnowledgeScope
|
||||||
from app.services.model_service import ModelClientService
|
from app.services.model_service import ModelClientService
|
||||||
from app.services.rag_service import RagResult
|
from app.services.rag_service import RagResult
|
||||||
|
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ def create_model(
|
|||||||
model_name=payload.modelName,
|
model_name=payload.modelName,
|
||||||
base_url=payload.baseUrl,
|
base_url=payload.baseUrl,
|
||||||
api_url=payload.apiUrl,
|
api_url=payload.apiUrl,
|
||||||
api_key=payload.apiKey,
|
api_key=SecretService.encrypt(payload.apiKey),
|
||||||
auth_type=payload.authType,
|
auth_type=payload.authType,
|
||||||
api_version=payload.apiVersion,
|
api_version=payload.apiVersion,
|
||||||
temperature=payload.temperature,
|
temperature=payload.temperature,
|
||||||
@@ -152,8 +153,8 @@ def update_model(
|
|||||||
model.model_name = payload.modelName
|
model.model_name = payload.modelName
|
||||||
model.base_url = payload.baseUrl
|
model.base_url = payload.baseUrl
|
||||||
model.api_url = payload.apiUrl
|
model.api_url = payload.apiUrl
|
||||||
if payload.apiKey:
|
if payload.apiKey and payload.apiKey != MASKED_SECRET:
|
||||||
model.api_key = payload.apiKey
|
model.api_key = SecretService.encrypt(payload.apiKey)
|
||||||
model.auth_type = payload.authType
|
model.auth_type = payload.authType
|
||||||
model.api_version = payload.apiVersion
|
model.api_version = payload.apiVersion
|
||||||
model.temperature = payload.temperature
|
model.temperature = payload.temperature
|
||||||
@@ -237,6 +238,11 @@ def save_config(
|
|||||||
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
|
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
|
||||||
if config is None:
|
if config is None:
|
||||||
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
|
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
|
||||||
|
if payload.configKey in SENSITIVE_CONFIG_KEYS:
|
||||||
|
if payload.configValue == MASKED_SECRET:
|
||||||
|
return api_success(_config_dict(config))
|
||||||
|
config.config_value = SecretService.encrypt(payload.configValue)
|
||||||
|
else:
|
||||||
config.config_value = payload.configValue
|
config.config_value = payload.configValue
|
||||||
config.description = payload.description
|
config.description = payload.description
|
||||||
config.updated_by = current_admin.id
|
config.updated_by = current_admin.id
|
||||||
@@ -257,7 +263,7 @@ def _model_dict(model: ModelConfig) -> dict:
|
|||||||
"modelName": model.model_name,
|
"modelName": model.model_name,
|
||||||
"baseUrl": model.base_url,
|
"baseUrl": model.base_url,
|
||||||
"apiUrl": model.api_url,
|
"apiUrl": model.api_url,
|
||||||
"apiKeyMasked": "******" if model.api_key else "",
|
"apiKeyMasked": SecretService.masked(model.api_key),
|
||||||
"authType": model.auth_type,
|
"authType": model.auth_type,
|
||||||
"apiVersion": model.api_version,
|
"apiVersion": model.api_version,
|
||||||
"temperature": float(model.temperature) if model.temperature is not None else None,
|
"temperature": float(model.temperature) if model.temperature is not None else None,
|
||||||
@@ -309,10 +315,13 @@ def clear_feishu_cache(current_admin: Admin = Depends(get_current_admin)) -> dic
|
|||||||
|
|
||||||
|
|
||||||
def _config_dict(config: SystemConfig) -> dict:
|
def _config_dict(config: SystemConfig) -> dict:
|
||||||
|
value = config.config_value
|
||||||
|
if config.config_key in SENSITIVE_CONFIG_KEYS:
|
||||||
|
value = SecretService.masked(value)
|
||||||
return {
|
return {
|
||||||
"id": config.id,
|
"id": config.id,
|
||||||
"configKey": config.config_key,
|
"configKey": config.config_key,
|
||||||
"configValue": config.config_value,
|
"configValue": value,
|
||||||
"description": config.description,
|
"description": config.description,
|
||||||
"updatedAt": config.updated_at,
|
"updatedAt": config.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class Settings(BaseSettings):
|
|||||||
auto_create_tables: bool = False
|
auto_create_tables: bool = False
|
||||||
|
|
||||||
jwt_secret_key: str = "change-me-in-production"
|
jwt_secret_key: str = "change-me-in-production"
|
||||||
|
config_encryption_key: str = ""
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 24 * 30
|
access_token_expire_minutes: int = 60 * 24 * 30
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ from app.api.router import api_router
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import create_tables
|
from app.core.database import create_tables
|
||||||
from app.core.exception_handlers import register_exception_handlers
|
from app.core.exception_handlers import register_exception_handlers
|
||||||
|
from app.services.secret_service import SecretService
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
SecretService.validate_production_key()
|
||||||
if settings.auto_create_tables:
|
if settings.auto_create_tables:
|
||||||
create_tables()
|
create_tables()
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.core.config import get_settings
|
|||||||
from app.models.ai_config import SystemConfig
|
from app.models.ai_config import SystemConfig
|
||||||
from app.services.external_errors import ExternalServiceError
|
from app.services.external_errors import ExternalServiceError
|
||||||
from app.services.knowledge_service import KnowledgeScope
|
from app.services.knowledge_service import KnowledgeScope
|
||||||
|
from app.services.secret_service import SecretService
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.services.rag_service import RetrievedChunk
|
from app.services.rag_service import RetrievedChunk
|
||||||
@@ -812,7 +813,7 @@ def _feishu_retrieval_config(db: Session | None) -> FeishuRetrievalConfig:
|
|||||||
return FeishuRetrievalConfig(
|
return FeishuRetrievalConfig(
|
||||||
search_url=_config_text(db, "feishu_search_url", settings.feishu_search_url),
|
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_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),
|
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),
|
retry_count=_config_int(db, "feishu_retry_count", settings.feishu_retry_count, minimum=0, maximum=10),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.core.config import get_settings
|
|||||||
from app.models.ai_config import ModelConfig, SystemConfig
|
from app.models.ai_config import ModelConfig, SystemConfig
|
||||||
from app.services.external_errors import ExternalServiceError
|
from app.services.external_errors import ExternalServiceError
|
||||||
from app.services.rag_service import NO_HIT_ANSWER, RagResult
|
from app.services.rag_service import NO_HIT_ANSWER, RagResult
|
||||||
|
from app.services.secret_service import SecretService
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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, "temperature", _decimal_to_float(model.temperature))
|
||||||
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
|
_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:
|
try:
|
||||||
response = httpx.post(base_url, json=payload, headers=headers, timeout=model.timeout_second)
|
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:
|
if model.api_url:
|
||||||
return model.api_url
|
return model.api_url
|
||||||
base_url = (model.base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
|
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}"
|
return f"{base_url}/models/{model.model_name}:generateContent?{query}"
|
||||||
|
|
||||||
|
|
||||||
def _auth_headers(model: ModelConfig) -> dict[str, str]:
|
def _auth_headers(model: ModelConfig) -> dict[str, str]:
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if model.auth_type == "api_key":
|
if model.auth_type == "api_key":
|
||||||
headers["x-api-key"] = model.api_key
|
headers["x-api-key"] = _model_api_key(model)
|
||||||
else:
|
else:
|
||||||
headers["Authorization"] = f"Bearer {model.api_key}"
|
headers["Authorization"] = f"Bearer {_model_api_key(model)}"
|
||||||
if model.api_version:
|
if model.api_version:
|
||||||
headers["api-version"] = model.api_version
|
headers["api-version"] = model.api_version
|
||||||
return headers
|
return headers
|
||||||
@@ -416,9 +417,9 @@ def _auth_headers(model: ModelConfig) -> dict[str, str]:
|
|||||||
def _anthropic_headers(model: ModelConfig) -> dict[str, str]:
|
def _anthropic_headers(model: ModelConfig) -> dict[str, str]:
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if model.auth_type == "bearer":
|
if model.auth_type == "bearer":
|
||||||
headers["Authorization"] = f"Bearer {model.api_key}"
|
headers["Authorization"] = f"Bearer {_model_api_key(model)}"
|
||||||
else:
|
else:
|
||||||
headers["x-api-key"] = model.api_key
|
headers["x-api-key"] = _model_api_key(model)
|
||||||
if model.api_version:
|
if model.api_version:
|
||||||
headers["anthropic-version"] = model.api_version
|
headers["anthropic-version"] = model.api_version
|
||||||
elif model.provider == "anthropic":
|
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
|
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:
|
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
|
||||||
if value is not None:
|
if value is not None:
|
||||||
target[key] = value
|
target[key] = value
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.ai_config import SystemConfig
|
from app.models.ai_config import SystemConfig
|
||||||
from app.services.redis_client import get_sync_redis_client
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
from app.services.secret_service import SecretService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -102,10 +103,8 @@ def _load_sms_config(db: Session) -> SmsProviderConfig:
|
|||||||
"aliyun_sms_access_key_id",
|
"aliyun_sms_access_key_id",
|
||||||
settings.aliyun_sms_access_key_id,
|
settings.aliyun_sms_access_key_id,
|
||||||
),
|
),
|
||||||
aliyun_sms_access_key_secret=_config_text(
|
aliyun_sms_access_key_secret=SecretService.decrypt(
|
||||||
values,
|
_config_text(values, "aliyun_sms_access_key_secret", settings.aliyun_sms_access_key_secret)
|
||||||
"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_sign_name=_config_text(values, "aliyun_sms_sign_name", settings.aliyun_sms_sign_name),
|
||||||
aliyun_sms_template_code=_config_text(
|
aliyun_sms_template_code=_config_text(
|
||||||
|
|||||||
Reference in New Issue
Block a user