diff --git a/.env.example b/.env.example index 3ae9f0c..1b45d6e 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ SECRET_KEY=your-secret-key-here-change-in-production-use-openssl-rand-hex-32 # 数据加密密钥 - 用于敏感数据加密 # 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ENCRYPTION_KEY= +DATABASE_ENCRYPTION_KEY="rJC2VqLg4KrlSq+DGJcYm869q4v5KB2dFAeuQTe0I50=" # ============================================ # 数据库配置 (PostgreSQL) diff --git a/.env.local b/.env.local index 7b56e1c..d488870 100644 --- a/.env.local +++ b/.env.local @@ -19,4 +19,5 @@ METADATA_DB_NAME="system_hub" METADATA_DB_HOST="192.168.1.114" METADATA_DB_PORT="5432" METADATA_DB_USER="tjwater" -METADATA_DB_PASSWORD="Tjwater@123456" \ No newline at end of file +METADATA_DB_PASSWORD="Tjwater@123456" +DATABASE_ENCRYPTION_KEY="rJC2VqLg4KrlSq+DGJcYm869q4v5KB2dFAeuQTe0I50=" diff --git a/app/auth/project_dependencies.py b/app/auth/project_dependencies.py index ba2c172..a1d68df 100644 --- a/app/auth/project_dependencies.py +++ b/app/auth/project_dependencies.py @@ -68,9 +68,7 @@ async def get_project_context( status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" ) - membership_role = await metadata_repo.get_membership_role( - project_uuid, user.id - ) + membership_role = await metadata_repo.get_membership_role(project_uuid, user.id) if not membership_role: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No access to project" @@ -102,12 +100,12 @@ async def get_project_pg_session( ) except ValueError as exc: logger.error( - "Missing ENCRYPTION_KEY while resolving project PostgreSQL routing", + "Invalid project PostgreSQL routing DSN configuration", exc_info=True, ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="ENCRYPTION_KEY is not configured for project PostgreSQL access", + detail=f"Project PostgreSQL routing DSN is invalid: {exc}", ) from exc if not routing: raise HTTPException( @@ -143,12 +141,12 @@ async def get_project_pg_connection( ) except ValueError as exc: logger.error( - "Missing ENCRYPTION_KEY while resolving project PostgreSQL routing", + "Invalid project PostgreSQL routing DSN configuration", exc_info=True, ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="ENCRYPTION_KEY is not configured for project PostgreSQL access", + detail=f"Project PostgreSQL routing DSN is invalid: {exc}", ) from exc if not routing: raise HTTPException( @@ -184,12 +182,12 @@ async def get_project_timescale_connection( ) except ValueError as exc: logger.error( - "Missing ENCRYPTION_KEY while resolving project TimescaleDB routing", + "Invalid project TimescaleDB routing DSN configuration", exc_info=True, ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="ENCRYPTION_KEY is not configured for project TimescaleDB access", + detail=f"Project TimescaleDB routing DSN is invalid: {exc}", ) from exc if not routing: raise HTTPException( diff --git a/app/core/config.py b/app/core/config.py index fe12aaa..bbde235 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -17,6 +17,7 @@ class Settings(BaseSettings): # 数据加密密钥 (使用 Fernet) ENCRYPTION_KEY: str = "" # 必须从环境变量设置 + DATABASE_ENCRYPTION_KEY: str = "" # project_databases.dsn_encrypted 专用 # Database Config (PostgreSQL) DB_NAME: str = "tjwater" diff --git a/app/core/encryption.py b/app/core/encryption.py index bc54147..9b5f6c2 100644 --- a/app/core/encryption.py +++ b/app/core/encryption.py @@ -5,16 +5,17 @@ import os from app.core.config import settings + class Encryptor: """ 使用 Fernet (对称加密) 实现数据加密/解密 适用于加密敏感配置、用户数据等 """ - + def __init__(self, key: Optional[bytes] = None): """ 初始化加密器 - + Args: key: 加密密钥,如果为 None 则从环境变量读取 """ @@ -26,58 +27,71 @@ class Encryptor: "Generate one using: Encryptor.generate_key()" ) key = key_str.encode() - + self.fernet = Fernet(key) - + def encrypt(self, data: str) -> str: """ 加密字符串 - + Args: data: 待加密的明文字符串 - + Returns: Base64 编码的加密字符串 """ if not data: return data - + encrypted_bytes = self.fernet.encrypt(data.encode()) return encrypted_bytes.decode() - + def decrypt(self, data: str) -> str: """ 解密字符串 - + Args: data: Base64 编码的加密字符串 - + Returns: 解密后的明文字符串 """ if not data: return data - + decrypted_bytes = self.fernet.decrypt(data.encode()) return decrypted_bytes.decode() - + @staticmethod def generate_key() -> str: """ 生成新的 Fernet 加密密钥 - + Returns: Base64 编码的密钥字符串 """ key = Fernet.generate_key() return key.decode() + # 全局加密器实例(懒加载) _encryptor: Optional[Encryptor] = None +_database_encryptor: Optional[Encryptor] = None + def is_encryption_configured() -> bool: return bool(os.getenv("ENCRYPTION_KEY") or settings.ENCRYPTION_KEY) + +def is_database_encryption_configured() -> bool: + return bool( + os.getenv("DATABASE_ENCRYPTION_KEY") + or settings.DATABASE_ENCRYPTION_KEY + or os.getenv("ENCRYPTION_KEY") + or settings.ENCRYPTION_KEY + ) + + def get_encryptor() -> Encryptor: """获取全局加密器实例""" global _encryptor @@ -85,6 +99,26 @@ def get_encryptor() -> Encryptor: _encryptor = Encryptor() return _encryptor + +def get_database_encryptor() -> Encryptor: + """获取 project DB DSN 专用加密器实例""" + global _database_encryptor + if _database_encryptor is None: + key_str = ( + os.getenv("DATABASE_ENCRYPTION_KEY") + or settings.DATABASE_ENCRYPTION_KEY + or os.getenv("ENCRYPTION_KEY") + or settings.ENCRYPTION_KEY + ) + if not key_str: + raise ValueError( + "DATABASE_ENCRYPTION_KEY not found in environment variables or .env. " + "Generate one using: Encryptor.generate_key()" + ) + _database_encryptor = Encryptor(key=key_str.encode()) + return _database_encryptor + + # 向后兼容(延迟加载) def __getattr__(name): if name == "encryptor": diff --git a/app/infra/repositories/metadata_repository.py b/app/infra/repositories/metadata_repository.py index b02c05d..d8fbeee 100644 --- a/app/infra/repositories/metadata_repository.py +++ b/app/infra/repositories/metadata_repository.py @@ -2,10 +2,16 @@ from dataclasses import dataclass from typing import Optional, List from uuid import UUID +from cryptography.fernet import InvalidToken from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.encryption import get_encryptor, is_encryption_configured +from app.core.encryption import ( + get_database_encryptor, + get_encryptor, + is_database_encryption_configured, + is_encryption_configured, +) from app.infra.db.metadata import models @@ -65,9 +71,7 @@ class MetadataRepository: def __init__(self, session: AsyncSession): self.session = session - async def get_user_by_keycloak_id( - self, keycloak_id: UUID - ) -> Optional[models.User]: + async def get_user_by_keycloak_id(self, keycloak_id: UUID) -> Optional[models.User]: result = await self.session.execute( select(models.User).where(models.User.keycloak_id == keycloak_id) ) @@ -102,11 +106,16 @@ class MetadataRepository: record = result.scalar_one_or_none() if not record: return None - if is_encryption_configured(): - encryptor = get_encryptor() + if not is_database_encryption_configured(): + raise ValueError("DATABASE_ENCRYPTION_KEY is not configured") + encryptor = get_database_encryptor() + try: dsn = encryptor.decrypt(record.dsn_encrypted) - else: - dsn = record.dsn_encrypted + except InvalidToken: + raise ValueError( + "Failed to decrypt project DB DSN: DATABASE_ENCRYPTION_KEY mismatch " + "or invalid dsn_encrypted value" + ) dsn = _normalize_postgres_dsn(dsn) return ProjectDbRouting( project_id=record.project_id,