diff --git a/README.md b/README.md index 05a2a39..41bb1e7 100644 --- a/README.md +++ b/README.md @@ -378,59 +378,38 @@ separate coordinated change, not an automatic part of this release. ### Self-service account profile Signed-in members can choose **Edit profile** from their membership page. -Deploy the new membership image, apply migration `0005_profile_email_change`, -and deploy the updated static CSS/proxy configuration. No new public settings -or credentials are required. Login remains Dex-only: the profile editor reads -LDAP **only when opened**, because the configured Dex LDAP connector exposes -username/email/name/groups, not arbitrary LDAP attributes. Cached OIDC claims -are neither a complete nor a current editable profile. Do not map the gamer tag -to `preferred_username` or change `idAttr` to expose it. +Login is Dex-only, and the profile editor offers **only the values the Dex LDAP +connector reads back into ID-token claims**, so what a member edits is exactly +what downstream services receive. The editor reads LDAP **only when opened**; +cached OIDC claims are not treated as a current profile. -| Profile label | LDAP attribute | -| --- | --- | -| About you | `description` | -| Gamer tag (one value) | `displayName` | -| Email address (verified separately) | `mail` | -| First name / Last name | `givenName` / `sn` | -| Profile photo | `jpegPhoto` | -| City/town / State/province | `l` / `st` | -| Websites | `labeledURI` | -| Mobile numbers / Phone numbers | `mobile` / `telephoneNumber` | -| Organizations / Job titles | `o` / `title` | -| Postal addresses / Postal codes | `postalAddress` / `postalCode` | -| Street addresses / Room numbers | `street` / `roomNumber` | -| SSH public keys | `sshPublicKey` | -| Password | RFC 3062 Password Modify (never read back) | +| Profile label | LDAP attribute | Dex claim | +| --- | --- | --- | +| Username (read-only) | `uid` | `preferred_username` | +| Name (one value) | `cn` (connector `nameAttr`) | `name` | +| Email address (verified separately) | `mail` | `email` | +| Password | RFC 3062 Password Modify (never read back) | — | -The form preserves existing multi-value attributes. Enter one value per line; -separate multiline postal addresses with a blank line. Blank optional fields -are removed on save; `sn` remains required by the directory schema. JPEGs are -limited to 1 MiB and 4096 pixels per side/16 million pixels, resized to fit 512 -pixels and reencoded without metadata. Existing photos are sanitized again -before private, authenticated delivery. No arbitrary URLs are fetched. Supported -SSH keys are Ed25519, RSA >=2048 bits and ECDSA NIST P-256/P-384/P-521; private -keys, authorized_keys options and malformed values are rejected. +Other directory attributes (photos, SSH keys, addresses, `displayName`, etc.) +are neither shown nor modified, so existing values are left in place. If the +connector's `nameAttr` is changed, change `profiles.TEXT_ATTRIBUTES` to match. +Saving the name replaces every `cn` value with the single entered value; Dex +already uses only the first. Account DNs must not use `cn` as their RDN. **Directory prerequisites:** -- The service account needs read/search access to the profile attributes above, - plus `uid`, `entryUUID`, `objectClass` and `modifyTimestamp`, across the +- The service account needs read/search access to `cn`, `mail`, `uid`, + `entryUUID` and `modifyTimestamp`, across the intended account search tree. It never needs read access to `userPassword`. Accounts are pinned to immutable UUIDs; missing/deleted entries do not fall back to a reused username. LDAP unavailability disables editing rather than presenting an empty form that could erase values. - Every write binds with the member's **current LDAP password** and checks that - entry's UUID again. Configure directory **self-write ACLs** only for these - profile attributes and self Password Modify; do not grant writes to `uid`, + entry's UUID again. Configure directory **self-write ACLs** only for `cn`, + `mail` and self Password Modify; do not grant writes to `uid`, `entryUUID`, account DNs, POSIX IDs, group membership or administrative fields. This supports existing Dex users outside the website registration OU without granting the membership service unrestricted directory write permissions. -- Install the `ldapPublicKey` auxiliary schema before SSH editing. When a user - supplies keys and the class is absent, the application adds **only** the fixed - `ldapPublicKey` class in the same modify. Either pre-add that class to editable - accounts or permit precisely that self-add with value-scoped `objectClass` - ACLs. Never grant arbitrary self-write to `objectClass`. No schema/ACL changes - are automatically applied by the website or OpenLDAP role. - OpenLDAP must support critical RFC 4528 assertion controls. Profile/email writes assert UUID and last-modified time, and profile forms carry a signed, one-hour revision to reject stale edits. A server rejecting the critical @@ -462,15 +441,13 @@ automatically revoked—use each service's session controls when necessary. All writes are CSRF-protected and rate-limited per account/IP. Passwords and profile form bodies must never be logged by ingress/APM/LDAP debug logging. Profile data can be shared by directory-consuming services: the page is private, -but it does not make phone/address/SSH fields private in the directory. Enforce +but it does not make other directory attributes private. Enforce directory read ACLs and tell users what other services can access. TLS defaults are unchanged; the existing explicit plaintext opt-in also exposes profile passwords/data and should not be used on untrusted networks. -The supplied Kubernetes ingress accepts up to 2 MiB; Apache allows that limit -only under `/members/profile/` and retains 256 KiB for other member routes. -Mirror the limits at any additional edge proxy. Oversized or invalid uploads -must be rejected before reaching image processing. +Apache limits all member routes, including the profile, to 256 KiB request +bodies; mirror that limit at any additional edge proxy. ### Database ProgrammingError during callback diff --git a/conf/httpd.conf b/conf/httpd.conf index 04801bb..92a214c 100644 --- a/conf/httpd.conf +++ b/conf/httpd.conf @@ -251,10 +251,6 @@ ProxyPassReverse "/members/" "http://membership:8000/members/" RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}" LimitRequestBody 262144 - - # 1 MiB JPEG plus form/multipart overhead; other member routes stay smaller. - LimitRequestBody 2097152 - # # Deny access to the entirety of your server's filesystem. You must diff --git a/htdocs/static/membership.css b/htdocs/static/membership.css index 4414d5a..f5008c5 100755 --- a/htdocs/static/membership.css +++ b/htdocs/static/membership.css @@ -74,11 +74,6 @@ button.button { cursor: pointer; font-size: inherit; line-height: inherit; } .profile-field-invalid input, .profile-field-invalid textarea, .profile-field [aria-invalid="true"] { border-color: var(--red); } .profile-errors { margin-block: 1rem 1.5rem; padding: 1.25rem; border: 1px solid var(--red); border-radius: .8rem; background: var(--surface); } .profile-field .errorlist { margin-block: .5rem 0; padding-left: 1.25rem; } -.profile-checkbox input { width: 1.2rem; height: 1.2rem; margin-right: .4rem; accent-color: var(--accent); vertical-align: middle; } -.profile-checkbox label { font-weight: 500; } -.profile-photo { margin: 0 0 1.5rem; } -.profile-photo img { display: block; object-fit: contain; border: 1px solid var(--border); border-radius: 1rem; background: var(--page-deep); } -.profile-photo figcaption { margin-top: .5rem; color: var(--muted); font-size: .85rem; } .profile-email-values { padding-left: 1.4rem; overflow-wrap: anywhere; } .profile-back { margin-top: 2rem; } @media (max-width: 540px) { .profile-section { padding: 1.1rem; } .profile-section .button { width: 100%; } .profile-jump-links { gap: .45rem; } .profile-jump-links a { font-size: .9rem; } } diff --git a/membership/members/profile_forms.py b/membership/members/profile_forms.py index 391c0bb..9d42a95 100755 --- a/membership/members/profile_forms.py +++ b/membership/members/profile_forms.py @@ -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"})) diff --git a/membership/members/profiles.py b/membership/members/profiles.py index bd243ec..27ca425 100755 --- a/membership/members/profiles.py +++ b/membership/members/profiles.py @@ -6,13 +6,12 @@ No service-account writes, password hashes, tokens or profile snapshots are stor 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 import BASE, MODIFY_REPLACE from ldap3.core.exceptions import LDAPException from ldap3.operation.search import compile_filter, parse_filter from ldap3.protocol.controls import build_control @@ -22,12 +21,12 @@ 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") +# Only attributes the Dex LDAP connector reads back into ID-token claims are +# editable: cn (nameAttr -> "name"). mail ("email") is changed through the +# separate verified flow and uid ("preferred_username") is immutable. Other +# directory attributes are left untouched, neither shown nor cleared. +TEXT_ATTRIBUTES = ("cn",) +READ_ATTRIBUTES = (*TEXT_ATTRIBUTES, "mail", "entryUUID", "uid", "modifyTimestamp") def _values(entry): @@ -89,10 +88,8 @@ def revision(account, values): 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} + # Dex publishes the first cn value; saving collapses any extras to one. + return {name: str(values[name][0]) if values[name] else "" for name in TEXT_ATTRIBUTES} @contextmanager @@ -126,15 +123,6 @@ def update_profile(account, cleaned, expected_revision): 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.") diff --git a/membership/members/tests/test_profiles.py b/membership/members/tests/test_profiles.py index 7784efe..f71170c 100755 --- a/membership/members/tests/test_profiles.py +++ b/membership/members/tests/test_profiles.py @@ -1,6 +1,6 @@ """Offline self-service profile tests; never bind LDAP or deliver real email. -Run only this module with config.test_settings. Credentials and SSH keys are +Run only this module with config.test_settings. Credentials are ephemeral test data, not service credentials. Directory writes use mocks; LDAP reads additionally exercise ldap3's in-memory strategy. """ @@ -9,28 +9,22 @@ import copy import hashlib import secrets from datetime import timedelta -from io import BytesIO from unittest.mock import MagicMock, patch from urllib.parse import urlsplit -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa from django.conf import settings from django.core import mail, signing from django.core.exceptions import ValidationError -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client, SimpleTestCase, TestCase, override_settings -from django.urls import Resolver404, resolve, reverse +from django.urls import NoReverseMatch, Resolver404, resolve, reverse from django.utils import timezone from ldap3 import BASE, MOCK_SYNC, MODIFY_REPLACE, Connection, Server from ldap3.core.exceptions import LDAPException -from PIL import Image from members import profiles from members.models import Account, ProfileEmailChange from members.profile_forms import ( MAX_INPUT_LENGTH, - MAX_PHOTO_BYTES, CurrentPasswordForm, ProfileEmailForm, ProfileForm, @@ -47,73 +41,49 @@ def profile_values(**overrides): """The complete READ_ATTRIBUTES shape returned by the real reader.""" values = {name: [] for name in profiles.READ_ATTRIBUTES} values.update({ - "description": ["Community member"], - "displayName": ["AliceInGame"], - "givenName": ["Alice"], - "sn": ["Example"], + "cn": ["Alice Example"], "mail": ["alice@example.org", "alice.other@example.org"], - "postalAddress": ["1 Example Street$Example City", "2 Other Road$Other City"], - "labeledURI": ["https://example.org/ Personal website"], "entryUUID": ["uuid-alice"], "uid": ["alice"], "modifyTimestamp": ["20261029120000Z"], - "objectClass": ["top", "person", "organizationalPerson", "inetOrgPerson"], }) values.update(overrides) return values -def jpeg_bytes(size=(800, 400), *, metadata=True): - output = BytesIO() - image = Image.new("RGB", size, (50, 80, 120)) - options = {} - if metadata: - exif = Image.Exif() - exif[0x010E] = "private image description" - options = {"exif": exif, "icc_profile": b"private-test-icc"} - image.save(output, format="JPEG", **options) - return output.getvalue() - - -def photo_upload(raw=None): - return SimpleUploadedFile( - "photo.jpg", jpeg_bytes() if raw is None else raw, - content_type="image/jpeg", - ) - - class ProfileFormTests(SimpleTestCase): def setUp(self): self.password = " " + secrets.token_urlsafe(24) + " " - def form(self, data=None, files=None): - return ProfileForm( - data={"sn": "Example", "current_password": self.password, **(data or {})}, - files=files, - ) + def form(self, data=None): + return ProfileForm(data={"cn": "Alice Example", "current_password": self.password, **(data or {})}) - def test_friendly_labels_and_single_gamer_tag(self): - form = self.form({"displayName": "AliceInGame"}) + def test_single_name_value_published_by_dex(self): + form = self.form() self.assertTrue(form.is_valid(), form.errors) - self.assertEqual(form.fields["displayName"].label, "Gamer tag") - self.assertEqual(form.fields["sn"].label, "Last name") - self.assertEqual(form.fields["l"].label, "City/town") - self.assertEqual(form.cleaned_data["displayName"], ["AliceInGame"]) - invalid = self.form({"displayName": "FirstTag\nSecondTag"}) - self.assertFalse(invalid.is_valid()) - self.assertIn("displayName", invalid.errors) + self.assertEqual(form.fields["cn"].label, "Name") + self.assertEqual(form.cleaned_data["cn"], ["Alice Example"]) + for name in ("First\nSecond", "x" * 257): + with self.subTest(length=len(name)): + invalid = self.form({"cn": name}) + self.assertFalse(invalid.is_valid()) + self.assertIn("cn", invalid.errors) - def test_all_editable_text_attributes_have_fields_but_identity_is_read_only(self): - self.assertTrue(set(profiles.TEXT_ATTRIBUTES) <= set(ProfileForm.base_fields)) - for name in ("uid", "dn", "ldap_dn", "entryUUID", "mail", "userPassword", "objectClass"): - self.assertNotIn(name, ProfileForm.base_fields) + def test_only_dex_claim_attributes_are_editable(self): + # Dex's LDAP connector publishes uid, cn and mail; mail has its own + # verified flow and uid is immutable, so only cn is on this form. + self.assertEqual(profiles.TEXT_ATTRIBUTES, ("cn",)) + self.assertEqual(set(ProfileForm.base_fields), {"cn", "current_password"}) + for name in ("displayName", "givenName", "sn", "jpegPhoto", "sshPublicKey", "postalAddress", "telephoneNumber", "description"): + self.assertNotIn(name, profiles.READ_ATTRIBUTES) + + def test_initial_uses_first_name_value_like_dex(self): + self.assertEqual(profiles.initial(profile_values(cn=["Alice", "Alice Example"])), {"cn": "Alice"}) + self.assertEqual(profiles.initial(profile_values(cn=[])), {"cn": ""}) def test_field_groups_and_unique_password_ids(self): form = self.form() - grouped = [ - *form.identity_fields(), *form.contact_fields(), - *form.address_fields(), *form.authorization_fields(), - ] + grouped = [*form.identity_fields(), *form.authorization_fields()] self.assertEqual(len({field.name for field in grouped}), len(grouped)) ids = [cls()["current_password"].auto_id for cls in ( ProfileForm, ProfileEmailForm, ProfilePasswordForm, CurrentPasswordForm, @@ -134,114 +104,12 @@ class ProfileFormTests(SimpleTestCase): field.clean(value) self.assertEqual(len(ProfileValuesField().clean("\n".join(str(i) for i in range(16)))), 16) - def test_surname_required_even_when_only_unsafe_or_blank_text(self): - for surname in ("", " \n ", "\x00\u200b"): - with self.subTest(surname=repr(surname)): - form = self.form({"sn": surname}) + def test_name_required_even_when_only_unsafe_or_blank_text(self): + for name in ("", " \n ", "\x00\u200b"): + with self.subTest(name=repr(name)): + form = self.form({"cn": name}) self.assertFalse(form.is_valid()) - self.assertIn("sn", form.errors) - - def test_postal_addresses_round_trip_multiple_multiline_values(self): - values = profile_values() - initial = profiles.initial(values) - self.assertEqual(initial["postalAddress"], "1 Example Street\nExample City\n\n2 Other Road\nOther City") - form = self.form(initial) - self.assertTrue(form.is_valid(), form.errors) - self.assertEqual(form.cleaned_data["postalAddress"], values["postalAddress"]) - self.assertEqual(ProfileValuesField(postal=True).clean(" A \r\n B \r\n\r\n A\nB "), ["A$B"]) - - def test_postal_literal_dollar_and_backslash_round_trip(self): - stored = [r"Unit \24 Five$Backslash \5c24", r"Another\5cAddress"] - text = profiles.initial(profile_values(postalAddress=stored))["postalAddress"] - self.assertEqual(ProfileValuesField(postal=True).clean(text), stored) - - def test_http_https_urls_with_optional_labels(self): - form = self.form({"labeledURI": "https://example.org/a?q=b Website\nhttp://example.net/"}) - self.assertTrue(form.is_valid(), form.errors) - self.assertEqual(form.cleaned_data["labeledURI"], ["https://example.org/a?q=b Website", "http://example.net/"]) - - def test_urls_reject_other_schemes_credentials_and_backslashes(self): - for url in ( - "javascript:alert(1)", "data:text/html,test", "file:///etc/passwd", - "ftp://example.org/file", "//example.org/", "https://user@example.org/", - "https://user:secret@example.org/", "https://example.org/\\path", "not-a-url", - ): - with self.subTest(url=url): - form = self.form({"labeledURI": url}) - self.assertFalse(form.is_valid()) - self.assertIn("labeledURI", form.errors) - - def test_supported_ssh_keys_multivalued_and_deduplicated_by_key_not_comment(self): - keys = [ - ed25519.Ed25519PrivateKey.generate().public_key(), - rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key(), - ec.generate_private_key(ec.SECP256R1()).public_key(), - ec.generate_private_key(ec.SECP384R1()).public_key(), - ec.generate_private_key(ec.SECP521R1()).public_key(), - ] - lines = [key.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH).decode() for key in keys] - form = self.form({"sshPublicKey": "\n".join([lines[0] + " laptop", lines[0] + " other comment", *lines[1:]])}) - self.assertTrue(form.is_valid(), form.errors) - self.assertEqual(form.cleaned_data["sshPublicKey"], [lines[0] + " laptop", *lines[1:]]) - - def test_invalid_ssh_options_or_private_material_rejected(self): - public = ed25519.Ed25519PrivateKey.generate().public_key().public_bytes( - serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH, - ).decode() - for value in ("ssh-ed25519 invalid", 'command="id" ' + public, "ssh-dss invalid", "-----BEGIN OPENSSH PRIVATE KEY-----"): - with self.subTest(value=value): - form = self.form({"sshPublicKey": value}) - self.assertFalse(form.is_valid()) - self.assertIn("sshPublicKey", form.errors) - - def test_jpeg_is_resized_reencoded_and_metadata_removed(self): - raw = jpeg_bytes() - form = self.form(files={"jpegPhoto": photo_upload(raw)}) - self.assertTrue(form.is_valid(), form.errors) - clean = form.cleaned_data["jpegPhoto"] - self.assertIsInstance(clean, bytes) - self.assertNotEqual(clean, raw) - with Image.open(BytesIO(clean)) as image: - self.assertEqual(image.format, "JPEG") - self.assertEqual(image.mode, "RGB") - self.assertEqual(image.size, (512, 256)) - self.assertFalse(image.getexif()) - self.assertNotIn("icc_profile", image.info) - self.assertNotIn("comment", image.info) - - def test_fake_truncated_empty_and_non_jpeg_uploads_rejected(self): - png = BytesIO() - Image.new("RGB", (2, 2)).save(png, "PNG") - for raw in (b"", b"not an image", b"\xff\xd8fake JPEG", jpeg_bytes()[:30], png.getvalue()): - with self.subTest(length=len(raw)): - form = self.form(files={"jpegPhoto": photo_upload(raw)}) - self.assertFalse(form.is_valid()) - self.assertIn("jpegPhoto", form.errors) - - def test_oversize_upload_size_and_actual_read_limits(self): - for declared_size in (MAX_PHOTO_BYTES + 1, 1): - with self.subTest(declared_size=declared_size): - upload = photo_upload(b"x" * (MAX_PHOTO_BYTES + 1)) - upload.size = declared_size - form = self.form(files={"jpegPhoto": upload}) - self.assertFalse(form.is_valid()) - self.assertIn("jpegPhoto", form.errors) - - def test_photo_dimension_limit(self): - form = self.form(files={"jpegPhoto": photo_upload(jpeg_bytes((4097, 1)))}) - self.assertFalse(form.is_valid()) - self.assertIn("jpegPhoto", form.errors) - - def test_photo_omission_removal_and_conflicting_replacement(self): - form = self.form() - self.assertTrue(form.is_valid(), form.errors) - self.assertIsNone(form.cleaned_data["jpegPhoto"]) - form = self.form({"remove_photo": "on"}) - self.assertTrue(form.is_valid(), form.errors) - self.assertTrue(form.cleaned_data["remove_photo"]) - form = self.form({"remove_photo": "on"}, {"jpegPhoto": photo_upload()}) - self.assertFalse(form.is_valid()) - self.assertIn("remove_photo", form.errors) + self.assertIn("cn", form.errors) def test_current_and_new_password_whitespace_preserved_not_rendered(self): new_password = " " + secrets.token_urlsafe(24) + " " @@ -288,7 +156,6 @@ class ProfileViewTests(TestCase): self.values = profile_values() self.sign_in(self.client, self.account) self.profile_url = reverse("members:profile") - self.photo_url = reverse("members:profile_photo") self.confirm_url = reverse("members:confirm_profile_email") # Unlike a loose read mock, this checks the saved pin seen by the view. self.read = self.start_patch("members.views.profiles.read_profile", side_effect=self.read_profile) @@ -341,7 +208,7 @@ class ProfileViewTests(TestCase): def test_anonymous_endpoints_redirect_to_sign_in_page(self): client = Client() - urls = (self.profile_url, self.photo_url, self.confirm_url, reverse("members:verify_profile_email", args=[secrets.token_urlsafe(32)])) + urls = (self.profile_url, self.confirm_url, reverse("members:verify_profile_email", args=[secrets.token_urlsafe(32)])) for url in urls: with self.subTest(url=url): self.assertRedirects(client.get(url), reverse("members:home"), fetch_redirect_response=False) @@ -358,6 +225,9 @@ class ProfileViewTests(TestCase): self.assertEqual(proof["account"], str(self.account.pk)) self.assertEqual(proof["uuid"], "uuid-alice") self.assertContains(response, 'name="csrfmiddlewaretoken"') + self.assertContains(response, 'name="cn" value="Alice Example"') + for removed in ('name="sshPublicKey"', 'name="jpegPhoto"', 'name="displayName"', 'name="postalAddress"', "multipart/form-data"): + self.assertNotContains(response, removed) self.assert_no_writes() def test_csrf_required_for_each_mutating_action_and_email_confirmation(self): @@ -396,9 +266,9 @@ class ProfileViewTests(TestCase): self.assert_no_writes() def test_invalid_profile_preserves_revision_and_does_not_write_or_echo_password(self): - response = self.client.post(self.profile_url, self.profile_data(sn="", revision="original-revision")) + response = self.client.post(self.profile_url, self.profile_data(cn="", revision="original-revision")) self.assertEqual(response.status_code, 200) - self.assertIn("sn", response.context["form"].errors) + self.assertIn("cn", response.context["form"].errors) self.assertEqual(response.context["revision"], "original-revision") self.assertNotContains(response, self.password) self.assert_no_writes() @@ -417,17 +287,16 @@ class ProfileViewTests(TestCase): self.assertFalse(ProfileEmailChange.objects.exists()) def test_profile_save_uses_cleaned_allowlist_current_account_and_exact_revision(self): - data = self.profile_data(displayName="NewGamerTag", uid="mallory", dn="cn=admin", ldap_uuid="uuid-other", userPassword="injected", mail="attacker@example.org") + data = self.profile_data(cn="New Name", displayName="NewGamerTag", sshPublicKey="ssh-ed25519 AAAA", uid="mallory", dn="cn=admin", ldap_uuid="uuid-other", userPassword="injected", mail="attacker@example.org") with self.assertLogs("members.views", level="INFO") as captured: response = self.client.post(self.profile_url, data) self.assertRedirects(response, self.profile_url + "?saved=1", fetch_redirect_response=False) account, cleaned, revision = self.update.call_args.args self.assertEqual((account.pk, account.ldap_uuid, account.ldap_dn), (self.account.pk, "uuid-alice", ALICE_DN)) self.assertEqual(revision, data["revision"]) - self.assertEqual(cleaned["displayName"], ["NewGamerTag"]) - self.assertEqual(cleaned["postalAddress"], self.values["postalAddress"]) + self.assertEqual(cleaned["cn"], ["New Name"]) self.assertEqual(cleaned["current_password"], self.password) - for name in ("uid", "dn", "ldap_uuid", "userPassword", "mail"): + for name in ("uid", "dn", "ldap_uuid", "userPassword", "mail", "displayName", "sshPublicKey"): self.assertNotIn(name, cleaned) self.assertNotIn(self.password, "\n".join(captured.output)) self.account.refresh_from_db() @@ -612,31 +481,17 @@ class ProfileViewTests(TestCase): self.account.refresh_from_db() self.assertEqual(self.account.email, "alice@example.org") - def test_photo_is_sanitized_and_bound_to_current_user_not_query_or_path(self): - self.values["jpegPhoto"] = [jpeg_bytes()] - response = self.client.get(self.photo_url, {"uid": "mallory", "dn": "cn=admin", "path": "../../etc/passwd"}) - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Content-Type"], "image/jpeg") - self.assertEqual(response["Content-Disposition"], 'inline; filename="profile.jpg"') - with Image.open(BytesIO(response.content)) as image: - self.assertEqual(image.size, (512, 256)) - self.assertFalse(image.getexif()) - self.assertNotEqual(response.content, self.values["jpegPhoto"][0]) - for suffix in ("alice/", "../secret", "%2e%2e%2fsecret"): - with self.subTest(suffix=suffix), self.assertRaises(Resolver404): - resolve(self.photo_url + suffix) - self.assert_no_writes() - - def test_missing_invalid_or_nonbytes_stored_photo_returns_404(self): - for photos in ([], [b"fake JPEG"], ["not bytes"]): - with self.subTest(photos=photos): - self.values["jpegPhoto"] = photos - self.assertEqual(self.client.get(self.photo_url).status_code, 404) - self.assert_no_writes() + def test_photo_endpoint_removed(self): + with self.assertRaises(NoReverseMatch): + reverse("members:profile_photo") + with self.assertRaises(Resolver404): + resolve(self.profile_url + "photo/") + self.assertEqual(self.client.post(self.profile_url, {**self.profile_data(), "jpegPhoto": "x"}).status_code, 302) + self.assertNotIn("jpegPhoto", self.update.call_args.args[1]) def test_get_only_endpoints_reject_post(self): token, _ = self.pending() - for url in (self.photo_url, reverse("members:verify_profile_email", args=[token])): + for url in (reverse("members:verify_profile_email", args=[token]),): self.assertEqual(self.client.post(url).status_code, 405) self.assert_no_writes() @@ -651,7 +506,7 @@ class ProfileDirectoryTests(TestCase): self.password = secrets.token_urlsafe(24) self.values = profile_values() self.conn = Connection(Server("offline.invalid"), client_strategy=MOCK_SYNC) - self.conn.strategy.add_entry(ALICE_DN, self.values) + self.conn.strategy.add_entry(ALICE_DN, {**self.values, "objectClass": ["top", "person", "organizationalPerson", "inetOrgPerson"]}) self.conn.bind() self.addCleanup(self.conn.unbind) # No network access can escape this module, even if a test regresses. @@ -676,7 +531,7 @@ class ProfileDirectoryTests(TestCase): def cleaned(self, **changes): return { **{name: list(self.values[name]) for name in profiles.TEXT_ATTRIBUTES}, - "current_password": self.password, "jpegPhoto": None, "remove_photo": False, + "current_password": self.password, **changes, } @@ -777,49 +632,31 @@ class ProfileDirectoryTests(TestCase): def test_profile_update_allowlist_and_atomic_critical_assertion(self): conn, authorized = self.authorized() - cleaned = self.cleaned(displayName=["Changed"], uid=["mallory"], mail=["bad@example.org"], userPassword=["injected"], objectClass=["admin"]) + cleaned = self.cleaned(cn=["Changed"], displayName=["Ignored"], jpegPhoto=b"ignored", uid=["mallory"], mail=["bad@example.org"], userPassword=["injected"], objectClass=["admin"]) revision = profiles.revision(self.account, self.values) with patch("members.profiles._authorized", authorized): profiles.update_profile(self.account, cleaned, revision) authorized.assert_called_once_with(self.account, self.password) - self.assertEqual(conn.modify.call_args.args, (ALICE_DN, {"displayName": [(MODIFY_REPLACE, ["Changed"])]})) + self.assertEqual(conn.modify.call_args.args, (ALICE_DN, {"cn": [(MODIFY_REPLACE, ["Changed"])]})) self.assert_critical_assertion(conn.modify.call_args) def test_profile_edit_without_timestamp_fails_closed(self): values = profile_values(modifyTimestamp=[]) conn, authorized = self.authorized(values=values) with patch("members.profiles._authorized", authorized), self.assertRaises(ValueError): - profiles.update_profile(self.account, self.cleaned(displayName=["Changed"]), profiles.revision(self.account, values)) + profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), profiles.revision(self.account, values)) conn.modify.assert_not_called() - def test_ssh_edit_adds_only_known_auxiliary_class_when_missing(self): - from ldap3 import MODIFY_ADD - - conn, authorized = self.authorized() - with patch("members.profiles._authorized", authorized): - profiles.update_profile(self.account, self.cleaned(sshPublicKey=["validated-public-key"]), profiles.revision(self.account, self.values)) - changes = conn.modify.call_args.args[1] - self.assertEqual(changes["objectClass"], [(MODIFY_ADD, ["ldapPublicKey"])]) - self.assertEqual(changes["sshPublicKey"], [(MODIFY_REPLACE, ["validated-public-key"])]) - self.assert_critical_assertion(conn.modify.call_args) - - def test_noop_does_not_write_and_blank_optional_value_clears(self): + def test_noop_does_not_write_and_extra_names_collapse_to_one(self): conn, authorized = self.authorized() with patch("members.profiles._authorized", authorized): profiles.update_profile(self.account, self.cleaned(), profiles.revision(self.account, self.values)) conn.modify.assert_not_called() - profiles.update_profile(self.account, self.cleaned(description=[]), profiles.revision(self.account, self.values)) - self.assertEqual(conn.modify.call_args.args[1], {"description": [(MODIFY_REPLACE, [])]}) - - def test_photo_replacement_and_removal_are_binary_ldap_changes(self): - for changes, expected in (({"jpegPhoto": b"sanitized photo"}, [b"sanitized photo"]), ({"remove_photo": True}, [])): - with self.subTest(changes=changes): - values = profile_values(jpegPhoto=[b"previous photo"]) - conn, authorized = self.authorized(values=values) - with patch("members.profiles._authorized", authorized): - profiles.update_profile(self.account, self.cleaned(**changes), profiles.revision(self.account, values)) - self.assertEqual(conn.modify.call_args.args[1], {"jpegPhoto": [(MODIFY_REPLACE, expected)]}) - self.assert_critical_assertion(conn.modify.call_args) + values = profile_values(cn=["Alice", "Alice Example"]) + conn, authorized = self.authorized(values=values) + with patch("members.profiles._authorized", authorized): + profiles.update_profile(self.account, self.cleaned(cn=["Alice"]), profiles.revision(self.account, values)) + self.assertEqual(conn.modify.call_args.args[1], {"cn": [(MODIFY_REPLACE, ["Alice"])]}) def test_revision_bound_to_account_uuid_and_all_read_values(self): valid = profiles.revision(self.account, self.values) @@ -830,9 +667,9 @@ class ProfileDirectoryTests(TestCase): with self.subTest(revision=revision): conn, authorized = self.authorized() with patch("members.profiles._authorized", authorized), self.assertRaises((ValueError, signing.BadSignature)): - profiles.update_profile(self.account, self.cleaned(displayName=["Changed"]), revision) + profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), revision) conn.modify.assert_not_called() - for changed in (profile_values(displayName=["Concurrent edit"]), profile_values(jpegPhoto=[b"new photo"]), profile_values(modifyTimestamp=["20261029120100Z"])): + for changed in (profile_values(cn=["Concurrent edit"]), profile_values(mail=["moved@example.org"]), profile_values(modifyTimestamp=["20261029120100Z"])): conn, authorized = self.authorized(values=changed) with patch("members.profiles._authorized", authorized), self.assertRaisesRegex(ValueError, "profile changed"): profiles.update_profile(self.account, self.cleaned(), valid) @@ -885,7 +722,7 @@ class ProfileDirectoryTests(TestCase): conn.extend.standard.modify_password.return_value = False with patch("members.profiles._authorized", authorized): with self.assertRaises(LDAPException): - profiles.update_profile(self.account, self.cleaned(displayName=["Changed"]), profiles.revision(self.account, self.values)) + profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), profiles.revision(self.account, self.values)) with self.assertRaises(LDAPException): profiles.change_password(self.account, self.password, secrets.token_urlsafe(24)) with self.assertRaises(LDAPException): diff --git a/membership/members/urls.py b/membership/members/urls.py index 6a394e1..3527ceb 100755 --- a/membership/members/urls.py +++ b/membership/members/urls.py @@ -12,7 +12,6 @@ urlpatterns = [ path("oidc/callback/", views.callback, name="callback"), path("logout/", views.logout, name="logout"), path("profile/", views.profile, name="profile"), - path("profile/photo/", views.profile_photo, name="profile_photo"), path("profile/email/verify//", views.verify_profile_email, name="verify_profile_email"), path("profile/email/confirm/", views.confirm_profile_email, name="confirm_profile_email"), path("profile/newsletter/", views.newsletter_preference, name="newsletter"), diff --git a/membership/members/views.py b/membership/members/views.py index a855b93..0db01e2 100755 --- a/membership/members/views.py +++ b/membership/members/views.py @@ -11,7 +11,6 @@ import stripe from authlib.integrations.django_client import OAuth from django.conf import settings from django.core.validators import validate_email -from django.core.files.uploadedfile import SimpleUploadedFile from django.core.mail import send_mail from django.db import ProgrammingError, transaction from django.http import HttpResponse @@ -275,7 +274,7 @@ def logout(request): def _profile_context(account, values): - return {"account": account, "profile_loaded": True, "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "has_photo": bool(values["jpegPhoto"]), "email_values": values["mail"], "revision": profiles.revision(account, values)} + return {"account": account, "profile_loaded": True, "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "email_values": values["mail"], "revision": profiles.revision(account, values)} @sensitive_post_parameters() @@ -302,7 +301,7 @@ def profile(request): if action not in forms: return HttpResponse(status=400) form_key, form_class = forms[action] - form = form_class(request.POST, request.FILES) if action == "profile" else form_class(request.POST) + form = form_class(request.POST) context[form_key] = form if form.is_valid(): try: @@ -336,32 +335,6 @@ def profile(request): return render(request, "profile.html", context) -@require_GET -@account_required -def profile_photo(request): - if limited("profile-photo", str(request.member.pk), 120): - return HttpResponse(status=429) - try: - values = profiles.read_profile(request.member) - if not values["jpegPhoto"]: - return HttpResponse(status=404) - # Existing LDAP photos are untrusted too: rewrite before browser delivery. - raw = values["jpegPhoto"][0] - if not isinstance(raw, bytes): - raise ValueError("Invalid stored image.") - form = ProfileForm(files={"jpegPhoto": SimpleUploadedFile("photo.jpg", raw, content_type="image/jpeg")}) - form.is_valid() - image = form.cleaned_data.get("jpegPhoto") - if not image: - return HttpResponse(status=404) - response = HttpResponse(image, content_type="image/jpeg") - response["Content-Disposition"] = 'inline; filename="profile.jpg"' - return response - except Exception as exc: - logger.error("Profile photo failed account=%s kind=%s", request.member.pk, type(exc).__name__) - return HttpResponse(status=404) - - @require_GET @account_required def verify_profile_email(request, token): diff --git a/membership/templates/profile.html b/membership/templates/profile.html index acf9491..3ca2810 100755 --- a/membership/templates/profile.html +++ b/membership/templates/profile.html @@ -18,7 +18,7 @@

