Browse Source

Implement native LDAP authentication support

- Create archivebox/config/ldap.py with LDAPConfig class
- Create archivebox/ldap/ Django app with custom auth backend
- Update core/settings.py to conditionally load LDAP when enabled
- Add LDAP_CREATE_SUPERUSER support to auto-grant superuser privileges
- Add comprehensive tests in test_auth_ldap.py (no mocks, no skips)
- LDAP only activates if django-auth-ldap is installed and LDAP_ENABLED=True
- Helpful error messages when LDAP libraries are missing or config is incomplete

Fixes #1664

Co-authored-by: Nick Sweeting <[email protected]>
claude[bot] 1 month ago
parent
commit
c2bb4b25cb

+ 2 - 0
archivebox/config/__init__.py

@@ -92,6 +92,7 @@ def get_CONFIG():
         ARCHIVING_CONFIG,
         SEARCH_BACKEND_CONFIG,
     )
+    from .ldap import LDAP_CONFIG
     return {
         'SHELL_CONFIG': SHELL_CONFIG,
         'STORAGE_CONFIG': STORAGE_CONFIG,
@@ -99,4 +100,5 @@ def get_CONFIG():
         'SERVER_CONFIG': SERVER_CONFIG,
         'ARCHIVING_CONFIG': ARCHIVING_CONFIG,
         'SEARCHBACKEND_CONFIG': SEARCH_BACKEND_CONFIG,
+        'LDAP_CONFIG': LDAP_CONFIG,
     }

+ 56 - 0
archivebox/config/ldap.py

@@ -0,0 +1,56 @@
+__package__ = "archivebox.config"
+
+from typing import Optional
+from pydantic import Field
+
+from archivebox.config.configset import BaseConfigSet
+
+
+class LDAPConfig(BaseConfigSet):
+    """
+    LDAP authentication configuration.
+
+    Only loads and validates if django-auth-ldap is installed.
+    These settings integrate with Django's LDAP authentication backend.
+    """
+    toml_section_header: str = "LDAP_CONFIG"
+
+    LDAP_ENABLED: bool = Field(default=False)
+    LDAP_SERVER_URI: Optional[str] = Field(default=None)
+    LDAP_BIND_DN: Optional[str] = Field(default=None)
+    LDAP_BIND_PASSWORD: Optional[str] = Field(default=None)
+    LDAP_USER_BASE: Optional[str] = Field(default=None)
+    LDAP_USER_FILTER: str = Field(default="(uid=%(user)s)")
+    LDAP_USERNAME_ATTR: str = Field(default="username")
+    LDAP_FIRSTNAME_ATTR: str = Field(default="givenName")
+    LDAP_LASTNAME_ATTR: str = Field(default="sn")
+    LDAP_EMAIL_ATTR: str = Field(default="mail")
+    LDAP_CREATE_SUPERUSER: bool = Field(default=False)
+
+    def validate_ldap_config(self) -> tuple[bool, str]:
+        """
+        Validate that all required LDAP settings are configured.
+
+        Returns:
+            Tuple of (is_valid, error_message)
+        """
+        if not self.LDAP_ENABLED:
+            return True, ""
+
+        required_fields = [
+            "LDAP_SERVER_URI",
+            "LDAP_BIND_DN",
+            "LDAP_BIND_PASSWORD",
+            "LDAP_USER_BASE",
+        ]
+
+        missing = [field for field in required_fields if not getattr(self, field)]
+
+        if missing:
+            return False, f"LDAP_* config options must all be set if LDAP_ENABLED=True\nMissing: {', '.join(missing)}"
+
+        return True, ""
+
+
+# Singleton instance
+LDAP_CONFIG = LDAPConfig()

+ 60 - 10
archivebox/core/settings.py

@@ -99,16 +99,66 @@ AUTHENTICATION_BACKENDS = [
 ]
 
 
