Files
PassKeeper/app/config.py
T

121 lines
5.1 KiB
Python

import os
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv()
# Shared set of known-insecure placeholder values that must never reach production.
_INSECURE_SECRET_DEFAULTS = {'dev-secret-change-me', 'jwt-secret-change-me', '', 'change-me'}
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'
# ── Critical security checks — fail loudly at startup, not silently at runtime ──
# These checks run at class definition time (i.e. at import / app startup).
# Any misconfiguration raises RuntimeError immediately so the process never
# serves a single request with an insecure configuration.
_secret_key = os.environ.get('SECRET_KEY', '')
if not _secret_key or _secret_key in _INSECURE_SECRET_DEFAULTS:
raise RuntimeError(
'[PassKeeper] SECRET_KEY is not set or uses an insecure placeholder. '
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
'and add SECRET_KEY=<value> to your production .env file.'
)
SECRET_KEY = _secret_key
_jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
if not _jwt_secret or _jwt_secret in _INSECURE_SECRET_DEFAULTS:
raise RuntimeError(
'[PassKeeper] JWT_SECRET_KEY is not set or uses an insecure placeholder. '
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
'and add JWT_SECRET_KEY=<value> to your production .env file.'
)
JWT_SECRET_KEY = _jwt_secret
_cors = os.environ.get('CORS_ORIGINS', '')
if not _cors or _cors.strip() == '*':
raise RuntimeError(
'[PassKeeper] CORS_ORIGINS must be set to a specific origin in production '
'(e.g. CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech). '
'A wildcard "*" is not permitted in production.'
)
CORS_ORIGINS = _cors
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
}