162 lines
8.7 KiB
Python
Executable File
162 lines
8.7 KiB
Python
Executable File
"""Self-service directory operations: pinned identity, user binds, fixed attributes.
|
|
|
|
Dex authenticates the session; LDAP remains authoritative for editable values.
|
|
No service-account writes, password hashes, tokens or profile snapshots are stored.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from contextlib import contextmanager
|
|
|
|
from django.conf import settings
|
|
from django.core import signing
|
|
from django.db import transaction
|
|
from ldap3 import BASE, MODIFY_ADD, MODIFY_REPLACE
|
|
from ldap3.core.exceptions import LDAPException
|
|
from ldap3.operation.search import compile_filter, parse_filter
|
|
from ldap3.protocol.controls import build_control
|
|
from ldap3.utils.conv import escape_filter_chars
|
|
|
|
from . import directory
|
|
from .models import Account
|
|
|
|
|
|
TEXT_ATTRIBUTES = (
|
|
"description", "displayName", "givenName", "l", "labeledURI", "mobile", "o",
|
|
"postalAddress", "postalCode", "roomNumber", "sn", "sshPublicKey", "st",
|
|
"street", "telephoneNumber", "title",
|
|
)
|
|
READ_ATTRIBUTES = (*TEXT_ATTRIBUTES, "mail", "jpegPhoto", "entryUUID", "uid", "modifyTimestamp", "objectClass")
|
|
|
|
|
|
def _values(entry):
|
|
values = {name: list(entry[name].values) if name in entry else [] for name in READ_ATTRIBUTES}
|
|
# Schema-aware ldap3 may convert this to datetime; assertion filters need
|
|
# the original LDAP GeneralizedTime syntax rather than Python's rendering.
|
|
if "modifyTimestamp" in entry:
|
|
values["modifyTimestamp"] = [value.decode("ascii") for value in entry["modifyTimestamp"].raw_values]
|
|
if len(values["entryUUID"]) != 1 or len(values["uid"]) != 1:
|
|
raise ValueError("A unique directory identity is required.")
|
|
return values
|
|
|
|
|
|
def _assertion(values):
|
|
# Critical RFC 4528 assertion: concurrent edits or a recreated DN fail
|
|
# atomically rather than modifying the wrong entry or overwriting new data.
|
|
if len(values["modifyTimestamp"]) != 1:
|
|
raise ValueError("Read access to modifyTimestamp is required for safe profile edits.")
|
|
query = f"(entryUUID={escape_filter_chars(str(values['entryUUID'][0]))})"
|
|
query = f"(&{query}(modifyTimestamp={escape_filter_chars(str(values['modifyTimestamp'][0]))}))"
|
|
node = parse_filter(query, None, False, False, None, False)
|
|
return build_control("1.3.6.1.1.12", True, compile_filter(node.elements[0]))
|
|
|
|
|
|
def _digest(values):
|
|
def encoded(value):
|
|
return hashlib.sha256(value).hexdigest() if isinstance(value, bytes) else str(value)
|
|
return hashlib.sha256(json.dumps({key: [encoded(v) for v in values[key]] for key in READ_ATTRIBUTES}, sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
def read_profile(account):
|
|
"""Resolve once from the signed uid, then pin UUID; never accept a browser DN."""
|
|
with transaction.atomic():
|
|
member = Account.objects.select_for_update().get(pk=account.pk)
|
|
if not member.oidc_sub or member.oidc_issuer != settings.DEX_ISSUER:
|
|
raise ValueError("A verified Dex identity is required.")
|
|
with directory.connection() as conn:
|
|
query = f"(entryUUID={escape_filter_chars(member.ldap_uuid)})" if member.ldap_uuid else f"(&(objectClass=inetOrgPerson)(uid={escape_filter_chars(member.uid)}))"
|
|
conn.search(settings.LDAP_SEARCH_BASE_DN, query, attributes=list(READ_ATTRIBUTES))
|
|
if len(conn.entries) != 1:
|
|
raise ValueError("Profile is missing or ambiguous.")
|
|
entry = conn.entries[0]
|
|
values = _values(entry)
|
|
entry_uuid = str(values["entryUUID"][0])
|
|
if str(values["uid"][0]) != member.uid or not entry_uuid or entry_uuid == "None" or len(entry_uuid) > 64:
|
|
raise ValueError("Directory identity does not match the account.")
|
|
if Account.objects.filter(ldap_uuid=entry_uuid).exclude(pk=member.pk).exists():
|
|
raise ValueError("Directory identity belongs to another account.")
|
|
if entry.entry_dn.casefold() == settings.LDAP_GROUP_PLACEHOLDER_DN.casefold():
|
|
raise ValueError("System entries cannot be edited.")
|
|
member.ldap_uuid, member.ldap_dn = entry_uuid, entry.entry_dn
|
|
member.save(update_fields=["ldap_uuid", "ldap_dn"])
|
|
account.ldap_uuid, account.ldap_dn = member.ldap_uuid, member.ldap_dn
|
|
return values
|
|
|
|
|
|
def revision(account, values):
|
|
return signing.dumps({"account": str(account.pk), "uuid": account.ldap_uuid, "digest": _digest(values)}, salt="ldap-profile")
|
|
|
|
|
|
def initial(values):
|
|
def postal(value):
|
|
# Decode once: a literal '\24' represented by '\5c24' must not become '$'.
|
|
return re.sub(r"\\(24|5[cC])", lambda match: "$" if match.group(1) == "24" else "\\", str(value).replace("$", "\n"))
|
|
return {name: ("\n\n".join(postal(value) for value in values[name]) if name == "postalAddress" else "\n".join(str(value) for value in values[name])) for name in TEXT_ATTRIBUTES}
|
|
|
|
|
|
@contextmanager
|
|
def _authorized(account, password):
|
|
if not password or not account.ldap_uuid or not account.ldap_dn:
|
|
raise ValueError("A pinned directory entry and current password are required.")
|
|
# Authenticate as this user, not as the service identity. slapd self ACLs
|
|
# authorize each modification; a compromised service cannot reset passwords.
|
|
with directory._connection(account.ldap_dn, password) as conn:
|
|
conn.search(account.ldap_dn, f"(entryUUID={escape_filter_chars(account.ldap_uuid)})", search_scope=BASE, attributes=list(READ_ATTRIBUTES))
|
|
if len(conn.entries) != 1:
|
|
raise ValueError("Directory identity changed; refusing to edit.")
|
|
values = _values(conn.entries[0])
|
|
if str(values["entryUUID"][0]) != account.ldap_uuid or str(values["uid"][0]) != account.uid:
|
|
raise ValueError("Directory identity changed; refusing to edit.")
|
|
yield conn, values
|
|
|
|
|
|
def verify_password(account, password):
|
|
with _authorized(account, password):
|
|
pass
|
|
|
|
|
|
def update_profile(account, cleaned, expected_revision):
|
|
proof = signing.loads(expected_revision, salt="ldap-profile", max_age=3600)
|
|
with _authorized(account, cleaned["current_password"]) as (conn, values):
|
|
if proof != {"account": str(account.pk), "uuid": account.ldap_uuid, "digest": _digest(values)}:
|
|
raise ValueError("Your profile changed. Reload it before saving.")
|
|
changes = {}
|
|
for name in TEXT_ATTRIBUTES:
|
|
new_values = cleaned[name]
|
|
if new_values != values[name]:
|
|
changes[name] = [(MODIFY_REPLACE, new_values)]
|
|
if cleaned["sshPublicKey"] and "ldappublickey" not in {str(value).casefold() for value in values["objectClass"]}:
|
|
# Fixed auxiliary class only, never arbitrary browser-supplied
|
|
# schema/classes. Install the SSH schema before enabling edits.
|
|
changes["objectClass"] = [(MODIFY_ADD, ["ldapPublicKey"])]
|
|
photo = cleaned.get("jpegPhoto")
|
|
if photo is not None:
|
|
changes["jpegPhoto"] = [(MODIFY_REPLACE, [photo])]
|
|
elif cleaned.get("remove_photo") and values["jpegPhoto"]:
|
|
changes["jpegPhoto"] = [(MODIFY_REPLACE, [])]
|
|
if changes and not conn.modify(account.ldap_dn, changes, controls=[_assertion(values)]):
|
|
raise LDAPException("Profile modification rejected.")
|
|
|
|
|
|
def change_password(account, current_password, new_password):
|
|
with _authorized(account, current_password) as (conn, _values):
|
|
# RFC 3062 delegates hashing to slapd (including existing server policies).
|
|
# No client-side MD5-Crypt, plaintext attribute write or admin reset.
|
|
# No userIdentity targets: operate on the authenticated user. RFC 4528
|
|
# assertions are not defined for this extended operation; supplying a
|
|
# critical assertion here can make slapd reject an otherwise valid change.
|
|
if not conn.extend.standard.modify_password(old_password=current_password, new_password=new_password):
|
|
raise LDAPException("Password modification rejected.")
|
|
|
|
|
|
def change_email(account, current_password, email):
|
|
with _authorized(account, current_password) as (conn, values):
|
|
# Enforce directory-side mail uniqueness too: only an LDAP uniqueness
|
|
# overlay can prevent another registrar racing this preflight search.
|
|
conn.search(settings.LDAP_SEARCH_BASE_DN, f"(mail={escape_filter_chars(email)})", attributes=["entryUUID"])
|
|
if any(str(entry.entryUUID.value) != account.ldap_uuid for entry in conn.entries):
|
|
raise ValueError("Email change cannot be completed.")
|
|
if not conn.modify(account.ldap_dn, {"mail": [(MODIFY_REPLACE, [email])]}, controls=[_assertion(values)]):
|
|
raise LDAPException("Email modification rejected.")
|