Profile: only edit fields Dex reads back

The Dex LDAP connector publishes uid, cn and mail as claims, so the
profile page now offers just Name (cn), the verified email change and
the password. The photo endpoint, SSH keys, addresses, phones and
other extended attributes are no longer read or written; existing LDAP
values are left untouched. Apache's profile upload limit drops back to
the 256 KiB used by other member routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:42:57 -05:00
co-authored by Claude Opus 5.5
parent 67d8caccce
commit d73f7d1b80
9 changed files with 114 additions and 507 deletions
+10 -133
View File
@@ -1,23 +1,15 @@
"""Validation-only forms for the shared LDAP profile; no directory writes."""
import unicodedata
import warnings
from io import BytesIO
from urllib.parse import urlsplit
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from .forms import PasswordForm
MAX_INPUT_LENGTH = 32768
MAX_VALUES = 16
MAX_PHOTO_BYTES = 1024 * 1024
def _safe_text(value: str) -> str:
@@ -32,9 +24,8 @@ def _safe_text(value: str) -> str:
class ProfileValuesField(forms.CharField):
"""Text input becomes an ordered, deduplicated list of LDAP strings."""
def __init__(self, *, value_length: int = 2048, postal: bool = False, max_values: int = MAX_VALUES, **kwargs):
def __init__(self, *, value_length: int = 2048, max_values: int = MAX_VALUES, **kwargs):
self.value_length = value_length
self.postal = postal
self.max_values = max_values
kwargs.setdefault("required", False)
kwargs.setdefault("help_text", "One value per line. Up to 16 values; leave blank to clear.")
@@ -48,18 +39,7 @@ class ProfileValuesField(forms.CharField):
if value is not None and len(str(value)) > MAX_INPUT_LENGTH:
raise ValidationError("Enter no more than 32,768 characters in this field.")
text = super().clean(_safe_text(str(value)) if value is not None else "")
if self.postal:
entries = []
lines = []
for line in text.split("\n") + [""]:
if line.strip():
# RFC 4517 Postal Address escapes literal '$' and '\'.
lines.append(line.strip().replace("\\", "\\5c").replace("$", "\\24"))
elif lines:
entries.append("$".join(lines))
lines = []
else:
entries = [line.strip() for line in text.split("\n") if line.strip()]
entries = [line.strip() for line in text.split("\n") if line.strip()]
if len(entries) > self.max_values:
raise ValidationError(f"Enter no more than {self.max_values} value(s).")
if any(len(entry) > self.value_length for entry in entries):
@@ -88,128 +68,25 @@ class CurrentPasswordForm(forms.Form):
class ProfileForm(CurrentPasswordForm):
description = ProfileValuesField(label="About you", value_length=4096)
displayName = ProfileValuesField(label="Gamer tag", max_values=1, help_text="One gamer tag, reused by gaming services. Leave blank to clear.")
givenName = ProfileValuesField(label="First name")
l = ProfileValuesField(label="City/town")
labeledURI = ProfileValuesField(label="Websites", help_text="One HTTP or HTTPS URL per line, optionally followed by a space and a label.")
mobile = ProfileValuesField(label="Mobile numbers")
o = ProfileValuesField(label="Organizations")
postalAddress = ProfileValuesField(label="Postal addresses", postal=True, help_text="Use a blank line between addresses. Line breaks within each address are preserved.")
postalCode = ProfileValuesField(label="Postal codes")
roomNumber = ProfileValuesField(label="Room numbers")
sn = ProfileValuesField(label="Last name", required=True, help_text="Enter at least one last name. One value per line.")
sshPublicKey = ProfileValuesField(label="SSH public keys", help_text="One public key per line: Ed25519, RSA (at least 2048 bits), or ECDSA NIST P-256/P-384/P-521. No private keys or authorized_keys options.")
st = ProfileValuesField(label="State/province")
street = ProfileValuesField(label="Street addresses")
telephoneNumber = ProfileValuesField(label="Phone numbers")
title = ProfileValuesField(label="Job titles")
jpegPhoto = forms.FileField(
label="Profile photo", required=False,
widget=forms.FileInput(attrs={"accept": "image/jpeg"}),
help_text="JPEG only, up to 1 MiB and 4096 pixels per side (16 million pixels total). Resized to fit 512 × 512; metadata is removed.",
"""Only Dex-visible values: cn is published to services as the "name" claim."""
cn = ProfileValuesField(
label="Name", required=True, max_values=1, value_length=256,
help_text="How you appear across eom.dev services.",
)
remove_photo = forms.BooleanField(label="Remove current photo", required=False)
field_order = ["cn", "current_password"]
def __init__(self, *args, **kwargs):
# Initial text values are formatted by the caller, including postal $ decoding.
kwargs.setdefault("auto_id", "id_profile_%s")
super().__init__(*args, **kwargs)
self.fields["cn"].widget = forms.TextInput(attrs={"maxlength": 256, "autocomplete": "name"})
def identity_fields(self):
return [self[name] for name in ("givenName", "sn", "displayName", "description", "o", "title")]
def contact_fields(self):
return [self[name] for name in ("labeledURI", "telephoneNumber", "mobile")]
def address_fields(self):
return [self[name] for name in ("postalAddress", "street", "roomNumber", "l", "st", "postalCode")]
return [self["cn"]]
def authorization_fields(self):
return [self["current_password"]]
def clean_labeledURI(self) -> list[str]:
entries = self.cleaned_data["labeledURI"]
validator = URLValidator(schemes=["https", "http"])
for entry in entries:
url = entry.partition(" ")[0]
try:
validator(url)
parsed = urlsplit(url)
if parsed.username is not None or parsed.password is not None or "\\" in url:
raise ValueError("Credentials and backslashes are not allowed.")
if any(char.isspace() for char in url):
raise ValueError("Whitespace is not allowed in URLs.")
except (ValidationError, ValueError):
raise ValidationError("Use an HTTP or HTTPS URL without credentials, optionally followed by a label.") from None
return entries
def clean_sshPublicKey(self) -> list[str]:
supported = {"ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"}
entries = []
seen = set()
for value in self.cleaned_data["sshPublicKey"]:
parts = value.split()
if len(parts) < 2 or parts[0] not in supported:
raise ValidationError("Enter a supported SSH public key without options or private-key material.")
try:
key = serialization.load_ssh_public_key(value.encode("utf-8"))
except (ValueError, TypeError, UnsupportedAlgorithm):
raise ValidationError("Enter a valid supported SSH public key.") from None
valid = (
isinstance(key, ed25519.Ed25519PublicKey)
or isinstance(key, rsa.RSAPublicKey) and key.key_size >= 2048
or isinstance(key, ec.EllipticCurvePublicKey)
and isinstance(key.curve, (ec.SECP256R1, ec.SECP384R1, ec.SECP521R1))
)
if not valid:
raise ValidationError("Use Ed25519, RSA of at least 2048 bits, or ECDSA NIST P-256/P-384/P-521.")
identity = tuple(parts[:2])
if identity not in seen:
seen.add(identity)
entries.append(value)
return entries
def clean_jpegPhoto(self) -> bytes | None:
upload = self.cleaned_data.get("jpegPhoto")
if upload is None:
return None
if upload.size > MAX_PHOTO_BYTES:
raise ValidationError("Choose a JPEG no larger than 1 MiB.")
raw = upload.read(MAX_PHOTO_BYTES + 1)
if len(raw) > MAX_PHOTO_BYTES:
raise ValidationError("Choose a JPEG no larger than 1 MiB.")
# Import only on upload; Pillow is supplied by the application's dependencies.
from PIL import Image, ImageOps, UnidentifiedImageError
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(BytesIO(raw), formats=["JPEG"]) as image:
width, height = image.size
if image.format != "JPEG" or not (0 < width <= 4096 and 0 < height <= 4096) or width * height > 16_000_000:
raise ValidationError("Choose a JPEG up to 4096 pixels per side and 16 million pixels total.")
image.verify()
with Image.open(BytesIO(raw), formats=["JPEG"]) as image:
image.load()
rgb = ImageOps.exif_transpose(image).convert("RGB")
rgb.thumbnail((512, 512), Image.Resampling.LANCZOS)
# A fresh pixel-only image prevents EXIF/ICC/comment metadata retention.
clean = Image.new("RGB", rgb.size)
clean.paste(rgb)
output = BytesIO()
clean.save(output, format="JPEG", quality=85)
return output.getvalue()
except (UnidentifiedImageError, OSError, ValueError, SyntaxError, Image.DecompressionBombWarning, Image.DecompressionBombError):
raise ValidationError("Choose a valid, safely sized JPEG image.") from None
def clean(self):
data = super().clean()
if data.get("jpegPhoto") is not None and data.get("remove_photo"):
self.add_error("remove_photo", "Choose either a new photo or removal, not both.")
return data
class ProfileEmailForm(CurrentPasswordForm):
email = forms.EmailField(label="New email address", max_length=254, widget=forms.EmailInput(attrs={"autocomplete": "email"}))