Files
TimeTracker/app/utils/env_validation.py
T
Dries Peeters e34a668ddc feat(auth): add LDAP directory authentication
Introduce AUTH_METHOD values ldap and all, with LDAP_* environment settings, ldap3-based LDAPService (search, optional groupOfNames checks, user bind, DB sync), and users.auth_provider (local|oidc|ldap) via migration 153_add_user_auth_provider.

Login supports LDAP-only and combined all (local then LDAP where appropriate); OIDC callback sets auth_provider. Forgot/reset/change password flows skip LDAP-managed accounts. Admin System Settings gains a read-only LDAP summary and POST /admin/ldap/test. Production env validation requires core LDAP variables when LDAP is enabled; OIDC registration and docs recognize all.

Documentation: new docs/admin/configuration/LDAP_SETUP.md; updates to OIDC_SETUP, GETTING_STARTED, Docker guides, Render deploy notes, docs README, and CHANGELOG. Tests: tests/test_ldap_auth.py; test_oidc_logout allows auth_method all.
2026-04-27 19:08:08 +02:00

199 lines
6.0 KiB
Python

"""
Environment variable validation on startup.
Ensures required configuration is present and valid.
"""
import os
from typing import Dict, List, Optional, Tuple
from flask import current_app
class EnvValidationError(Exception):
"""Raised when environment validation fails"""
pass
def validate_required_env_vars(required_vars: List[str], raise_on_error: bool = True) -> Tuple[bool, List[str]]:
"""
Validate that required environment variables are set.
Args:
required_vars: List of required environment variable names
raise_on_error: If True, raise EnvValidationError on failure
Returns:
Tuple of (is_valid, missing_vars)
"""
missing = []
for var in required_vars:
value = os.getenv(var)
if not value or value.strip() == "":
missing.append(var)
if missing and raise_on_error:
raise EnvValidationError(f"Missing required environment variables: {', '.join(missing)}")
return len(missing) == 0, missing
def validate_secret_key() -> bool:
"""
Validate that SECRET_KEY is set and secure.
Returns:
True if valid, False otherwise
"""
secret_key = os.getenv("SECRET_KEY", "")
placeholder_values = {"dev-secret-key-change-in-production", "your-secret-key-change-this", "your-secret-key-here"}
if not secret_key:
return False
if secret_key in placeholder_values:
return False
if len(secret_key) < 32:
return False
return True
def validate_database_url() -> bool:
"""
Validate that DATABASE_URL is set and valid.
Returns:
True if valid, False otherwise
"""
database_url = os.getenv("DATABASE_URL", "")
if not database_url:
# Check for PostgreSQL env vars
if all([os.getenv("POSTGRES_DB"), os.getenv("POSTGRES_USER"), os.getenv("POSTGRES_PASSWORD")]):
return True
return False
# Basic validation - check for known database schemes
valid_schemes = ["postgresql", "postgresql+psycopg2", "sqlite"]
if not any(database_url.startswith(scheme) for scheme in valid_schemes):
return False
return True
def validate_production_config() -> Tuple[bool, List[str]]:
"""
Validate production configuration requirements.
Returns:
Tuple of (is_valid, issues)
"""
issues = []
# Check SECRET_KEY
if not validate_secret_key():
issues.append("SECRET_KEY must be set and at least 32 characters long")
# Check database
if not validate_database_url():
issues.append("DATABASE_URL or PostgreSQL environment variables must be set")
# Check HTTPS settings in production
flask_env = os.getenv("FLASK_ENV", "production")
if flask_env == "production":
session_secure = os.getenv("SESSION_COOKIE_SECURE", "false").lower() == "true"
if not session_secure:
issues.append("SESSION_COOKIE_SECURE should be true in production")
# LDAP required vars when LDAP authentication is enabled
auth_method = (os.getenv("AUTH_METHOD", "local") or "local").strip().lower()
if auth_method in ("ldap", "all"):
for var in ("LDAP_HOST", "LDAP_BASE_DN", "LDAP_BIND_DN", "LDAP_BIND_PASSWORD"):
val = (os.getenv(var) or "").strip()
if not val:
issues.append(f"{var} must be set when AUTH_METHOD enables LDAP ({auth_method})")
return len(issues) == 0, issues
def validate_optional_env_vars() -> Dict[str, bool]:
"""
Validate optional environment variables and return their status.
Returns:
Dict mapping env var names to their validation status
"""
auth_m = (os.getenv("AUTH_METHOD", "") or "").strip().lower()
oidc_required = auth_m in ("oidc", "both", "all")
optional_vars = {
"TZ": lambda v: bool(v),
"CURRENCY": lambda v: bool(v),
"OIDC_ISSUER": lambda v: bool(v) if oidc_required else True,
"OIDC_CLIENT_ID": lambda v: bool(v) if oidc_required else True,
"OIDC_CLIENT_SECRET": lambda v: bool(v) if oidc_required else True,
}
results = {}
for var, validator in optional_vars.items():
value = os.getenv(var, "")
results[var] = validator(value)
return results
def validate_all(raise_on_error: bool = False) -> Tuple[bool, Dict[str, any]]:
"""
Validate all environment configuration.
Args:
raise_on_error: If True, raise EnvValidationError on critical failures
Returns:
Tuple of (is_valid, validation_results)
"""
results = {"required": {}, "optional": {}, "production": {}, "warnings": []}
# Required vars (minimal set)
required_vars = [] # Most vars have defaults, but SECRET_KEY is critical in production
is_production = os.getenv("FLASK_ENV", "production") == "production"
if is_production:
required_vars = ["SECRET_KEY"]
is_valid, missing = validate_required_env_vars(required_vars, raise_on_error=False)
results["required"] = {"valid": is_valid, "missing": missing}
# Secret key validation
secret_valid = validate_secret_key()
if not secret_valid and is_production:
results["warnings"].append("SECRET_KEY is not secure for production")
# Database validation
db_valid = validate_database_url()
results["required"]["database_valid"] = db_valid
# Production config validation
prod_valid, prod_issues = validate_production_config()
results["production"] = {"valid": prod_valid, "issues": prod_issues}
# Optional vars
results["optional"] = validate_optional_env_vars()
# Overall validity
overall_valid = is_valid and db_valid and (not is_production or prod_valid)
if not overall_valid and raise_on_error:
error_msg = "Environment validation failed:\n"
if missing:
error_msg += f" Missing: {', '.join(missing)}\n"
if not db_valid:
error_msg += " Database configuration invalid\n"
if prod_issues:
error_msg += f" Production issues: {', '.join(prod_issues)}\n"
raise EnvValidationError(error_msg.strip())
return overall_valid, results