This commit is contained in:
2026-10-29 11:51:44 -04:00
parent 27a6305b22
commit e4be5bfdf4
10 changed files with 432 additions and 71 deletions
+29 -13
View File
@@ -8,10 +8,16 @@ from urllib.parse import urlsplit
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, Connection, Server, Tls
from ldap3.core.exceptions import LDAPException
from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn
from .models import Account
class LDAPServiceBindError(LDAPException):
"""The application's LDAP service credentials were rejected, not a user's."""
@contextmanager
def connection():
@@ -25,7 +31,12 @@ def connection():
# Plaintext requires explicit operator consent; LDAPS is never downgraded.
if url.scheme == "ldap" and not settings.LDAP_ALLOW_INSECURE and not conn.start_tls():
raise LDAPException("StartTLS failed; refusing to bind.")
if not conn.bind():
try:
bound = conn.bind()
except LDAPInvalidCredentialsResult:
# Do not expose server diagnostics or retry with a user's password.
raise LDAPServiceBindError("Check LDAP_BIND_DN and LDAP_BIND_PASSWORD: use the service account's plaintext password, not its stored hash.") from None
if not bound:
raise LDAPException("LDAP bind failed.")
yield conn
finally:
@@ -41,16 +52,6 @@ def _identity(entry):
return {"uid": str(entry.uid.value), "email": str(entry.mail.value), "ldap_dn": entry.entry_dn, "ldap_uuid": str(entry.entryUUID.value)}
def identity_for_login(uid):
if not isinstance(uid, str) or not re.fullmatch(r"[a-z][a-z0-9._-]{2,47}", uid):
raise ValueError("Dex must return a valid LDAP uid as preferred_username.")
with connection() as conn:
entries = _find(conn, f"(&(objectClass=inetOrgPerson)(uid={escape_filter_chars(uid)}))")
if len(entries) != 1:
raise ValueError("LDAP identity is missing or ambiguous.")
return _identity(entries[0])
def create_account(uid, email, password):
if settings.LDAP_PASSWORD_SCHEME != "{ARGON2}":
raise ImproperlyConfigured("Verify slapd's Argon2 password policy before enabling registration.")
@@ -82,9 +83,22 @@ def reconcile_groups(account, desired, managed):
if not placeholder:
raise ImproperlyConfigured("LDAP_GROUP_PLACEHOLDER_DN is required to revoke the final member.")
with connection() as conn:
entries = _find(conn, f"(entryUUID={escape_filter_chars(account.ldap_uuid)})")
if account.ldap_uuid:
query = f"(entryUUID={escape_filter_chars(account.ldap_uuid)})"
else:
# Only the worker resolves first-time Dex users. Never fall back to
# username once an entryUUID has been pinned (including deletions).
if account.oidc_issuer != settings.DEX_ISSUER or not account.oidc_sub or not re.fullmatch(r"[a-z][a-z0-9._-]{2,47}", account.uid):
raise ValueError("A verified Dex mapping is required to resolve the directory identity.")
query = f"(&(objectClass=inetOrgPerson)(uid={escape_filter_chars(account.uid)}))"
entries = _find(conn, query)
if len(entries) != 1:
raise ValueError("Membership identity missing or ambiguous; refusing to modify groups.")
identity = _identity(entries[0])
if not identity["ldap_uuid"] or identity["ldap_uuid"] == "None" or len(identity["ldap_uuid"]) > 64:
raise ValueError("A directory entryUUID is required to synchronize groups.")
if not account.ldap_uuid and Account.objects.filter(ldap_uuid=identity["ldap_uuid"]).exclude(pk=account.pk).exists():
raise ValueError("Directory entry is already pinned to another local account; refusing to modify groups.")
dn = entries[0].entry_dn
if placeholder.casefold() == dn.casefold():
raise ImproperlyConfigured("The LDAP sentinel must not be a login account.")
@@ -107,4 +121,6 @@ def reconcile_groups(account, desired, managed):
changes.append((MODIFY_ADD, [dn]))
if changes and not conn.modify(group_dn, {"uniqueMember": changes}):
raise LDAPException("LDAP group synchronization failed.")
# Caller holds the account row lock and persists this pin with sync state.
account.ldap_uuid = identity["ldap_uuid"]
return dn