ldapgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ldapgate/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """ldapgate - LDAP/AD authentication proxy and FastAPI middleware"""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = [
6
+ "LDAPAuthenticator",
7
+ "LDAPConfig",
8
+ "LDAPAuthMiddleware",
9
+ "SessionManager",
10
+ "create_proxy_app",
11
+ "create_login_router",
12
+ "add_ldap_auth",
13
+ ]
14
+
15
+ from ldapgate.config import LDAPConfig
16
+ from ldapgate.ldap import LDAPAuthenticator
17
+ from ldapgate.middleware import LDAPAuthMiddleware, add_ldap_auth
18
+ from ldapgate.proxy import create_proxy_app, create_login_router
19
+ from ldapgate.sessions import SessionManager
ldapgate/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """CLI for ldapgate reverse proxy."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import click
7
+ import uvicorn
8
+
9
+ from ldapgate.config import load_config
10
+ from ldapgate.proxy import create_proxy_app
11
+
12
+ # Env var used to pass config path to the module-level app factory for --reload mode
13
+ _CONFIG_ENV_VAR = "LDAPGATE_CONFIG_PATH"
14
+
15
+
16
+ @click.group()
17
+ def cli():
18
+ """ldapgate - LDAP/AD authentication proxy."""
19
+ pass
20
+
21
+
22
+ @cli.command()
23
+ @click.option(
24
+ "--config",
25
+ type=click.Path(path_type=Path),
26
+ default=None,
27
+ help="Path to ldapgate.yaml config file (reads env vars if omitted)",
28
+ )
29
+ @click.option(
30
+ "--host",
31
+ default=None,
32
+ help="Override listen host (default: 0.0.0.0)",
33
+ )
34
+ @click.option(
35
+ "--port",
36
+ type=int,
37
+ default=None,
38
+ help="Override listen port (default: 9000)",
39
+ )
40
+ @click.option(
41
+ "--backend",
42
+ default=None,
43
+ help="Override backend URL",
44
+ )
45
+ @click.option(
46
+ "--reload",
47
+ is_flag=True,
48
+ help="Enable auto-reload on code changes",
49
+ )
50
+ def serve(config: Path, host: str, port: int, backend: str, reload: bool):
51
+ """Start ldapgate reverse proxy server.
52
+
53
+ Example:
54
+ ldapgate serve --config ldapgate.yaml
55
+ ldapgate serve --backend http://localhost:3923
56
+ """
57
+ try:
58
+ cfg = load_config(config)
59
+
60
+ if host:
61
+ cfg.proxy.listen_host = host
62
+ if port:
63
+ cfg.proxy.listen_port = port
64
+ if backend:
65
+ cfg.proxy.backend_url = backend
66
+
67
+ click.echo(
68
+ f"Starting ldapgate proxy on {cfg.proxy.listen_host}:{cfg.proxy.listen_port}"
69
+ )
70
+ click.echo(f"Backend: {cfg.proxy.backend_url}")
71
+ click.echo(f"Login path: {cfg.proxy.login_path}")
72
+
73
+ if reload:
74
+ # Reload mode requires an import string; pass config + overrides via env vars
75
+ # so the factory in this module can reconstruct the app identically.
76
+ if config:
77
+ os.environ[_CONFIG_ENV_VAR] = str(config)
78
+ if backend:
79
+ os.environ["LDAPGATE_BACKEND_URL"] = backend
80
+ uvicorn.run(
81
+ "ldapgate.cli:_reload_app_factory",
82
+ factory=True,
83
+ host=cfg.proxy.listen_host,
84
+ port=cfg.proxy.listen_port,
85
+ reload=True,
86
+ log_level="info",
87
+ )
88
+ else:
89
+ app = create_proxy_app(cfg)
90
+ uvicorn.run(
91
+ app,
92
+ host=cfg.proxy.listen_host,
93
+ port=cfg.proxy.listen_port,
94
+ log_level="info",
95
+ )
96
+
97
+ except FileNotFoundError as e:
98
+ click.echo(f"Error: {e}", err=True)
99
+ raise SystemExit(1)
100
+ except Exception as e:
101
+ click.echo(f"Error: {e}", err=True)
102
+ raise SystemExit(1)
103
+
104
+
105
+ def _reload_app_factory():
106
+ """App factory used by uvicorn --reload (needs importable callable)."""
107
+ config_path = os.environ.get(_CONFIG_ENV_VAR)
108
+ cfg = load_config(config_path)
109
+ backend = os.environ.get("LDAPGATE_BACKEND_URL")
110
+ if backend:
111
+ cfg.proxy.backend_url = backend
112
+ return create_proxy_app(cfg)
113
+
114
+
115
+ def main():
116
+ """Entry point for ldapgate CLI."""
117
+ cli()
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
ldapgate/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Configuration management for ldapgate."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, Field
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class LDAPSettings(BaseModel):
12
+ """LDAP/AD configuration."""
13
+
14
+ url: str = Field(
15
+ ..., description="LDAP server URL (e.g., ldaps://dc.example.com:636)"
16
+ )
17
+ bind_dn: str = Field(..., description="Service account DN for binding")
18
+ bind_password: str = Field(..., description="Service account password")
19
+ base_dn: str = Field(..., description="Base DN for user searches")
20
+ user_filter: str = Field(
21
+ "(sAMAccountName={username})",
22
+ description="LDAP filter for user lookup (e.g., AD: sAMAccountName, OpenLDAP: uid)",
23
+ )
24
+ group_dn: Optional[str] = Field(
25
+ None, description="Optional group DN to restrict access (e.g., CN=app-users,..."
26
+ )
27
+ timeout: int = Field(10, description="LDAP connection timeout in seconds")
28
+
29
+
30
+ class ProxySettings(BaseModel):
31
+ """Reverse proxy configuration."""
32
+
33
+ listen_host: str = Field("0.0.0.0", description="Host to listen on")
34
+ listen_port: int = Field(9000, description="Port to listen on")
35
+ backend_url: str = Field(..., description="Backend service URL to proxy to")
36
+ secret_key: str = Field(..., description="Secret key for signing session cookies")
37
+ session_ttl: int = Field(3600, description="Session time-to-live in seconds")
38
+ user_header: str = Field(
39
+ "X-Forwarded-User", description="Header name for authenticated username"
40
+ )
41
+ login_path: str = Field("/_auth/login", description="Login page path")
42
+ logout_path: str = Field("/_auth/logout", description="Logout page path")
43
+ app_name: str = Field("ldapgate", description="Application name for login form")
44
+ secure_cookies: bool = Field(
45
+ False,
46
+ description="Set Secure flag on session cookies (enable when behind HTTPS proxy)",
47
+ )
48
+
49
+
50
+ class LDAPConfig(BaseSettings):
51
+ """Complete ldapgate configuration."""
52
+
53
+ ldap: LDAPSettings
54
+ proxy: ProxySettings
55
+
56
+ model_config = SettingsConfigDict(
57
+ env_nested_delimiter="__",
58
+ )
59
+
60
+ @classmethod
61
+ def from_yaml(cls, path: str | Path) -> "LDAPConfig":
62
+ """Load configuration from YAML file.
63
+
64
+ Args:
65
+ path: Path to YAML config file
66
+
67
+ Returns:
68
+ Configured LDAPConfig instance
69
+
70
+ Raises:
71
+ FileNotFoundError: If config file doesn't exist
72
+ yaml.YAMLError: If YAML is invalid
73
+ """
74
+ path = Path(path)
75
+ if not path.exists():
76
+ raise FileNotFoundError(f"Config file not found: {path}")
77
+
78
+ with open(path) as f:
79
+ data = yaml.safe_load(f)
80
+
81
+ if not data:
82
+ raise ValueError("Empty config file")
83
+
84
+ return cls(**data)
85
+
86
+ @classmethod
87
+ def from_env(cls) -> "LDAPConfig":
88
+ """Load configuration from environment variables.
89
+
90
+ Expected format:
91
+ - LDAP__URL
92
+ - LDAP__BIND_DN
93
+ - LDAP__BIND_PASSWORD
94
+ - etc.
95
+
96
+ Returns:
97
+ Configured LDAPConfig instance
98
+ """
99
+ return cls()
100
+
101
+
102
+ def load_config(yaml_path: str | Path | None = None) -> LDAPConfig:
103
+ """Load configuration from YAML or environment.
104
+
105
+ Args:
106
+ yaml_path: Optional path to YAML config file.
107
+ If provided, loads from file. Otherwise uses environment vars.
108
+
109
+ Returns:
110
+ Configured LDAPConfig instance
111
+ """
112
+ if yaml_path:
113
+ return LDAPConfig.from_yaml(yaml_path)
114
+ return LDAPConfig.from_env()
ldapgate/ldap.py ADDED
@@ -0,0 +1,148 @@
1
+ """LDAP/AD authentication core using ldap3."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from ldap3 import BASE, NONE, SUBTREE, Connection, Server
7
+ from ldap3.core.exceptions import LDAPException
8
+
9
+ from ldapgate.config import LDAPSettings
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def _escape_ldap(value: str) -> str:
15
+ """Escape special characters in LDAP filter values (RFC 4515)."""
16
+ replacements = [
17
+ ("\\", "\\5c"),
18
+ ("*", "\\2a"),
19
+ ("(", "\\28"),
20
+ (")", "\\29"),
21
+ ("\x00", "\\00"),
22
+ ]
23
+ for char, escaped in replacements:
24
+ value = value.replace(char, escaped)
25
+ return value
26
+
27
+
28
+ class LDAPAuthenticator:
29
+ """Authenticates users against LDAP/AD directory."""
30
+
31
+ def __init__(self, config: LDAPSettings):
32
+ """Initialize LDAP authenticator.
33
+
34
+ Args:
35
+ config: LDAP configuration with server details and filters
36
+ """
37
+ self.config = config
38
+ self.server = Server(config.url, connect_timeout=config.timeout, get_info=NONE)
39
+
40
+ async def authenticate(self, username: str, password: str) -> bool:
41
+ """Authenticate user against LDAP directory.
42
+
43
+ Process:
44
+ 1. Bind as service account
45
+ 2. Search for user DN matching user_filter
46
+ 3. Re-bind with user DN + supplied password
47
+ 4. Optionally check group membership
48
+
49
+ Args:
50
+ username: Username to authenticate
51
+ password: Password to verify
52
+
53
+ Returns:
54
+ True if authentication successful, False otherwise
55
+ """
56
+ return await asyncio.to_thread(
57
+ self._authenticate_sync, username, password
58
+ )
59
+
60
+ def _authenticate_sync(self, username: str, password: str) -> bool:
61
+ """Synchronous authentication logic (run in thread pool)."""
62
+ if not username or not password:
63
+ return False
64
+
65
+ try:
66
+ # Step 1: Bind as service account
67
+ with Connection(
68
+ self.server,
69
+ user=self.config.bind_dn,
70
+ password=self.config.bind_password,
71
+ raise_exceptions=True,
72
+ ) as conn:
73
+ # Step 2: Search for user DN
74
+ # Escape special LDAP characters to prevent injection
75
+ safe_username = _escape_ldap(username)
76
+ user_filter = self.config.user_filter.format(username=safe_username)
77
+ conn.search(
78
+ search_base=self.config.base_dn,
79
+ search_filter=user_filter,
80
+ search_scope=SUBTREE,
81
+ )
82
+
83
+ if not conn.entries:
84
+ return False
85
+
86
+ user_dn = conn.entries[0].entry_dn
87
+
88
+ # Step 3: Try to bind as the user with supplied password
89
+ with Connection(
90
+ self.server,
91
+ user=user_dn,
92
+ password=password,
93
+ raise_exceptions=True,
94
+ ) as conn:
95
+ # Connection successful = auth successful
96
+ pass
97
+
98
+ # Step 4: Optional group membership check
99
+ if self.config.group_dn:
100
+ if not self._check_group_membership(user_dn):
101
+ return False
102
+
103
+ return True
104
+
105
+ except LDAPException as e:
106
+ log.debug("LDAP authentication failed: %s", e)
107
+ return False
108
+ except Exception as e:
109
+ log.warning("Unexpected error during LDAP authentication: %s", e)
110
+ return False
111
+
112
+ def _check_group_membership(self, user_dn: str) -> bool:
113
+ """Check if user is member of configured group.
114
+
115
+ Args:
116
+ user_dn: User's distinguished name
117
+
118
+ Returns:
119
+ True if user is in group (or no group configured), False otherwise
120
+ """
121
+ try:
122
+ with Connection(
123
+ self.server,
124
+ user=self.config.bind_dn,
125
+ password=self.config.bind_password,
126
+ raise_exceptions=True,
127
+ ) as conn:
128
+ # Use BASE scope to check a single group entry for membership.
129
+ # Supports both AD (member=) and OpenLDAP (uniqueMember=) via OR filter.
130
+ # Escape the DN for filter safety — DNs can contain (, ), *, \ chars.
131
+ safe_dn = _escape_ldap(user_dn)
132
+ group_filter = (
133
+ f"(|(member={safe_dn})(uniqueMember={safe_dn}))"
134
+ )
135
+ conn.search(
136
+ search_base=self.config.group_dn,
137
+ search_filter=group_filter,
138
+ search_scope=BASE,
139
+ attributes=["cn"],
140
+ )
141
+ return bool(conn.entries)
142
+
143
+ except LDAPException as e:
144
+ log.debug("LDAP group membership check failed: %s", e)
145
+ return False
146
+ except Exception as e:
147
+ log.warning("Unexpected error during group membership check: %s", e)
148
+ return False
ldapgate/middleware.py ADDED
@@ -0,0 +1,113 @@
1
+ """Starlette middleware for FastAPI LDAP authentication."""
2
+
3
+ from typing import Optional
4
+ from urllib.parse import quote
5
+
6
+ from fastapi import FastAPI
7
+ from starlette.datastructures import MutableHeaders
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import RedirectResponse, Response
11
+
12
+ from ldapgate.config import LDAPConfig
13
+ from ldapgate.sessions import SessionManager
14
+
15
+
16
+ class LDAPAuthMiddleware(BaseHTTPMiddleware):
17
+ """Starlette middleware for LDAP authentication in FastAPI apps.
18
+
19
+ Usage:
20
+ config = load_config("ldapgate.yaml")
21
+ app.add_middleware(LDAPAuthMiddleware, config=config)
22
+ """
23
+
24
+ def __init__(self, app: FastAPI, config: LDAPConfig):
25
+ """Initialize middleware.
26
+
27
+ Args:
28
+ app: FastAPI application instance
29
+ config: LDAPConfig with LDAP and session settings
30
+ """
31
+ super().__init__(app)
32
+ self.config = config
33
+ self.session_manager = SessionManager(
34
+ config.proxy.secret_key, config.proxy.session_ttl
35
+ )
36
+
37
+ async def dispatch(self, request: Request, call_next) -> Response:
38
+ """Middleware dispatch handler.
39
+
40
+ Checks session cookie. If valid, adds request.state.user and injects header.
41
+ If invalid, redirects to login form.
42
+
43
+ Args:
44
+ request: Incoming request
45
+ call_next: Next middleware/route handler
46
+
47
+ Returns:
48
+ Response
49
+ """
50
+ # Skip auth for login endpoints and static assets
51
+ if self._should_skip_auth(request.url.path):
52
+ return await call_next(request)
53
+
54
+ # Check session
55
+ session_cookie = request.cookies.get(SessionManager.COOKIE_NAME)
56
+ username = self.session_manager.verify_session(session_cookie)
57
+
58
+ if not username:
59
+ # Redirect to login with original URL as redirect target
60
+ redirect_url = request.url.path
61
+ if request.url.query:
62
+ redirect_url += f"?{request.url.query}"
63
+ return RedirectResponse(
64
+ url=f"{self.config.proxy.login_path}?redirect={quote(redirect_url, safe='')}",
65
+ status_code=302,
66
+ )
67
+
68
+ # Store username in request state for downstream use
69
+ request.state.user = username
70
+
71
+ # Inject user header into request scope (MutableHeaders modifies scope in place)
72
+ MutableHeaders(scope=request.scope)[self.config.proxy.user_header] = username
73
+
74
+ # Call next middleware/route
75
+ response = await call_next(request)
76
+
77
+ return response
78
+
79
+ def _should_skip_auth(self, path: str) -> bool:
80
+ """Check if path should skip authentication.
81
+
82
+ Args:
83
+ path: Request path
84
+
85
+ Returns:
86
+ True if auth should be skipped
87
+ """
88
+ # Skip login/logout endpoints
89
+ if path.startswith(self.config.proxy.login_path):
90
+ return True
91
+ if path.startswith(self.config.proxy.logout_path):
92
+ return True
93
+
94
+ # Skip common static asset paths
95
+ static_prefixes = ["/_static/", "/static/", "/assets/", "/favicon.ico", "/favicon.svg", "/robots.txt"]
96
+ return any(path.startswith(prefix) for prefix in static_prefixes)
97
+
98
+
99
+ def add_ldap_auth(app: FastAPI, config: LDAPConfig, template_path: Optional[str] = None) -> None:
100
+ """Add LDAP auth to a FastAPI app: login routes + session middleware.
101
+
102
+ Registers the login form (GET/POST) on the app and attaches
103
+ LDAPAuthMiddleware so all other routes require a valid session.
104
+
105
+ Args:
106
+ app: FastAPI application
107
+ config: LDAPConfig instance
108
+ template_path: Optional path to a custom Jinja2 login template file.
109
+ If omitted, uses the bundled ldapgate template.
110
+ """
111
+ from ldapgate.proxy import create_login_router
112
+ app.include_router(create_login_router(config, template_path=template_path))
113
+ app.add_middleware(LDAPAuthMiddleware, config=config)