95291df971
Matching-Rule-In-Chain liefert gegen die insight.local-DCs grundsaetzlich keine Treffer (auch nicht bei ASCII-Gruppen und direkter Mitgliedschaft) - live im Container verifiziert. Direkte memberOf-Pruefung funktioniert, verschachtelte Gruppen werden bewusst nicht aufgeloest.
116 lines
4.1 KiB
Python
116 lines
4.1 KiB
Python
"""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 and verify direct membership in the configured admin group via
|
|
the ``memberOf`` attribute.
|
|
|
|
Only members of the configured group may log in. All other users are
|
|
rejected before any local session is created.
|
|
|
|
Note: nested group membership is intentionally not resolved. The
|
|
Matching-Rule-In-Chain (1.2.840.113556.1941) was tested against the
|
|
insight.local DCs and returned no matches at all, even for direct
|
|
members with ASCII-only group DNs - so we check ``memberOf`` directly.
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
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 a direct member of the
|
|
# configured admin group. Non-members simply match nothing.
|
|
# (memberOf is the backlink of the group's member attribute; the
|
|
# matching-rule-in-chain was rejected because it does not work
|
|
# against these DCs, see module docstring.)
|
|
group_filter = f"(memberOf={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,
|
|
)
|