Profile
This commit is contained in:
Executable
+161
@@ -0,0 +1,161 @@
|
||||
"""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.")
|
||||
Reference in New Issue
Block a user