from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import Field from typing import List import os class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', case_sensitive=False, extra='ignore') APP_NAME: str = Field(default='Hotel Booking API', description='Application name') APP_VERSION: str = Field(default='1.0.0', description='Application version') ENVIRONMENT: str = Field(default='development', description='Environment: development, staging, production') DEBUG: bool = Field(default=False, description='Debug mode') API_V1_PREFIX: str = Field(default='/api/v1', description='API v1 prefix') HOST: str = Field(default='0.0.0.0', description='Server host. WARNING: 0.0.0.0 binds to all interfaces. Use 127.0.0.1 for development or specific IP for production.') # nosec B104 # Acceptable default with validation warning in production PORT: int = Field(default=8000, description='Server port') DB_USER: str = Field(default='root', description='Database user') DB_PASS: str = Field(default='', description='Database password') DB_NAME: str = Field(default='hotel_db', description='Database name') DB_HOST: str = Field(default='localhost', description='Database host') DB_PORT: str = Field(default='3306', description='Database port') JWT_SECRET: str = Field(default='', description='JWT secret key - MUST be set via environment variable. Minimum 64 characters recommended for production.') JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm') JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes') JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=3, description='JWT refresh token expiration in days (reduced from 7 for better security)') MAX_LOGIN_ATTEMPTS: int = Field(default=5, description='Maximum failed login attempts before account lockout') ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(default=30, description='Account lockout duration in minutes after max failed attempts') ENCRYPTION_KEY: str = Field(default='', description='Base64-encoded encryption key for data encryption at rest') CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL') CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins') RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting') RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP') RATE_LIMIT_ADMIN_PER_MINUTE: int = Field(default=300, description='Requests per minute for admin users') RATE_LIMIT_STAFF_PER_MINUTE: int = Field(default=200, description='Requests per minute for staff users') RATE_LIMIT_ACCOUNTANT_PER_MINUTE: int = Field(default=200, description='Requests per minute for accountant users') RATE_LIMIT_CUSTOMER_PER_MINUTE: int = Field(default=100, description='Requests per minute for customer users') CSRF_PROTECTION_ENABLED: bool = Field(default=True, description='Enable CSRF protection') HSTS_PRELOAD_ENABLED: bool = Field(default=False, description='Enable HSTS preload directive (requires domain submission to hstspreload.org)') LOG_LEVEL: str = Field(default='INFO', description='Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL') LOG_FILE: str = Field(default='logs/app.log', description='Log file path') LOG_MAX_BYTES: int = Field(default=10485760, description='Max log file size (10MB)') LOG_BACKUP_COUNT: int = Field(default=5, description='Number of backup log files') SMTP_HOST: str = Field(default='smtp.gmail.com', description='SMTP host') SMTP_PORT: int = Field(default=587, description='SMTP port') SMTP_USER: str = Field(default='', description='SMTP username') SMTP_PASSWORD: str = Field(default='', description='SMTP password') SMTP_FROM_EMAIL: str = Field(default='', description='From email address') SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name') UPLOAD_DIR: str = Field(default='uploads', description='Upload directory') MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)') MAX_REQUEST_BODY_SIZE: int = Field(default=10485760, description='Max request body size in bytes (10MB)') ALLOWED_EXTENSIONS: List[str] = Field(default_factory=lambda: ['jpg', 'jpeg', 'png', 'gif', 'webp'], description='Allowed file extensions') REDIS_ENABLED: bool = Field(default=False, description='Enable Redis caching') REDIS_HOST: str = Field(default='localhost', description='Redis host') REDIS_PORT: int = Field(default=6379, description='Redis port') REDIS_DB: int = Field(default=0, description='Redis database number') REDIS_PASSWORD: str = Field(default='', description='Redis password') REQUEST_TIMEOUT: int = Field(default=30, description='Request timeout in seconds') HEALTH_CHECK_INTERVAL: int = Field(default=30, description='Health check interval in seconds') STRIPE_SECRET_KEY: str = Field(default='', description='Stripe secret key') STRIPE_PUBLISHABLE_KEY: str = Field(default='', description='Stripe publishable key') STRIPE_WEBHOOK_SECRET: str = Field(default='', description='Stripe webhook secret') PAYPAL_CLIENT_ID: str = Field(default='', description='PayPal client ID') PAYPAL_CLIENT_SECRET: str = Field(default='', description='PayPal client secret') PAYPAL_MODE: str = Field(default='sandbox', description='PayPal mode: sandbox or live') BORICA_TERMINAL_ID: str = Field(default='', description='Borica Terminal ID') BORICA_MERCHANT_ID: str = Field(default='', description='Borica Merchant ID') BORICA_PRIVATE_KEY_PATH: str = Field(default='', description='Borica private key file path') BORICA_CERTIFICATE_PATH: str = Field(default='', description='Borica certificate file path') BORICA_GATEWAY_URL: str = Field(default='https://3dsgate-dev.borica.bg/cgi-bin/cgi_link', description='Borica gateway URL (test or production)') BORICA_MODE: str = Field(default='test', description='Borica mode: test or production') @property def database_url(self) -> str: """Generate database URL with proper credential escaping to prevent injection.""" from urllib.parse import quote_plus # Properly escape credentials to handle special characters user = quote_plus(self.DB_USER) password = quote_plus(self.DB_PASS) host = quote_plus(self.DB_HOST) port = str(self.DB_PORT) name = quote_plus(self.DB_NAME) return f'mysql+pymysql://{user}:{password}@{host}:{port}/{name}' @property def is_production(self) -> bool: return self.ENVIRONMENT.lower() == 'production' @property def is_development(self) -> bool: return self.ENVIRONMENT.lower() == 'development' @property def redis_url(self) -> str: if self.REDIS_PASSWORD: return f'redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' IP_WHITELIST_ENABLED: bool = Field(default=False, description='Enable IP whitelisting for admin endpoints') ADMIN_IP_WHITELIST: List[str] = Field(default_factory=list, description='List of allowed IP addresses/CIDR ranges for admin endpoints') def validate_host_configuration(self) -> None: """ Validate HOST configuration for security. Warns if binding to all interfaces (0.0.0.0) in production. """ if self.HOST == '0.0.0.0' and self.is_production: import logging logger = logging.getLogger(__name__) logger.warning( 'SECURITY WARNING: HOST is set to 0.0.0.0 in production. ' 'This binds the server to all network interfaces. ' 'Consider using a specific IP address or ensure proper firewall rules are in place.' ) def validate_encryption_key(self) -> None: """ Validate encryption key is properly configured. Raises ValueError if key is missing or invalid in production. """ if not self.ENCRYPTION_KEY: if self.is_production: raise ValueError( 'CRITICAL: ENCRYPTION_KEY is not configured in production. ' 'Please set ENCRYPTION_KEY environment variable to a base64-encoded 32-byte key.' ) else: # In development, warn but don't fail import logging logger = logging.getLogger(__name__) logger.warning( 'ENCRYPTION_KEY is not configured. Encryption operations may fail. ' 'Please set ENCRYPTION_KEY environment variable.' ) return # Validate base64 encoding and key length (32 bytes = 44 base64 chars) try: import base64 decoded = base64.b64decode(self.ENCRYPTION_KEY) if len(decoded) != 32: raise ValueError( f'ENCRYPTION_KEY must be a base64-encoded 32-byte key. ' f'Received {len(decoded)} bytes after decoding.' ) except Exception as e: if self.is_production: raise ValueError( f'Invalid ENCRYPTION_KEY format: {str(e)}. ' 'Must be a valid base64-encoded 32-byte key.' ) else: import logging logger = logging.getLogger(__name__) logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}') settings = Settings() # Validate JWT_SECRET on startup - fail fast if not configured def validate_jwt_secret(): """Validate JWT_SECRET is properly configured. Called on startup.""" if not settings.JWT_SECRET or settings.JWT_SECRET.strip() == '': error_msg = ( 'CRITICAL SECURITY ERROR: JWT_SECRET is not configured. ' 'Please set JWT_SECRET environment variable to a secure random string. ' 'Minimum 64 characters recommended for production. ' 'Generate one using: python -c "import secrets; print(secrets.token_urlsafe(64))"' ) import logging logger = logging.getLogger(__name__) logger.error(error_msg) if settings.is_production: raise ValueError(error_msg) else: logger.warning( 'JWT_SECRET not configured. This will cause authentication to fail. ' 'Set JWT_SECRET environment variable before starting the application.' ) # Warn if using weak secret (less than 64 characters) if len(settings.JWT_SECRET) < 64: import logging logger = logging.getLogger(__name__) if settings.is_production: logger.warning( f'JWT_SECRET is only {len(settings.JWT_SECRET)} characters. ' 'Recommend using at least 64 characters for production security.' ) else: logger.debug(f'JWT_SECRET length: {len(settings.JWT_SECRET)} characters') # Validate on import validate_jwt_secret() settings.validate_host_configuration()