import os from datetime import timedelta from dotenv import load_dotenv load_dotenv() class BaseConfig: SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me') JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-change-me') JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7) SQLALCHEMY_DATABASE_URI = ( 'mysql+pymysql://{user}:{password}@{host}:{port}/{db}?charset=utf8mb4'.format( user=os.environ.get('MYSQL_USER', 'passkeeper'), password=os.environ.get('MYSQL_PASSWORD', ''), host=os.environ.get('MYSQL_HOST', '127.0.0.1'), port=os.environ.get('MYSQL_PORT', '3306'), db=os.environ.get('MYSQL_DB', 'passkeeper'), ) ) SQLALCHEMY_TRACK_MODIFICATIONS = False # MySQL closes idle connections after wait_timeout (default 8 hours). # pool_recycle ensures SQLAlchemy replaces connections before that deadline. # pool_pre_ping sends a cheap SELECT 1 before each checkout so stale # connections are detected and recycled rather than causing "MySQL has gone # away" errors on the first request after a long idle period. SQLALCHEMY_ENGINE_OPTIONS = { 'pool_recycle': 3600, # recycle connections after 1 hour 'pool_pre_ping': True, # test each connection before use 'pool_timeout': 30, # raise after 30 s if no connection available 'pool_size': 10, # base pool size per worker 'max_overflow': 5, # allow up to 5 extra connections under load } WTF_CSRF_ENABLED = True WTF_CSRF_TIME_LIMIT = 3600 ARGON2_TIME_COST = int(os.environ.get('ARGON2_TIME_COST', 3)) ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536)) ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4)) # Server-side AES-256-GCM key for encrypting TOTP secrets at rest. # Must be a 64-character hex string (32 bytes). # Generate: python -c "import secrets; print(secrets.token_hex(32))" TOTP_ENCRYPTION_KEY = os.environ.get('TOTP_ENCRYPTION_KEY', '') # Rate limiting — use Redis in production so all Gunicorn workers share one counter. # Falls back to memory (per-worker, dev only). RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://') # CORS — restrict to the production origin in production config. CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*') # Cache-busting version string appended as ?v=... to all static assets. # Bump this value on every deploy to force browsers to reload CSS/JS. # Set via .env: STATIC_VERSION=20260418 STATIC_VERSION = os.environ.get('STATIC_VERSION', '1') # Session cookie defaults — applied in all environments. # SECURE is intentionally left out of BaseConfig so dev HTTP still works. # See ProductionConfig below for the full hardened set. SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' class DevelopmentConfig(BaseConfig): DEBUG = True RATELIMIT_ENABLED = False class ProductionConfig(BaseConfig): DEBUG = False RATELIMIT_ENABLED = True # Force HTTPS in production — marks cookie Secure so it is never sent over HTTP. SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' config = { 'development': DevelopmentConfig, 'production': ProductionConfig, }