One account. Your own identity.

Your profile

-

Your profile is shared across eom.dev services. Your gamer tag is also reused by gaming services. Add only information you want to share with those services.

+

Your eom.dev account is shared across every service you sign in to. Services see your username, name and email address.

{% if account %}

Signed in as {{ account.uid }}. Your username cannot be changed here.

{% endif %}
{% if saved %}

Your changes have been saved.

{% endif %} @@ -43,56 +43,21 @@ {% else %} -
+ {% csrf_token %} - {% if form.errors %}{% endif %} -
- Identity -

Tell the community who you are. Last name is required. Text fields support multiple values: one per line, up to 16 per field. Blank optional fields are cleared when you save.

- {% include "profile_fields.html" with fields=form.identity_fields only %} - {% if has_photo %}
Your current profile photo
Current profile photo
{% endif %} -
- {{ form.jpegPhoto.label_tag }} -

{{ form.jpegPhoto.help_text }}

- {{ form.jpegPhoto }} - {% if form.jpegPhoto.errors %}{% endif %} -
-
{{ form.remove_photo }} {{ form.remove_photo.label_tag }}{% if form.remove_photo.errors %}{% endif %}
-

Leave the photo field empty to keep your photo. To remove it, select the checkbox without uploading a replacement.

-
-
- Contact -

Choose the websites and phone numbers to share. Email changes are verified separately below.

- {% include "profile_fields.html" with fields=form.contact_fields only %} -
-
- Addresses -

Address details are optional. For postal addresses, use a blank line between entries; other fields use one value per line.

- {% include "profile_fields.html" with fields=form.address_fields only %} -
-
- SSH keys -

Share public keys only. Never paste a private key or a password here.

-
- {{ form.sshPublicKey.label_tag }} -

{{ form.sshPublicKey.help_text }}

- {{ form.sshPublicKey }} - {% if form.sshPublicKey.errors %}{% endif %} -
-
-
- Authorize profile changes - {% include "profile_fields.html" with fields=form.authorization_fields only %} - -
+

Name

+ {% if form.errors %}{% endif %} + {% include "profile_fields.html" with fields=form.identity_fields only %} + {% include "profile_fields.html" with fields=form.authorization_fields only %} +

Email address

-

Currently stored email addresses (read-only):

+

Current email address:

A verified change replaces all currently stored email addresses with the new address. We will email a verification link to the new address; nothing changes until you confirm it with your current password.