mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
251 lines
11 KiB
Python
251 lines
11 KiB
Python
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
from typing import List
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(
|
||
env_file=".env",
|
||
case_sensitive=True,
|
||
extra="ignore",
|
||
)
|
||
|
||
APP_ENV: str = "local"
|
||
APP_NAME: str = "legal-case-tracker"
|
||
|
||
PUBLIC_JWT_TTL_DAYS: int = 7
|
||
ADMIN_JWT_TTL_MINUTES: int = 240
|
||
ADMIN_JWT_SECRET: str = "change_me_admin"
|
||
ADMIN_AUTH_MODE: str = "password_totp_optional" # password | password_totp_optional | password_totp_required
|
||
TOTP_ISSUER: str = "Правовой Трекер"
|
||
PUBLIC_JWT_SECRET: str = "change_me_public"
|
||
PUBLIC_COOKIE_NAME: str = "public_jwt"
|
||
PUBLIC_COOKIE_SECURE: bool = False
|
||
PUBLIC_COOKIE_SAMESITE: str = "lax"
|
||
PUBLIC_STRICT_ORIGIN_CHECK: bool = True
|
||
PUBLIC_ALLOWED_WEB_ORIGINS: str = (
|
||
"http://localhost:8080,http://localhost:8081,"
|
||
"https://ruakb.ru,https://www.ruakb.ru,"
|
||
"https://ruakb.online,https://www.ruakb.online"
|
||
)
|
||
PRODUCTION_ENFORCE_SECURE_SETTINGS: bool = True
|
||
|
||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081"
|
||
CORS_ALLOW_METHODS: str = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||
CORS_ALLOW_HEADERS: str = "Authorization,Content-Type,X-Requested-With,X-Request-ID"
|
||
CORS_ALLOW_CREDENTIALS: bool = True
|
||
|
||
DATABASE_URL: str
|
||
REDIS_URL: str
|
||
|
||
S3_ENDPOINT: str
|
||
S3_ACCESS_KEY: str
|
||
S3_SECRET_KEY: str
|
||
S3_BUCKET: str
|
||
S3_REGION: str = "us-east-1"
|
||
S3_USE_SSL: bool = False
|
||
S3_VERIFY_SSL: bool = True
|
||
S3_CA_CERT_PATH: str = ""
|
||
MAX_FILE_MB: int = 25
|
||
MAX_CASE_MB: int = 250
|
||
ATTACHMENT_SCAN_ENABLED: bool = False
|
||
ATTACHMENT_SCAN_ENFORCE: bool = False
|
||
ATTACHMENT_ALLOWED_MIME_TYPES: str = (
|
||
"application/pdf,image/jpeg,image/png,video/mp4,text/plain"
|
||
)
|
||
CLAMAV_ENABLED: bool = False
|
||
CLAMAV_HOST: str = "clamav"
|
||
CLAMAV_PORT: int = 3310
|
||
CLAMAV_TIMEOUT_SECONDS: int = 20
|
||
|
||
TELEGRAM_BOT_TOKEN: str = "change_me"
|
||
TELEGRAM_CHAT_ID: str = "0"
|
||
SMS_PROVIDER: str = "dummy"
|
||
SMSAERO_EMAIL: str = ""
|
||
SMSAERO_API_KEY: str = ""
|
||
OTP_SMS_TEMPLATE: str = "Your verification code: {code}"
|
||
OTP_AUTOTEST_FORCE_MOCK_SMS: bool = True
|
||
PUBLIC_AUTH_MODE: str = "sms" # sms | email | sms_or_email | totp
|
||
EMAIL_PROVIDER: str = "dummy" # dummy | smtp
|
||
EMAIL_SERVICE_ENABLED: bool = True
|
||
EMAIL_SERVICE_URL: str = "http://email-service:8010"
|
||
INTERNAL_SERVICE_TOKEN: str = "change_me_internal_service_token"
|
||
SMTP_HOST: str = ""
|
||
SMTP_PORT: int = 587
|
||
SMTP_USER: str = ""
|
||
SMTP_PASSWORD: str = ""
|
||
SMTP_FROM: str = ""
|
||
SMTP_USE_TLS: bool = True
|
||
SMTP_USE_SSL: bool = False
|
||
OTP_EMAIL_SUBJECT_TEMPLATE: str = "Код подтверждения: {code}"
|
||
OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}"
|
||
OTP_EMAIL_FALLBACK_ENABLED: bool = True
|
||
OTP_SMS_MIN_BALANCE: float = 20.0
|
||
DATA_ENCRYPTION_ACTIVE_KID: str = "legacy"
|
||
DATA_ENCRYPTION_KEYS: str = ""
|
||
CHAT_ENCRYPTION_ACTIVE_KID: str = ""
|
||
CHAT_ENCRYPTION_KEYS: str = ""
|
||
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
||
CHAT_ENCRYPTION_SECRET: str = ""
|
||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||
OTP_SEND_RATE_LIMIT: int = 8
|
||
OTP_VERIFY_RATE_LIMIT: int = 20
|
||
OTP_DEV_MODE: bool = False
|
||
ADMIN_BOOTSTRAP_ENABLED: bool = True
|
||
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
|
||
ADMIN_BOOTSTRAP_PASSWORD: str = "admin123"
|
||
ADMIN_BOOTSTRAP_NAME: str = "Администратор системы"
|
||
|
||
# Compose/infra vars that may exist in shared .env
|
||
POSTGRES_USER: str = "postgres"
|
||
POSTGRES_PASSWORD: str = "postgres"
|
||
POSTGRES_DB: str = "legal"
|
||
MINIO_ROOT_USER: str = "minio_local_admin"
|
||
MINIO_ROOT_PASSWORD: str = "minio_local_password_change_me"
|
||
MINIO_TLS_ENABLED: bool = False
|
||
|
||
@property
|
||
def cors_origins_list(self) -> List[str]:
|
||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||
|
||
@property
|
||
def cors_allow_methods_list(self) -> List[str]:
|
||
values = [v.strip().upper() for v in str(self.CORS_ALLOW_METHODS or "").split(",") if v.strip()]
|
||
return values or ["GET", "POST", "OPTIONS"]
|
||
|
||
@property
|
||
def cors_allow_headers_list(self) -> List[str]:
|
||
values = [v.strip() for v in str(self.CORS_ALLOW_HEADERS or "").split(",") if v.strip()]
|
||
return values or ["Authorization", "Content-Type"]
|
||
|
||
@property
|
||
def public_allowed_web_origins_list(self) -> List[str]:
|
||
values: list[str] = []
|
||
for item in str(self.PUBLIC_ALLOWED_WEB_ORIGINS or "").split(","):
|
||
value = item.strip().rstrip("/").lower()
|
||
if value:
|
||
values.append(value)
|
||
return values
|
||
|
||
@property
|
||
def app_env_is_production(self) -> bool:
|
||
return str(self.APP_ENV or "").strip().lower() in {"prod", "production"}
|
||
|
||
@property
|
||
def public_cookie_secure_effective(self) -> bool:
|
||
if self.app_env_is_production:
|
||
return True
|
||
return bool(self.PUBLIC_COOKIE_SECURE)
|
||
|
||
@property
|
||
def public_cookie_samesite_effective(self) -> str:
|
||
raw = str(self.PUBLIC_COOKIE_SAMESITE or "lax").strip().lower()
|
||
if raw in {"lax", "strict", "none"}:
|
||
return raw
|
||
return "lax"
|
||
|
||
settings = Settings()
|
||
|
||
|
||
def _looks_insecure_secret(value: str, *, min_len: int = 16) -> bool:
|
||
raw = str(value or "").strip()
|
||
lowered = raw.lower()
|
||
if len(raw) < min_len:
|
||
return True
|
||
markers = (
|
||
"change_me",
|
||
"example",
|
||
"admin123",
|
||
"password",
|
||
"test",
|
||
"local",
|
||
)
|
||
return any(marker in lowered for marker in markers)
|
||
|
||
|
||
def validate_production_security_or_raise(component: str = "app") -> None:
|
||
if not settings.app_env_is_production:
|
||
return
|
||
if not bool(getattr(settings, "PRODUCTION_ENFORCE_SECURE_SETTINGS", True)):
|
||
return
|
||
|
||
issues: list[str] = []
|
||
|
||
if bool(settings.OTP_DEV_MODE):
|
||
issues.append("OTP_DEV_MODE=true запрещен в production")
|
||
if bool(settings.ADMIN_BOOTSTRAP_ENABLED):
|
||
issues.append("ADMIN_BOOTSTRAP_ENABLED=true запрещен в production")
|
||
|
||
if not settings.public_cookie_secure_effective:
|
||
issues.append("PUBLIC cookie должен быть secure в production")
|
||
|
||
if settings.public_cookie_samesite_effective == "none" and not settings.public_cookie_secure_effective:
|
||
issues.append("PUBLIC_COOKIE_SAMESITE=none требует secure cookie")
|
||
|
||
if _looks_insecure_secret(settings.ADMIN_JWT_SECRET):
|
||
issues.append("ADMIN_JWT_SECRET выглядит небезопасным")
|
||
if _looks_insecure_secret(settings.PUBLIC_JWT_SECRET):
|
||
issues.append("PUBLIC_JWT_SECRET выглядит небезопасным")
|
||
if _looks_insecure_secret(settings.DATA_ENCRYPTION_SECRET):
|
||
issues.append("DATA_ENCRYPTION_SECRET выглядит небезопасным")
|
||
if _looks_insecure_secret(settings.INTERNAL_SERVICE_TOKEN):
|
||
issues.append("INTERNAL_SERVICE_TOKEN выглядит небезопасным")
|
||
|
||
if not str(settings.CHAT_ENCRYPTION_SECRET or "").strip():
|
||
# Backward-compatible: keyring-based CHAT_ENCRYPTION_KEYS is allowed.
|
||
if not str(getattr(settings, "CHAT_ENCRYPTION_KEYS", "") or "").strip():
|
||
issues.append("CHAT_ENCRYPTION_SECRET или CHAT_ENCRYPTION_KEYS обязателен в production")
|
||
|
||
if not str(getattr(settings, "DATA_ENCRYPTION_ACTIVE_KID", "") or "").strip():
|
||
issues.append("DATA_ENCRYPTION_ACTIVE_KID должен быть задан в production")
|
||
|
||
minio_user = str(settings.MINIO_ROOT_USER or "").strip().lower()
|
||
minio_password = str(settings.MINIO_ROOT_PASSWORD or "").strip()
|
||
if minio_user in {"", "minioadmin", "minio_local_admin"}:
|
||
issues.append("MINIO_ROOT_USER должен быть переопределен для production")
|
||
if _looks_insecure_secret(minio_password):
|
||
issues.append("MINIO_ROOT_PASSWORD выглядит небезопасным")
|
||
|
||
if not bool(settings.S3_USE_SSL):
|
||
issues.append("S3_USE_SSL должен быть включен в production")
|
||
s3_endpoint = str(settings.S3_ENDPOINT or "").strip().lower()
|
||
if not s3_endpoint.startswith("https://"):
|
||
issues.append("S3_ENDPOINT должен начинаться с https:// в production")
|
||
if not bool(settings.S3_VERIFY_SSL):
|
||
issues.append("S3_VERIFY_SSL должен быть включен в production")
|
||
if not str(settings.S3_CA_CERT_PATH or "").strip():
|
||
issues.append("S3_CA_CERT_PATH должен быть задан для trusted TLS в production")
|
||
if not bool(settings.MINIO_TLS_ENABLED):
|
||
issues.append("MINIO_TLS_ENABLED должен быть включен в production")
|
||
|
||
if bool(getattr(settings, "PUBLIC_STRICT_ORIGIN_CHECK", True)):
|
||
allowed_public_origins = settings.public_allowed_web_origins_list
|
||
if not allowed_public_origins:
|
||
issues.append("PUBLIC_ALLOWED_WEB_ORIGINS должен быть задан в production")
|
||
for origin in allowed_public_origins:
|
||
if "localhost" in origin or "127.0.0.1" in origin:
|
||
issues.append("PUBLIC_ALLOWED_WEB_ORIGINS не должен содержать localhost в production")
|
||
break
|
||
|
||
cors_origins = [item.strip().lower().rstrip("/") for item in settings.cors_origins_list]
|
||
if not cors_origins:
|
||
issues.append("CORS_ORIGINS должен быть задан в production")
|
||
for origin in cors_origins:
|
||
if origin == "*" or "*" in origin:
|
||
issues.append("CORS_ORIGINS не должен содержать wildcard (*) в production")
|
||
break
|
||
if "localhost" in origin or "127.0.0.1" in origin:
|
||
issues.append("CORS_ORIGINS не должен содержать localhost в production")
|
||
break
|
||
if not origin.startswith("https://"):
|
||
issues.append("CORS_ORIGINS должен содержать только https origins в production")
|
||
break
|
||
|
||
cors_methods = [item.strip().upper() for item in settings.cors_allow_methods_list]
|
||
if "*" in cors_methods:
|
||
issues.append("CORS_ALLOW_METHODS не должен содержать wildcard (*) в production")
|
||
cors_headers_lower = [item.strip().lower() for item in settings.cors_allow_headers_list]
|
||
if "*" in cors_headers_lower:
|
||
issues.append("CORS_ALLOW_HEADERS не должен содержать wildcard (*) в production")
|
||
|
||
if issues:
|
||
formatted = "\n".join(f"- {item}" for item in issues)
|
||
raise RuntimeError(f"[{component}] insecure production configuration:\n{formatted}")
|