Files
www/membership/members/directory.py
T
2026-10-29 11:51:44 -04:00

127 lines
7.2 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():
url = urlsplit(settings.LDAP_URL)
if url.scheme not in {"ldap", "ldaps"} or not url.hostname or not settings.LDAP_BIND_DN or not settings.LDAP_BIND_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=settings.LDAP_BIND_DN, password=settings.LDAP_BIND_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:
# 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.")
# Revoke obsolete tiers before granting new tiers on plan changes.
for group_dn in sorted(managed, key=lambda group: (group in desired, 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 not in desired or candidate.casefold() != dn.casefold()):
changes.append((MODIFY_DELETE, [candidate]))
if group_dn in desired 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