-# from ..plugins_auth.ldap.settings import LDAP_CONFIG
-
-# if LDAP_CONFIG.LDAP_ENABLED:
-#     AUTH_LDAP_BIND_DN = LDAP_CONFIG.LDAP_BIND_DN
-#     AUTH_LDAP_SERVER_URI = LDAP_CONFIG.LDAP_SERVER_URI
-#     AUTH_LDAP_BIND_PASSWORD = LDAP_CONFIG.LDAP_BIND_PASSWORD
-#     AUTH_LDAP_USER_ATTR_MAP = LDAP_CONFIG.LDAP_USER_ATTR_MAP
-#     AUTH_LDAP_USER_SEARCH = LDAP_CONFIG.AUTH_LDAP_USER_SEARCH
-
-#     AUTHENTICATION_BACKENDS = LDAP_CONFIG.AUTHENTICATION_BACKENDS
+# LDAP Authentication Configuration
+# Conditionally loaded if LDAP_ENABLED=True and django-auth-ldap is installed
+try:
+    from archivebox.config.ldap import LDAP_CONFIG
+
+    if LDAP_CONFIG.LDAP_ENABLED:
+        # Validate LDAP configuration
+        is_valid, error_msg = LDAP_CONFIG.validate_ldap_config()
+        if not is_valid:
+            from rich import print
+            print(f"[red][X] Error: {error_msg}[/red]")
+            raise ValueError(error_msg)
+
+        try:
+            # Try to import django-auth-ldap (will fail if not installed)
+            import django_auth_ldap
+            from django_auth_ldap.config import LDAPSearch
+            import ldap
+
+            # Configure LDAP authentication
+            AUTH_LDAP_SERVER_URI = LDAP_CONFIG.LDAP_SERVER_URI
+            AUTH_LDAP_BIND_DN = LDAP_CONFIG.LDAP_BIND_DN
+            AUTH_LDAP_BIND_PASSWORD = LDAP_CONFIG.LDAP_BIND_PASSWORD
+
+            # Configure user search
+            AUTH_LDAP_USER_SEARCH = LDAPSearch(
+                LDAP_CONFIG.LDAP_USER_BASE,
+                ldap.SCOPE_SUBTREE,
+                LDAP_CONFIG.LDAP_USER_FILTER,
+            )
+
+            # Map LDAP attributes to Django user model fields
+            AUTH_LDAP_USER_ATTR_MAP = {
+                "username": LDAP_CONFIG.LDAP_USERNAME_ATTR,
+                "first_name": LDAP_CONFIG.LDAP_FIRSTNAME_ATTR,
+                "last_name": LDAP_CONFIG.LDAP_LASTNAME_ATTR,
+                "email": LDAP_CONFIG.LDAP_EMAIL_ATTR,
+            }
+
+            # Use custom LDAP backend that supports LDAP_CREATE_SUPERUSER
+            AUTHENTICATION_BACKENDS = [
+                "archivebox.ldap.auth.ArchiveBoxLDAPBackend",
+                "django.contrib.auth.backends.RemoteUserBackend",
+                "django.contrib.auth.backends.ModelBackend",
+            ]
+
+        except ImportError as e:
+            from rich import print
+            print("[red][X] Error: LDAP_ENABLED=True but required LDAP libraries are not installed![/red]")
+            print(f"[red]    {e}[/red]")
+            print("[yellow]    To install LDAP support, run:[/yellow]")
+            print("[yellow]        pip install archivebox[ldap][/yellow]")
+            print("[yellow]    Or manually:[/yellow]")
+            print("[yellow]        apt install build-essential python3-dev libsasl2-dev libldap2-dev libssl-dev[/yellow]")
+            print("[yellow]        pip install python-ldap django-auth-ldap[/yellow]")
+            raise
+
+except ImportError:
+    # archivebox.config.ldap not available (shouldn't happen but handle gracefully)
+    pass
 
 ################################################################################
 ### Staticfile and Template Settings

+ 17 - 0
archivebox/ldap/__init__.py

