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:
@@ -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"}))
|
||||
|
||||
Reference in New Issue
Block a user