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 +19 -0
- ldapgate/cli.py +121 -0
- ldapgate/config.py +114 -0
- ldapgate/ldap.py +148 -0
- ldapgate/middleware.py +113 -0
- ldapgate/proxy.py +396 -0
- ldapgate/sessions.py +52 -0
- ldapgate/templates/login.html +232 -0
- ldapgate-0.1.0.dist-info/METADATA +140 -0
- ldapgate-0.1.0.dist-info/RECORD +12 -0
- ldapgate-0.1.0.dist-info/WHEEL +4 -0
- ldapgate-0.1.0.dist-info/entry_points.txt +2 -0
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)
|