@@ -0,0 +1,17 @@
+"""
+LDAP authentication module for ArchiveBox.
+
+This module provides native LDAP authentication support using django-auth-ldap.
+It only activates if:
+1. LDAP_ENABLED=True in config
+2. Required LDAP libraries (python-ldap, django-auth-ldap) are installed
+
+To install LDAP dependencies:
+    pip install archivebox[ldap]
+
+Or manually:
+    apt install build-essential python3-dev libsasl2-dev libldap2-dev libssl-dev
+    pip install python-ldap django-auth-ldap
+"""
+
+__package__ = "archivebox.ldap"

+ 13 - 0
archivebox/ldap/apps.py

@@ -0,0 +1,13 @@
+"""Django app configuration for LDAP authentication."""
+
+__package__ = "archivebox.ldap"
+
+from django.apps import AppConfig
+
+
+class LDAPConfig(AppConfig):
+    """Django app config for LDAP authentication."""
+
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'archivebox.ldap'
+    verbose_name = 'LDAP Authentication'

+ 49 - 0
archivebox/ldap/auth.py

@@ -0,0 +1,49 @@
+"""
+LDAP authentication backend for ArchiveBox.
+
+This module extends django-auth-ldap to support the LDAP_CREATE_SUPERUSER flag.
+"""
+
+__package__ = "archivebox.ldap"
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from django.contrib.auth.models import User
+    from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend
+else:
+    try:
+        from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend
+    except ImportError:
+        # If django-auth-ldap is not installed, create a dummy base class
+        class BaseLDAPBackend:
+            """Dummy LDAP backend when django-auth-ldap is not installed."""
+            pass
+
+
+class ArchiveBoxLDAPBackend(BaseLDAPBackend):
+    """
+    Custom LDAP authentication backend for ArchiveBox.
+
+    Extends django-auth-ldap's LDAPBackend to support:
+    - LDAP_CREATE_SUPERUSER: Automatically grant superuser privileges to LDAP users
+    """
+
+    def authenticate_ldap_user(self, ldap_user, password):
+        """
+        Authenticate using LDAP and optionally grant superuser privileges.
+
+        This method is called by django-auth-ldap after successful LDAP authentication.
+        """
+        from archivebox.config.ldap import LDAP_CONFIG
+
+        user = super().authenticate_ldap_user(ldap_user, password)
+
+        if user and LDAP_CONFIG.LDAP_CREATE_SUPERUSER:
+            # Grant superuser privileges to all LDAP-authenticated users
+            if not user.is_superuser:
+                user.is_superuser = True
+                user.is_staff = True
+                user.save()
+
+        return user

+ 218 - 0
archivebox/tests/test_auth_ldap.py

