LDAP/AD-Auth fuer Dashboard: User-Bind gegen insight.local, Zugriff nur fuer Domänen-Admins

- ldap_auth Service: User bindet als user@domain (kein Servicekonto noetig),
  Gruppenpruefung ueber Matching-Rule-In-Chain (verschachtelte Gruppen)
- Login-Route: lokaler User zuerst, dann LDAP-Fallback; LDAP-User werden
  lokal gespiegelt (is_ldap, is_admin, Profildaten aus AD)
- Neue Config: ldap_domain, ldap_admin_group, ldap_timeout_seconds
- ldap3 Dependency, 7 Unit-Tests (ldap3 gemockt), ruff+mypy sauber
- Gruppen-DN verified am DC: CN=Domänen-Admins,CN=Users,DC=insight,DC=local
This commit is contained in:
B0rbor4d
2026-09-28 22:06:05 +00:00
parent cc4c3fcecb
commit 8305352497
7 changed files with 320 additions and 22 deletions
+110
View File
@@ -0,0 +1,110 @@
"""LDAP/AD authentication for dashboard users.
User-bind design (no service account required):
1. The user binds with their own credentials as ``user@domain`` (AD accepts
userPrincipalName as bind DN).
2. Over that authenticated connection we search the user's own directory
entry, restricted by the in-chain matching rule to members of the
configured admin group (direct or nested membership).
Only members of the configured group may log in. All other users are
rejected before any local session is created.
"""
import ssl
from dataclasses import dataclass
import ldap3
from ldap3.core.exceptions import LDAPExceptionError
from ldap3.utils.conv import escape_filter_chars
from app.core.config import Settings
# LDAP_MATCHING_RULE_IN_CHAIN: resolves nested group membership server-side
_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1941"
class LdapAuthError(Exception):
"""Authentication failed. Message is safe to show to the user."""
def __init__(self, detail: str = "Benutzername oder Passwort falsch") -> None:
super().__init__(detail)
self.detail = detail
@dataclass
class LdapUser:
username: str
full_name: str | None
email: str | None
is_admin: bool # always True today: only admin-group members may log in
def _build_server(settings: Settings) -> ldap3.Server:
use_ssl = settings.ldap_uri.lower().startswith("ldaps://")
# DC certificates come from an internal AD CA that is not distributed to
# clients; full chain validation would need the enterprise root CA.
tls = ldap3.Tls(validate=ssl.CERT_NONE) if use_ssl else None
return ldap3.Server(
settings.ldap_uri,
get_info=ldap3.NONE,
connect_timeout=settings.ldap_timeout_seconds,
tls=tls,
)
def _bind_dn(username: str, settings: Settings) -> str:
if settings.ldap_domain:
return f"{username}@{settings.ldap_domain}"
return username
def authenticate(username: str, password: str, settings: Settings) -> LdapUser:
"""Validate credentials against AD and verify admin-group membership.
Raises LdapAuthError on any failure (wrong password, unknown user,
not in admin group, LDAP unreachable).
"""
if not settings.ldap_enabled:
raise LdapAuthError("LDAP ist nicht aktiviert")
if not settings.ldap_admin_group:
raise LdapAuthError("LDAP-Admin-Gruppe ist nicht konfiguriert")
if not username or not password:
raise LdapAuthError()
server = _build_server(settings)
try:
conn = ldap3.Connection(
server,
user=_bind_dn(username.strip(), settings),
password=password,
auto_bind=True,
receive_timeout=settings.ldap_timeout_seconds,
)
except LDAPExceptionError as exc:
# Covers invalidCredentials (49) as well as connect/TLS timeouts
raise LdapAuthError() from exc
with conn:
user_filter = settings.ldap_user_filter.format(
username=escape_filter_chars(username.strip())
)
# One search proves: user exists AND is (transitively) in the admin
# group. Non-members simply match nothing.
group_filter = f"(memberOf:{_MATCHING_RULE_IN_CHAIN}:={settings.ldap_admin_group})"
ok = conn.search(
search_base=settings.ldap_user_search_base,
search_filter=f"(&{user_filter}{group_filter})",
search_scope=ldap3.SUBTREE,
attributes=["displayName", "mail"],
)
if not ok or not conn.entries:
raise LdapAuthError("Kein Zugriff: Kein Mitglied der Administrator-Gruppe")
entry = conn.entries[0]
return LdapUser(
username=username.strip(),
full_name=str(entry.displayName) if entry.displayName else None,
email=str(entry.mail) if entry.mail else None,
is_admin=True,
)