Files
2026-10-29 14:17:16 -04:00

139 lines
7.8 KiB
Python
Executable File

"""TLS by default; paid groups are never granted during registration or login."""
import re
import ssl
from contextlib import contextmanager
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, 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():
with _connection(settings.LDAP_BIND_DN, settings.LDAP_BIND_PASSWORD, service=True) as conn:
yield conn
@contextmanager
def _connection(bind_dn, password, *, service=False):
url = urlsplit(settings.LDAP_URL)
if url.scheme not in {"ldap", "ldaps"} or not url.hostname or not bind_dn or not password or not settings.LDAP_ACCOUNT_BASE_DN:
raise ImproperlyConfigured("Configure LDAP URL, service bind, and account base DN.")
server = Server(url.hostname, port=url.port or (636 if url.scheme == "ldaps" else 389), use_ssl=url.scheme == "ldaps", tls=Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=settings.LDAP_CA_CERT_FILE, version=ssl.PROTOCOL_TLS_CLIENT), connect_timeout=5)
conn = Connection(server, user=bind_dn, password=password, raise_exceptions=True, receive_timeout=8, auto_referrals=False)
try:
conn.open()
# 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.")
try:
bound = conn.bind()
except LDAPInvalidCredentialsResult:
if not service:
raise ValueError("Current password could not be verified.") from None
# 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:
conn.unbind()
def _find(conn, query):
conn.search(settings.LDAP_SEARCH_BASE_DN, query, attributes=["uid", "mail", "entryUUID"])
return list(conn.entries)
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 create_account(uid, email, password):
if settings.LDAP_PASSWORD_SCHEME != "{ARGON2}":
raise ImproperlyConfigured("Verify slapd's Argon2 password policy before enabling registration.")
with connection() as conn:
if _find(conn, f"(|(uid={escape_filter_chars(uid)})(mail={escape_filter_chars(email)}))"):
# Never reset/reuse an existing LDAP password based on mailbox access.
raise ValueError("Account already exists. Sign in through Dex or contact the administrator.")
dn = f"uid={escape_rdn(uid)},{settings.LDAP_ACCOUNT_BASE_DN}"
created = False
try:
if not conn.add(dn, attributes={"objectClass": ["top", "inetOrgPerson"], "uid": uid, "cn": uid, "sn": uid, "mail": email}):
raise LDAPException("LDAP account creation failed.")
created = True
if not conn.extend.standard.modify_password(dn, new_password=password):
raise LDAPException("LDAP password modify failed.")
conn.search(dn, "(objectClass=inetOrgPerson)", search_scope=BASE, attributes=["uid", "mail", "entryUUID"])
if len(conn.entries) != 1:
raise LDAPException("Cannot read created identity.")
return _identity(conn.entries[0])
except Exception:
if created:
conn.delete(dn)
raise
def reconcile_groups(account, desired, managed):
"""Idempotent, atomic per-group changes. A real sentinel keeps groups nonempty."""
placeholder = settings.LDAP_GROUP_PLACEHOLDER_DN
if not placeholder:
raise ImproperlyConfigured("LDAP_GROUP_PLACEHOLDER_DN is required to revoke the final member.")
with connection() as conn:
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.")
if not conn.search(placeholder, "(objectClass=*)", search_scope=BASE, attributes=["objectClass"]):
raise ImproperlyConfigured("LDAP sentinel does not exist.")
# A DN shared by multiple service levels is one target, even if its
# spelling changes case between mappings or historical records.
desired_keys = {group.casefold() for group in desired}
managed_targets = {group.casefold(): group for group in managed}
# Revoke obsolete grants before granting new ones on plan changes.
for group_dn in sorted(managed_targets.values(), key=lambda group: (group.casefold() in desired_keys, group)):
if not conn.search(group_dn, "(objectClass=groupOfUniqueNames)", search_scope=BASE, attributes=["uniqueMember"]):
raise ImproperlyConfigured("Managed group is missing or not groupOfUniqueNames.")
members = {str(value).casefold() for value in conn.entries[0].uniqueMember.values}
changes = []
if placeholder.casefold() not in members:
changes.append((MODIFY_ADD, [placeholder]))
# Remove a stale DN too, e.g. after a directory rename.
candidates = {value.casefold(): value for value in (account.ldap_dn, dn)}
for candidate in candidates.values():
if candidate.casefold() in members and (group_dn.casefold() not in desired_keys or candidate.casefold() != dn.casefold()):
changes.append((MODIFY_DELETE, [candidate]))
if group_dn.casefold() in desired_keys and dn.casefold() not in members:
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