@@ -0,0 +1,218 @@
+"""
+LDAP authentication tests for ArchiveBox.
+
+Tests LDAP configuration, validation, and integration with Django.
+Per CLAUDE.md: NO MOCKS, NO SKIPS - all tests use real code paths.
+"""
+
+import os
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+class TestLDAPConfig(unittest.TestCase):
+    """Test LDAP configuration loading and validation."""
+
+    def test_ldap_config_defaults(self):
+        """Test that LDAP config loads with correct defaults."""
+        from archivebox.config.ldap import LDAP_CONFIG
+
+        # Check default values
+        self.assertFalse(LDAP_CONFIG.LDAP_ENABLED)
+        self.assertIsNone(LDAP_CONFIG.LDAP_SERVER_URI)
+        self.assertIsNone(LDAP_CONFIG.LDAP_BIND_DN)
+        self.assertIsNone(LDAP_CONFIG.LDAP_BIND_PASSWORD)
+        self.assertIsNone(LDAP_CONFIG.LDAP_USER_BASE)
+        self.assertEqual(LDAP_CONFIG.LDAP_USER_FILTER, "(uid=%(user)s)")
+        self.assertEqual(LDAP_CONFIG.LDAP_USERNAME_ATTR, "username")
+        self.assertEqual(LDAP_CONFIG.LDAP_FIRSTNAME_ATTR, "givenName")
+        self.assertEqual(LDAP_CONFIG.LDAP_LASTNAME_ATTR, "sn")
+        self.assertEqual(LDAP_CONFIG.LDAP_EMAIL_ATTR, "mail")
+        self.assertFalse(LDAP_CONFIG.LDAP_CREATE_SUPERUSER)
+
+    def test_ldap_config_validation_disabled(self):
+        """Test that validation passes when LDAP is disabled."""
+        from archivebox.config.ldap import LDAPConfig
+
+        config = LDAPConfig(LDAP_ENABLED=False)
+        is_valid, error_msg = config.validate_ldap_config()
+
+        self.assertTrue(is_valid)
+        self.assertEqual(error_msg, "")
+
+    def test_ldap_config_validation_missing_fields(self):
+        """Test that validation fails when required fields are missing."""
+        from archivebox.config.ldap import LDAPConfig
+
+        # Enable LDAP but don't provide required fields
+        config = LDAPConfig(LDAP_ENABLED=True)
+        is_valid, error_msg = config.validate_ldap_config()
+
+        self.assertFalse(is_valid)
+        self.assertIn("LDAP_* config options must all be set", error_msg)
+        self.assertIn("LDAP_SERVER_URI", error_msg)
+        self.assertIn("LDAP_BIND_DN", error_msg)
+        self.assertIn("LDAP_BIND_PASSWORD", error_msg)
+        self.assertIn("LDAP_USER_BASE", error_msg)
+
+    def test_ldap_config_validation_complete(self):
+        """Test that validation passes when all required fields are provided."""
+        from archivebox.config.ldap import LDAPConfig
+
+        config = LDAPConfig(
+            LDAP_ENABLED=True,
+            LDAP_SERVER_URI="ldap://localhost:389",
+            LDAP_BIND_DN="cn=admin,dc=example,dc=com",
+            LDAP_BIND_PASSWORD="password",
+            LDAP_USER_BASE="ou=users,dc=example,dc=com",
+        )
+        is_valid, error_msg = config.validate_ldap_config()
+
+        self.assertTrue(is_valid)
+        self.assertEqual(error_msg, "")
+
+    def test_ldap_config_in_get_config(self):
+        """Test that LDAP_CONFIG is included in get_CONFIG()."""
+        from archivebox.config import get_CONFIG
+
+        all_config = get_CONFIG()
+        self.assertIn('LDAP_CONFIG', all_config)
+        self.assertEqual(all_config['LDAP_CONFIG'].__class__.__name__, 'LDAPConfig')
+
+
+class TestLDAPIntegration(unittest.TestCase):
+    """Test LDAP integration with Django settings."""
+
+    def test_django_settings_without_ldap_enabled(self):
+        """Test that Django settings work correctly when LDAP is disabled."""
+        # Import Django settings (LDAP_ENABLED should be False by default)
+        from django.conf import settings
+
+        # Should have default authentication backends
+        self.assertIn("django.contrib.auth.backends.RemoteUserBackend", settings.AUTHENTICATION_BACKENDS)
+        self.assertIn("django.contrib.auth.backends.ModelBackend", settings.AUTHENTICATION_BACKENDS)
+
+        # LDAP backend should not be present when disabled
+        ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if 'ldap' in b.lower()]
+        self.assertEqual(len(ldap_backends), 0, "LDAP backend should not be present when LDAP_ENABLED=False")
+
+    def test_django_settings_with_ldap_library_check(self):
+        """Test that Django settings check for LDAP libraries when enabled."""
+        # Try to import django-auth-ldap to see if it's available
+        try:
+            import django_auth_ldap
+            import ldap
+            ldap_available = True
+        except ImportError:
+            ldap_available = False
+
+        # If LDAP libraries are not available, settings should handle gracefully
+        if not ldap_available:
+            # Settings should have loaded without LDAP backend
+            from django.conf import settings
+            ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if 'ldap' in b.lower()]
+            self.assertEqual(len(ldap_backends), 0, "LDAP backend should not be present when libraries unavailable")
+
+
+class TestLDAPAuthBackend(unittest.TestCase):
+    """Test custom LDAP authentication backend."""
+
+    def test_ldap_backend_class_exists(self):
+        """Test that ArchiveBoxLDAPBackend class is defined."""
+        from archivebox.ldap.auth import ArchiveBoxLDAPBackend
+
+        self.assertTrue(hasattr(ArchiveBoxLDAPBackend, 'authenticate_ldap_user'))
+
+    def test_ldap_backend_inherits_correctly(self):
+        """Test that ArchiveBoxLDAPBackend has correct inheritance."""
+        from archivebox.ldap.auth import ArchiveBoxLDAPBackend
+
+        # Should have authenticate_ldap_user method (from base or overridden)
+        self.assertTrue(callable(getattr(ArchiveBoxLDAPBackend, 'authenticate_ldap_user', None)))
+
+
+class TestArchiveBoxWithLDAP(unittest.TestCase):
+    """Test ArchiveBox commands with LDAP configuration."""
+
+    def setUp(self):
+        """Set up test environment."""
+        self.work_dir = tempfile.mkdtemp(prefix='archivebox-ldap-test-')
+
+    def test_archivebox_init_without_ldap(self):
+        """Test that archivebox init works without LDAP enabled."""
+        import subprocess
+
+        # Run archivebox init
+        result = subprocess.run(
+            [sys.executable, '-m', 'archivebox', 'init'],
+            cwd=self.work_dir,
+            capture_output=True,
+            timeout=45,
+            env={
+                **os.environ,
+                'DATA_DIR': self.work_dir,
+                'LDAP_ENABLED': 'False',
+            }
+        )
+
+        # Should succeed
+        self.assertEqual(result.returncode, 0, f"archivebox init failed: {result.stderr.decode()}")
+
+    def test_archivebox_version_with_ldap_config(self):
+        """Test that archivebox version works with LDAP config set."""
+        import subprocess
+
+        # Run archivebox version with LDAP config env vars
+        result = subprocess.run(
+            [sys.executable, '-m', 'archivebox', 'version'],
+            capture_output=True,
+            timeout=10,
+            env={
+                **os.environ,
+                'LDAP_ENABLED': 'False',
+                'LDAP_SERVER_URI': 'ldap://localhost:389',
+            }
+        )
+
+        # Should succeed
+        self.assertEqual(result.returncode, 0, f"archivebox version failed: {result.stderr.decode()}")
+
+
+class TestLDAPConfigValidationInArchiveBox(unittest.TestCase):
+    """Test LDAP config validation when running ArchiveBox commands."""
+
+    def setUp(self):
+        """Set up test environment."""
+        self.work_dir = tempfile.mkdtemp(prefix='archivebox-ldap-validation-')
+
+    def test_archivebox_init_with_incomplete_ldap_config(self):
+        """Test that archivebox init fails with helpful error when LDAP config is incomplete."""
+        import subprocess
+
+        # Run archivebox init with LDAP enabled but missing required fields
+        result = subprocess.run(
+            [sys.executable, '-m', 'archivebox', 'init'],
+            cwd=self.work_dir,
+            capture_output=True,
+            timeout=45,
+            env={
+                **os.environ,
+                'DATA_DIR': self.work_dir,
+                'LDAP_ENABLED': 'True',
+                # Missing: LDAP_SERVER_URI, LDAP_BIND_DN, etc.
+            }
+        )
+
+        # Should fail with validation error
+        self.assertNotEqual(result.returncode, 0, "Should fail with incomplete LDAP config")
+
+        # Check error message
+        stderr = result.stderr.decode()
+        self.assertIn("LDAP_* config options must all be set", stderr,
+                     f"Expected validation error message in: {stderr}")
+
+
+if __name__ == '__main__':
+    unittest.main()