Files
Gondulf/src/gondulf/config.py
Phil Skentelbery d3c3e8dc6b feat(security): merge Phase 4b security hardening
Complete security hardening implementation including HTTPS enforcement,
security headers, rate limiting, and comprehensive security test suite.

Key features:
- HTTPS enforcement with HSTS support
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options)
- Rate limiting for all critical endpoints
- Enhanced email template security
- 87% test coverage with security-specific tests

Architect approval: 9.5/10

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 18:28:50 -07:00

183 lines
6.4 KiB
Python

"""
Configuration management for Gondulf IndieAuth server.
Loads configuration from environment variables with GONDULF_ prefix.
Validates required settings on startup and provides sensible defaults.
"""
import os
from dotenv import load_dotenv
# Load environment variables from .env file if present
load_dotenv()
class ConfigurationError(Exception):
"""Raised when configuration is invalid or missing required values."""
pass
class Config:
"""Application configuration loaded from environment variables."""
# Required settings - no defaults
SECRET_KEY: str
BASE_URL: str
# Database
DATABASE_URL: str
# SMTP Configuration
SMTP_HOST: str
SMTP_PORT: int
SMTP_USERNAME: str | None
SMTP_PASSWORD: str | None
SMTP_FROM: str
SMTP_USE_TLS: bool
# Token and Code Expiry (seconds)
TOKEN_EXPIRY: int
CODE_EXPIRY: int
# Token Cleanup (Phase 3)
TOKEN_CLEANUP_ENABLED: bool
TOKEN_CLEANUP_INTERVAL: int
# Security Configuration (Phase 4b)
HTTPS_REDIRECT: bool
TRUST_PROXY: bool
SECURE_COOKIES: bool
# Logging
LOG_LEVEL: str
DEBUG: bool
@classmethod
def load(cls) -> None:
"""
Load and validate configuration from environment variables.
Raises:
ConfigurationError: If required settings are missing or invalid
"""
# Required - SECRET_KEY must exist and be sufficiently long
secret_key = os.getenv("GONDULF_SECRET_KEY")
if not secret_key:
raise ConfigurationError(
"GONDULF_SECRET_KEY is required. Generate with: "
"python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
if len(secret_key) < 32:
raise ConfigurationError(
"GONDULF_SECRET_KEY must be at least 32 characters for security"
)
cls.SECRET_KEY = secret_key
# Required - BASE_URL must exist for OAuth metadata
base_url = os.getenv("GONDULF_BASE_URL")
if not base_url:
raise ConfigurationError(
"GONDULF_BASE_URL is required for OAuth 2.0 metadata endpoint. "
"Examples: https://auth.example.com or http://localhost:8000 (development only)"
)
# Normalize: remove trailing slash if present
cls.BASE_URL = base_url.rstrip("/")
# Database - with sensible default
cls.DATABASE_URL = os.getenv(
"GONDULF_DATABASE_URL", "sqlite:///./data/gondulf.db"
)
# SMTP Configuration
cls.SMTP_HOST = os.getenv("GONDULF_SMTP_HOST", "localhost")
cls.SMTP_PORT = int(os.getenv("GONDULF_SMTP_PORT", "587"))
cls.SMTP_USERNAME = os.getenv("GONDULF_SMTP_USERNAME") or None
cls.SMTP_PASSWORD = os.getenv("GONDULF_SMTP_PASSWORD") or None
cls.SMTP_FROM = os.getenv("GONDULF_SMTP_FROM", "noreply@example.com")
cls.SMTP_USE_TLS = os.getenv("GONDULF_SMTP_USE_TLS", "true").lower() == "true"
# Token and Code Expiry
cls.TOKEN_EXPIRY = int(os.getenv("GONDULF_TOKEN_EXPIRY", "3600"))
cls.CODE_EXPIRY = int(os.getenv("GONDULF_CODE_EXPIRY", "600"))
# Token Cleanup Configuration
cls.TOKEN_CLEANUP_ENABLED = os.getenv("GONDULF_TOKEN_CLEANUP_ENABLED", "false").lower() == "true"
cls.TOKEN_CLEANUP_INTERVAL = int(os.getenv("GONDULF_TOKEN_CLEANUP_INTERVAL", "3600"))
# Security Configuration (Phase 4b)
cls.HTTPS_REDIRECT = os.getenv("GONDULF_HTTPS_REDIRECT", "true").lower() == "true"
cls.TRUST_PROXY = os.getenv("GONDULF_TRUST_PROXY", "false").lower() == "true"
cls.SECURE_COOKIES = os.getenv("GONDULF_SECURE_COOKIES", "true").lower() == "true"
# Logging
cls.DEBUG = os.getenv("GONDULF_DEBUG", "false").lower() == "true"
# If DEBUG is true, default LOG_LEVEL to DEBUG, otherwise INFO
default_log_level = "DEBUG" if cls.DEBUG else "INFO"
cls.LOG_LEVEL = os.getenv("GONDULF_LOG_LEVEL", default_log_level).upper()
# Validate log level
valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if cls.LOG_LEVEL not in valid_log_levels:
raise ConfigurationError(
f"GONDULF_LOG_LEVEL must be one of: {', '.join(valid_log_levels)}"
)
@classmethod
def validate(cls) -> None:
"""
Validate configuration after loading.
Performs additional validation beyond initial loading.
"""
# Validate BASE_URL is a valid URL
if not cls.BASE_URL.startswith(("http://", "https://")):
raise ConfigurationError(
"GONDULF_BASE_URL must start with http:// or https://"
)
# Warn if using http:// in production-like settings
if cls.BASE_URL.startswith("http://") and "localhost" not in cls.BASE_URL:
import warnings
warnings.warn(
"GONDULF_BASE_URL uses http:// for non-localhost domain. "
"HTTPS is required for production IndieAuth servers.",
UserWarning
)
# Validate SMTP port is reasonable
if cls.SMTP_PORT < 1 or cls.SMTP_PORT > 65535:
raise ConfigurationError(
f"GONDULF_SMTP_PORT must be between 1 and 65535, got {cls.SMTP_PORT}"
)
# Validate expiry times are positive and within bounds
if cls.TOKEN_EXPIRY < 300: # Minimum 5 minutes
raise ConfigurationError(
"GONDULF_TOKEN_EXPIRY must be at least 300 seconds (5 minutes)"
)
if cls.TOKEN_EXPIRY > 86400: # Maximum 24 hours
raise ConfigurationError(
"GONDULF_TOKEN_EXPIRY must be at most 86400 seconds (24 hours)"
)
if cls.CODE_EXPIRY <= 0:
raise ConfigurationError(
f"GONDULF_CODE_EXPIRY must be positive, got {cls.CODE_EXPIRY}"
)
# Validate cleanup interval if enabled
if cls.TOKEN_CLEANUP_ENABLED and cls.TOKEN_CLEANUP_INTERVAL < 600:
raise ConfigurationError(
"GONDULF_TOKEN_CLEANUP_INTERVAL must be at least 600 seconds (10 minutes)"
)
# Disable HTTPS redirect in development mode
if cls.DEBUG:
cls.HTTPS_REDIRECT = False
# Configuration is loaded lazily or explicitly by the application
# Tests should call Config.load() explicitly in fixtures
# Production code should call Config.load() at startup