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:
@@ -378,59 +378,38 @@ separate coordinated change, not an automatic part of this release.
|
|||||||
### Self-service account profile
|
### Self-service account profile
|
||||||
|
|
||||||
Signed-in members can choose **Edit profile** from their membership page.
|
Signed-in members can choose **Edit profile** from their membership page.
|
||||||
Deploy the new membership image, apply migration `0005_profile_email_change`,
|
Login is Dex-only, and the profile editor offers **only the values the Dex LDAP
|
||||||
and deploy the updated static CSS/proxy configuration. No new public settings
|
connector reads back into ID-token claims**, so what a member edits is exactly
|
||||||
or credentials are required. Login remains Dex-only: the profile editor reads
|
what downstream services receive. The editor reads LDAP **only when opened**;
|
||||||
LDAP **only when opened**, because the configured Dex LDAP connector exposes
|
cached OIDC claims are not treated as a current profile.
|
||||||
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.
|
|
||||||
|
|
||||||
| Profile label | LDAP attribute |
|
| Profile label | LDAP attribute | Dex claim |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| About you | `description` |
|
| Username (read-only) | `uid` | `preferred_username` |
|
||||||
| Gamer tag (one value) | `displayName` |
|
| Name (one value) | `cn` (connector `nameAttr`) | `name` |
|
||||||
| Email address (verified separately) | `mail` |
|
| Email address (verified separately) | `mail` | `email` |
|
||||||
| First name / Last name | `givenName` / `sn` |
|
| Password | RFC 3062 Password Modify (never read back) | — |
|
||||||
| 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) |
|
|
||||||
|
|
||||||
The form preserves existing multi-value attributes. Enter one value per line;
|
Other directory attributes (photos, SSH keys, addresses, `displayName`, etc.)
|
||||||
separate multiline postal addresses with a blank line. Blank optional fields
|
are neither shown nor modified, so existing values are left in place. If the
|
||||||
are removed on save; `sn` remains required by the directory schema. JPEGs are
|
connector's `nameAttr` is changed, change `profiles.TEXT_ATTRIBUTES` to match.
|
||||||
limited to 1 MiB and 4096 pixels per side/16 million pixels, resized to fit 512
|
Saving the name replaces every `cn` value with the single entered value; Dex
|
||||||
pixels and reencoded without metadata. Existing photos are sanitized again
|
already uses only the first. Account DNs must not use `cn` as their RDN.
|
||||||
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.
|
|
||||||
|
|
||||||
**Directory prerequisites:**
|
**Directory prerequisites:**
|
||||||
|
|
||||||
- The service account needs read/search access to the profile attributes above,
|
- The service account needs read/search access to `cn`, `mail`, `uid`,
|
||||||
plus `uid`, `entryUUID`, `objectClass` and `modifyTimestamp`, across the
|
`entryUUID` and `modifyTimestamp`, across the
|
||||||
intended account search tree. It never needs read access to `userPassword`.
|
intended account search tree. It never needs read access to `userPassword`.
|
||||||
Accounts are pinned to immutable UUIDs; missing/deleted entries do not fall
|
Accounts are pinned to immutable UUIDs; missing/deleted entries do not fall
|
||||||
back to a reused username. LDAP unavailability disables editing rather than
|
back to a reused username. LDAP unavailability disables editing rather than
|
||||||
presenting an empty form that could erase values.
|
presenting an empty form that could erase values.
|
||||||
- Every write binds with the member's **current LDAP password** and checks that
|
- 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
|
entry's UUID again. Configure directory **self-write ACLs** only for `cn`,
|
||||||
profile attributes and self Password Modify; do not grant writes to `uid`,
|
`mail` and self Password Modify; do not grant writes to `uid`,
|
||||||
`entryUUID`, account DNs, POSIX IDs, group membership or administrative fields.
|
`entryUUID`, account DNs, POSIX IDs, group membership or administrative fields.
|
||||||
This supports existing Dex users outside the website registration OU without
|
This supports existing Dex users outside the website registration OU without
|
||||||
granting the membership service unrestricted directory write permissions.
|
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
|
- OpenLDAP must support critical RFC 4528 assertion controls. Profile/email
|
||||||
writes assert UUID and last-modified time, and profile forms carry a signed,
|
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
|
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
|
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 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,
|
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
|
directory read ACLs and tell users what other services can access. TLS defaults
|
||||||
are unchanged; the existing explicit plaintext opt-in also exposes profile
|
are unchanged; the existing explicit plaintext opt-in also exposes profile
|
||||||
passwords/data and should not be used on untrusted networks.
|
passwords/data and should not be used on untrusted networks.
|
||||||
|
|
||||||
The supplied Kubernetes ingress accepts up to 2 MiB; Apache allows that limit
|
Apache limits all member routes, including the profile, to 256 KiB request
|
||||||
only under `/members/profile/` and retains 256 KiB for other member routes.
|
bodies; mirror that limit at any additional edge proxy.
|
||||||
Mirror the limits at any additional edge proxy. Oversized or invalid uploads
|
|
||||||
must be rejected before reaching image processing.
|
|
||||||
|
|
||||||
### Database ProgrammingError during callback
|
### Database ProgrammingError during callback
|
||||||
|
|
||||||
|
|||||||
@@ -251,10 +251,6 @@ ProxyPassReverse "/members/" "http://membership:8000/members/"
|
|||||||
RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}"
|
RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}"
|
||||||
LimitRequestBody 262144
|
LimitRequestBody 262144
|
||||||
</Location>
|
</Location>
|
||||||
<Location "/members/profile/">
|
|
||||||
# 1 MiB JPEG plus form/multipart overhead; other member routes stay smaller.
|
|
||||||
LimitRequestBody 2097152
|
|
||||||
</Location>
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Deny access to the entirety of your server's filesystem. You must
|
# Deny access to the entirety of your server's filesystem. You must
|
||||||
|
|||||||
@@ -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-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-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-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-email-values { padding-left: 1.4rem; overflow-wrap: anywhere; }
|
||||||
.profile-back { margin-top: 2rem; }
|
.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; } }
|
@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; } }
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
"""Validation-only forms for the shared LDAP profile; no directory writes."""
|
"""Validation-only forms for the shared LDAP profile; no directory writes."""
|
||||||
|
|
||||||
import unicodedata
|
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 import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import URLValidator
|
|
||||||
|
|
||||||
from .forms import PasswordForm
|
from .forms import PasswordForm
|
||||||
|
|
||||||
|
|
||||||
MAX_INPUT_LENGTH = 32768
|
MAX_INPUT_LENGTH = 32768
|
||||||
MAX_VALUES = 16
|
MAX_VALUES = 16
|
||||||
MAX_PHOTO_BYTES = 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_text(value: str) -> str:
|
def _safe_text(value: str) -> str:
|
||||||
@@ -32,9 +24,8 @@ def _safe_text(value: str) -> str:
|
|||||||
class ProfileValuesField(forms.CharField):
|
class ProfileValuesField(forms.CharField):
|
||||||
"""Text input becomes an ordered, deduplicated list of LDAP strings."""
|
"""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.value_length = value_length
|
||||||
self.postal = postal
|
|
||||||
self.max_values = max_values
|
self.max_values = max_values
|
||||||
kwargs.setdefault("required", False)
|
kwargs.setdefault("required", False)
|
||||||
kwargs.setdefault("help_text", "One value per line. Up to 16 values; leave blank to clear.")
|
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:
|
if value is not None and len(str(value)) > MAX_INPUT_LENGTH:
|
||||||
raise ValidationError("Enter no more than 32,768 characters in this field.")
|
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 "")
|
text = super().clean(_safe_text(str(value)) if value is not None else "")
|
||||||
if self.postal:
|
entries = [line.strip() for line in text.split("\n") if line.strip()]
|
||||||
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()]
|
|
||||||
if len(entries) > self.max_values:
|
if len(entries) > self.max_values:
|
||||||
raise ValidationError(f"Enter no more than {self.max_values} value(s).")
|
raise ValidationError(f"Enter no more than {self.max_values} value(s).")
|
||||||
if any(len(entry) > self.value_length for entry in entries):
|
if any(len(entry) > self.value_length for entry in entries):
|
||||||
@@ -88,128 +68,25 @@ class CurrentPasswordForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class ProfileForm(CurrentPasswordForm):
|
class ProfileForm(CurrentPasswordForm):
|
||||||
description = ProfileValuesField(label="About you", value_length=4096)
|
"""Only Dex-visible values: cn is published to services as the "name" claim."""
|
||||||
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")
|
cn = ProfileValuesField(
|
||||||
l = ProfileValuesField(label="City/town")
|
label="Name", required=True, max_values=1, value_length=256,
|
||||||
labeledURI = ProfileValuesField(label="Websites", help_text="One HTTP or HTTPS URL per line, optionally followed by a space and a label.")
|
help_text="How you appear across eom.dev services.",
|
||||||
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.",
|
|
||||||
)
|
)
|
||||||
remove_photo = forms.BooleanField(label="Remove current photo", required=False)
|
field_order = ["cn", "current_password"]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Initial text values are formatted by the caller, including postal $ decoding.
|
|
||||||
kwargs.setdefault("auto_id", "id_profile_%s")
|
kwargs.setdefault("auto_id", "id_profile_%s")
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["cn"].widget = forms.TextInput(attrs={"maxlength": 256, "autocomplete": "name"})
|
||||||
|
|
||||||
def identity_fields(self):
|
def identity_fields(self):
|
||||||
return [self[name] for name in ("givenName", "sn", "displayName", "description", "o", "title")]
|
return [self["cn"]]
|
||||||
|
|
||||||
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")]
|
|
||||||
|
|
||||||
def authorization_fields(self):
|
def authorization_fields(self):
|
||||||
return [self["current_password"]]
|
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):
|
class ProfileEmailForm(CurrentPasswordForm):
|
||||||
email = forms.EmailField(label="New email address", max_length=254, widget=forms.EmailInput(attrs={"autocomplete": "email"}))
|
email = forms.EmailField(label="New email address", max_length=254, widget=forms.EmailInput(attrs={"autocomplete": "email"}))
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ No service-account writes, password hashes, tokens or profile snapshots are stor
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.db import transaction
|
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.core.exceptions import LDAPException
|
||||||
from ldap3.operation.search import compile_filter, parse_filter
|
from ldap3.operation.search import compile_filter, parse_filter
|
||||||
from ldap3.protocol.controls import build_control
|
from ldap3.protocol.controls import build_control
|
||||||
@@ -22,12 +21,12 @@ from . import directory
|
|||||||
from .models import Account
|
from .models import Account
|
||||||
|
|
||||||
|
|
||||||
TEXT_ATTRIBUTES = (
|
# Only attributes the Dex LDAP connector reads back into ID-token claims are
|
||||||
"description", "displayName", "givenName", "l", "labeledURI", "mobile", "o",
|
# editable: cn (nameAttr -> "name"). mail ("email") is changed through the
|
||||||
"postalAddress", "postalCode", "roomNumber", "sn", "sshPublicKey", "st",
|
# separate verified flow and uid ("preferred_username") is immutable. Other
|
||||||
"street", "telephoneNumber", "title",
|
# directory attributes are left untouched, neither shown nor cleared.
|
||||||
)
|
TEXT_ATTRIBUTES = ("cn",)
|
||||||
READ_ATTRIBUTES = (*TEXT_ATTRIBUTES, "mail", "jpegPhoto", "entryUUID", "uid", "modifyTimestamp", "objectClass")
|
READ_ATTRIBUTES = (*TEXT_ATTRIBUTES, "mail", "entryUUID", "uid", "modifyTimestamp")
|
||||||
|
|
||||||
|
|
||||||
def _values(entry):
|
def _values(entry):
|
||||||
@@ -89,10 +88,8 @@ def revision(account, values):
|
|||||||
|
|
||||||
|
|
||||||
def initial(values):
|
def initial(values):
|
||||||
def postal(value):
|
# Dex publishes the first cn value; saving collapses any extras to one.
|
||||||
# Decode once: a literal '\24' represented by '\5c24' must not become '$'.
|
return {name: str(values[name][0]) if values[name] else "" for name in TEXT_ATTRIBUTES}
|
||||||
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
|
@contextmanager
|
||||||
@@ -126,15 +123,6 @@ def update_profile(account, cleaned, expected_revision):
|
|||||||
new_values = cleaned[name]
|
new_values = cleaned[name]
|
||||||
if new_values != values[name]:
|
if new_values != values[name]:
|
||||||
changes[name] = [(MODIFY_REPLACE, new_values)]
|
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)]):
|
if changes and not conn.modify(account.ldap_dn, changes, controls=[_assertion(values)]):
|
||||||
raise LDAPException("Profile modification rejected.")
|
raise LDAPException("Profile modification rejected.")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Offline self-service profile tests; never bind LDAP or deliver real email.
|
"""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
|
ephemeral test data, not service credentials. Directory writes use mocks; LDAP
|
||||||
reads additionally exercise ldap3's in-memory strategy.
|
reads additionally exercise ldap3's in-memory strategy.
|
||||||
"""
|
"""
|
||||||
@@ -9,28 +9,22 @@ import copy
|
|||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from io import BytesIO
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from urllib.parse import urlsplit
|
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.conf import settings
|
||||||
from django.core import mail, signing
|
from django.core import mail, signing
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from django.test import Client, SimpleTestCase, TestCase, override_settings
|
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 django.utils import timezone
|
||||||
from ldap3 import BASE, MOCK_SYNC, MODIFY_REPLACE, Connection, Server
|
from ldap3 import BASE, MOCK_SYNC, MODIFY_REPLACE, Connection, Server
|
||||||
from ldap3.core.exceptions import LDAPException
|
from ldap3.core.exceptions import LDAPException
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from members import profiles
|
from members import profiles
|
||||||
from members.models import Account, ProfileEmailChange
|
from members.models import Account, ProfileEmailChange
|
||||||
from members.profile_forms import (
|
from members.profile_forms import (
|
||||||
MAX_INPUT_LENGTH,
|
MAX_INPUT_LENGTH,
|
||||||
MAX_PHOTO_BYTES,
|
|
||||||
CurrentPasswordForm,
|
CurrentPasswordForm,
|
||||||
ProfileEmailForm,
|
ProfileEmailForm,
|
||||||
ProfileForm,
|
ProfileForm,
|
||||||
@@ -47,73 +41,49 @@ def profile_values(**overrides):
|
|||||||
"""The complete READ_ATTRIBUTES shape returned by the real reader."""
|
"""The complete READ_ATTRIBUTES shape returned by the real reader."""
|
||||||
values = {name: [] for name in profiles.READ_ATTRIBUTES}
|
values = {name: [] for name in profiles.READ_ATTRIBUTES}
|
||||||
values.update({
|
values.update({
|
||||||
"description": ["Community member"],
|
"cn": ["Alice Example"],
|
||||||
"displayName": ["AliceInGame"],
|
|
||||||
"givenName": ["Alice"],
|
|
||||||
"sn": ["Example"],
|
|
||||||
"mail": ["alice@example.org", "alice.other@example.org"],
|
"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"],
|
"entryUUID": ["uuid-alice"],
|
||||||
"uid": ["alice"],
|
"uid": ["alice"],
|
||||||
"modifyTimestamp": ["20261029120000Z"],
|
"modifyTimestamp": ["20261029120000Z"],
|
||||||
"objectClass": ["top", "person", "organizationalPerson", "inetOrgPerson"],
|
|
||||||
})
|
})
|
||||||
values.update(overrides)
|
values.update(overrides)
|
||||||
return values
|
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):
|
class ProfileFormTests(SimpleTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.password = " " + secrets.token_urlsafe(24) + " "
|
self.password = " " + secrets.token_urlsafe(24) + " "
|
||||||
|
|
||||||
def form(self, data=None, files=None):
|
def form(self, data=None):
|
||||||
return ProfileForm(
|
return ProfileForm(data={"cn": "Alice Example", "current_password": self.password, **(data or {})})
|
||||||
data={"sn": "Example", "current_password": self.password, **(data or {})},
|
|
||||||
files=files,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_friendly_labels_and_single_gamer_tag(self):
|
def test_single_name_value_published_by_dex(self):
|
||||||
form = self.form({"displayName": "AliceInGame"})
|
form = self.form()
|
||||||
self.assertTrue(form.is_valid(), form.errors)
|
self.assertTrue(form.is_valid(), form.errors)
|
||||||
self.assertEqual(form.fields["displayName"].label, "Gamer tag")
|
self.assertEqual(form.fields["cn"].label, "Name")
|
||||||
self.assertEqual(form.fields["sn"].label, "Last name")
|
self.assertEqual(form.cleaned_data["cn"], ["Alice Example"])
|
||||||
self.assertEqual(form.fields["l"].label, "City/town")
|
for name in ("First\nSecond", "x" * 257):
|
||||||
self.assertEqual(form.cleaned_data["displayName"], ["AliceInGame"])
|
with self.subTest(length=len(name)):
|
||||||
invalid = self.form({"displayName": "FirstTag\nSecondTag"})
|
invalid = self.form({"cn": name})
|
||||||
self.assertFalse(invalid.is_valid())
|
self.assertFalse(invalid.is_valid())
|
||||||
self.assertIn("displayName", invalid.errors)
|
self.assertIn("cn", invalid.errors)
|
||||||
|
|
||||||
def test_all_editable_text_attributes_have_fields_but_identity_is_read_only(self):
|
def test_only_dex_claim_attributes_are_editable(self):
|
||||||
self.assertTrue(set(profiles.TEXT_ATTRIBUTES) <= set(ProfileForm.base_fields))
|
# Dex's LDAP connector publishes uid, cn and mail; mail has its own
|
||||||
for name in ("uid", "dn", "ldap_dn", "entryUUID", "mail", "userPassword", "objectClass"):
|
# verified flow and uid is immutable, so only cn is on this form.
|
||||||
self.assertNotIn(name, ProfileForm.base_fields)
|
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):
|
def test_field_groups_and_unique_password_ids(self):
|
||||||
form = self.form()
|
form = self.form()
|
||||||
grouped = [
|
grouped = [*form.identity_fields(), *form.authorization_fields()]
|
||||||
*form.identity_fields(), *form.contact_fields(),
|
|
||||||
*form.address_fields(), *form.authorization_fields(),
|
|
||||||
]
|
|
||||||
self.assertEqual(len({field.name for field in grouped}), len(grouped))
|
self.assertEqual(len({field.name for field in grouped}), len(grouped))
|
||||||
ids = [cls()["current_password"].auto_id for cls in (
|
ids = [cls()["current_password"].auto_id for cls in (
|
||||||
ProfileForm, ProfileEmailForm, ProfilePasswordForm, CurrentPasswordForm,
|
ProfileForm, ProfileEmailForm, ProfilePasswordForm, CurrentPasswordForm,
|
||||||
@@ -134,114 +104,12 @@ class ProfileFormTests(SimpleTestCase):
|
|||||||
field.clean(value)
|
field.clean(value)
|
||||||
self.assertEqual(len(ProfileValuesField().clean("\n".join(str(i) for i in range(16)))), 16)
|
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):
|
def test_name_required_even_when_only_unsafe_or_blank_text(self):
|
||||||
for surname in ("", " \n ", "\x00\u200b"):
|
for name in ("", " \n ", "\x00\u200b"):
|
||||||
with self.subTest(surname=repr(surname)):
|
with self.subTest(name=repr(name)):
|
||||||
form = self.form({"sn": surname})
|
form = self.form({"cn": name})
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertIn("sn", form.errors)
|
self.assertIn("cn", 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)
|
|
||||||
|
|
||||||
def test_current_and_new_password_whitespace_preserved_not_rendered(self):
|
def test_current_and_new_password_whitespace_preserved_not_rendered(self):
|
||||||
new_password = " " + secrets.token_urlsafe(24) + " "
|
new_password = " " + secrets.token_urlsafe(24) + " "
|
||||||
@@ -288,7 +156,6 @@ class ProfileViewTests(TestCase):
|
|||||||
self.values = profile_values()
|
self.values = profile_values()
|
||||||
self.sign_in(self.client, self.account)
|
self.sign_in(self.client, self.account)
|
||||||
self.profile_url = reverse("members:profile")
|
self.profile_url = reverse("members:profile")
|
||||||
self.photo_url = reverse("members:profile_photo")
|
|
||||||
self.confirm_url = reverse("members:confirm_profile_email")
|
self.confirm_url = reverse("members:confirm_profile_email")
|
||||||
# Unlike a loose read mock, this checks the saved pin seen by the view.
|
# 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)
|
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):
|
def test_anonymous_endpoints_redirect_to_sign_in_page(self):
|
||||||
client = Client()
|
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:
|
for url in urls:
|
||||||
with self.subTest(url=url):
|
with self.subTest(url=url):
|
||||||
self.assertRedirects(client.get(url), reverse("members:home"), fetch_redirect_response=False)
|
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["account"], str(self.account.pk))
|
||||||
self.assertEqual(proof["uuid"], "uuid-alice")
|
self.assertEqual(proof["uuid"], "uuid-alice")
|
||||||
self.assertContains(response, 'name="csrfmiddlewaretoken"')
|
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()
|
self.assert_no_writes()
|
||||||
|
|
||||||
def test_csrf_required_for_each_mutating_action_and_email_confirmation(self):
|
def test_csrf_required_for_each_mutating_action_and_email_confirmation(self):
|
||||||
@@ -396,9 +266,9 @@ class ProfileViewTests(TestCase):
|
|||||||
self.assert_no_writes()
|
self.assert_no_writes()
|
||||||
|
|
||||||
def test_invalid_profile_preserves_revision_and_does_not_write_or_echo_password(self):
|
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.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.assertEqual(response.context["revision"], "original-revision")
|
||||||
self.assertNotContains(response, self.password)
|
self.assertNotContains(response, self.password)
|
||||||
self.assert_no_writes()
|
self.assert_no_writes()
|
||||||
@@ -417,17 +287,16 @@ class ProfileViewTests(TestCase):
|
|||||||
self.assertFalse(ProfileEmailChange.objects.exists())
|
self.assertFalse(ProfileEmailChange.objects.exists())
|
||||||
|
|
||||||
def test_profile_save_uses_cleaned_allowlist_current_account_and_exact_revision(self):
|
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:
|
with self.assertLogs("members.views", level="INFO") as captured:
|
||||||
response = self.client.post(self.profile_url, data)
|
response = self.client.post(self.profile_url, data)
|
||||||
self.assertRedirects(response, self.profile_url + "?saved=1", fetch_redirect_response=False)
|
self.assertRedirects(response, self.profile_url + "?saved=1", fetch_redirect_response=False)
|
||||||
account, cleaned, revision = self.update.call_args.args
|
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((account.pk, account.ldap_uuid, account.ldap_dn), (self.account.pk, "uuid-alice", ALICE_DN))
|
||||||
self.assertEqual(revision, data["revision"])
|
self.assertEqual(revision, data["revision"])
|
||||||
self.assertEqual(cleaned["displayName"], ["NewGamerTag"])
|
self.assertEqual(cleaned["cn"], ["New Name"])
|
||||||
self.assertEqual(cleaned["postalAddress"], self.values["postalAddress"])
|
|
||||||
self.assertEqual(cleaned["current_password"], self.password)
|
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(name, cleaned)
|
||||||
self.assertNotIn(self.password, "\n".join(captured.output))
|
self.assertNotIn(self.password, "\n".join(captured.output))
|
||||||
self.account.refresh_from_db()
|
self.account.refresh_from_db()
|
||||||
@@ -612,31 +481,17 @@ class ProfileViewTests(TestCase):
|
|||||||
self.account.refresh_from_db()
|
self.account.refresh_from_db()
|
||||||
self.assertEqual(self.account.email, "alice@example.org")
|
self.assertEqual(self.account.email, "alice@example.org")
|
||||||
|
|
||||||
def test_photo_is_sanitized_and_bound_to_current_user_not_query_or_path(self):
|
def test_photo_endpoint_removed(self):
|
||||||
self.values["jpegPhoto"] = [jpeg_bytes()]
|
with self.assertRaises(NoReverseMatch):
|
||||||
response = self.client.get(self.photo_url, {"uid": "mallory", "dn": "cn=admin", "path": "../../etc/passwd"})
|
reverse("members:profile_photo")
|
||||||
self.assertEqual(response.status_code, 200)
|
with self.assertRaises(Resolver404):
|
||||||
self.assertEqual(response["Content-Type"], "image/jpeg")
|
resolve(self.profile_url + "photo/")
|
||||||
self.assertEqual(response["Content-Disposition"], 'inline; filename="profile.jpg"')
|
self.assertEqual(self.client.post(self.profile_url, {**self.profile_data(), "jpegPhoto": "x"}).status_code, 302)
|
||||||
with Image.open(BytesIO(response.content)) as image:
|
self.assertNotIn("jpegPhoto", self.update.call_args.args[1])
|
||||||
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_get_only_endpoints_reject_post(self):
|
def test_get_only_endpoints_reject_post(self):
|
||||||
token, _ = self.pending()
|
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.assertEqual(self.client.post(url).status_code, 405)
|
||||||
self.assert_no_writes()
|
self.assert_no_writes()
|
||||||
|
|
||||||
@@ -651,7 +506,7 @@ class ProfileDirectoryTests(TestCase):
|
|||||||
self.password = secrets.token_urlsafe(24)
|
self.password = secrets.token_urlsafe(24)
|
||||||
self.values = profile_values()
|
self.values = profile_values()
|
||||||
self.conn = Connection(Server("offline.invalid"), client_strategy=MOCK_SYNC)
|
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.conn.bind()
|
||||||
self.addCleanup(self.conn.unbind)
|
self.addCleanup(self.conn.unbind)
|
||||||
# No network access can escape this module, even if a test regresses.
|
# No network access can escape this module, even if a test regresses.
|
||||||
@@ -676,7 +531,7 @@ class ProfileDirectoryTests(TestCase):
|
|||||||
def cleaned(self, **changes):
|
def cleaned(self, **changes):
|
||||||
return {
|
return {
|
||||||
**{name: list(self.values[name]) for name in profiles.TEXT_ATTRIBUTES},
|
**{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,
|
**changes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,49 +632,31 @@ class ProfileDirectoryTests(TestCase):
|
|||||||
|
|
||||||
def test_profile_update_allowlist_and_atomic_critical_assertion(self):
|
def test_profile_update_allowlist_and_atomic_critical_assertion(self):
|
||||||
conn, authorized = self.authorized()
|
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)
|
revision = profiles.revision(self.account, self.values)
|
||||||
with patch("members.profiles._authorized", authorized):
|
with patch("members.profiles._authorized", authorized):
|
||||||
profiles.update_profile(self.account, cleaned, revision)
|
profiles.update_profile(self.account, cleaned, revision)
|
||||||
authorized.assert_called_once_with(self.account, self.password)
|
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)
|
self.assert_critical_assertion(conn.modify.call_args)
|
||||||
|
|
||||||
def test_profile_edit_without_timestamp_fails_closed(self):
|
def test_profile_edit_without_timestamp_fails_closed(self):
|
||||||
values = profile_values(modifyTimestamp=[])
|
values = profile_values(modifyTimestamp=[])
|
||||||
conn, authorized = self.authorized(values=values)
|
conn, authorized = self.authorized(values=values)
|
||||||
with patch("members.profiles._authorized", authorized), self.assertRaises(ValueError):
|
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()
|
conn.modify.assert_not_called()
|
||||||
|
|
||||||
def test_ssh_edit_adds_only_known_auxiliary_class_when_missing(self):
|
def test_noop_does_not_write_and_extra_names_collapse_to_one(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):
|
|
||||||
conn, authorized = self.authorized()
|
conn, authorized = self.authorized()
|
||||||
with patch("members.profiles._authorized", authorized):
|
with patch("members.profiles._authorized", authorized):
|
||||||
profiles.update_profile(self.account, self.cleaned(), profiles.revision(self.account, self.values))
|
profiles.update_profile(self.account, self.cleaned(), profiles.revision(self.account, self.values))
|
||||||
conn.modify.assert_not_called()
|
conn.modify.assert_not_called()
|
||||||
profiles.update_profile(self.account, self.cleaned(description=[]), profiles.revision(self.account, self.values))
|
values = profile_values(cn=["Alice", "Alice Example"])
|
||||||
self.assertEqual(conn.modify.call_args.args[1], {"description": [(MODIFY_REPLACE, [])]})
|
conn, authorized = self.authorized(values=values)
|
||||||
|
with patch("members.profiles._authorized", authorized):
|
||||||
def test_photo_replacement_and_removal_are_binary_ldap_changes(self):
|
profiles.update_profile(self.account, self.cleaned(cn=["Alice"]), profiles.revision(self.account, values))
|
||||||
for changes, expected in (({"jpegPhoto": b"sanitized photo"}, [b"sanitized photo"]), ({"remove_photo": True}, [])):
|
self.assertEqual(conn.modify.call_args.args[1], {"cn": [(MODIFY_REPLACE, ["Alice"])]})
|
||||||
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)
|
|
||||||
|
|
||||||
def test_revision_bound_to_account_uuid_and_all_read_values(self):
|
def test_revision_bound_to_account_uuid_and_all_read_values(self):
|
||||||
valid = profiles.revision(self.account, self.values)
|
valid = profiles.revision(self.account, self.values)
|
||||||
@@ -830,9 +667,9 @@ class ProfileDirectoryTests(TestCase):
|
|||||||
with self.subTest(revision=revision):
|
with self.subTest(revision=revision):
|
||||||
conn, authorized = self.authorized()
|
conn, authorized = self.authorized()
|
||||||
with patch("members.profiles._authorized", authorized), self.assertRaises((ValueError, signing.BadSignature)):
|
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()
|
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)
|
conn, authorized = self.authorized(values=changed)
|
||||||
with patch("members.profiles._authorized", authorized), self.assertRaisesRegex(ValueError, "profile changed"):
|
with patch("members.profiles._authorized", authorized), self.assertRaisesRegex(ValueError, "profile changed"):
|
||||||
profiles.update_profile(self.account, self.cleaned(), valid)
|
profiles.update_profile(self.account, self.cleaned(), valid)
|
||||||
@@ -885,7 +722,7 @@ class ProfileDirectoryTests(TestCase):
|
|||||||
conn.extend.standard.modify_password.return_value = False
|
conn.extend.standard.modify_password.return_value = False
|
||||||
with patch("members.profiles._authorized", authorized):
|
with patch("members.profiles._authorized", authorized):
|
||||||
with self.assertRaises(LDAPException):
|
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):
|
with self.assertRaises(LDAPException):
|
||||||
profiles.change_password(self.account, self.password, secrets.token_urlsafe(24))
|
profiles.change_password(self.account, self.password, secrets.token_urlsafe(24))
|
||||||
with self.assertRaises(LDAPException):
|
with self.assertRaises(LDAPException):
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ urlpatterns = [
|
|||||||
path("oidc/callback/", views.callback, name="callback"),
|
path("oidc/callback/", views.callback, name="callback"),
|
||||||
path("logout/", views.logout, name="logout"),
|
path("logout/", views.logout, name="logout"),
|
||||||
path("profile/", views.profile, name="profile"),
|
path("profile/", views.profile, name="profile"),
|
||||||
path("profile/photo/", views.profile_photo, name="profile_photo"),
|
|
||||||
path("profile/email/verify/<str:token>/", views.verify_profile_email, name="verify_profile_email"),
|
path("profile/email/verify/<str:token>/", views.verify_profile_email, name="verify_profile_email"),
|
||||||
path("profile/email/confirm/", views.confirm_profile_email, name="confirm_profile_email"),
|
path("profile/email/confirm/", views.confirm_profile_email, name="confirm_profile_email"),
|
||||||
path("profile/newsletter/", views.newsletter_preference, name="newsletter"),
|
path("profile/newsletter/", views.newsletter_preference, name="newsletter"),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import stripe
|
|||||||
from authlib.integrations.django_client import OAuth
|
from authlib.integrations.django_client import OAuth
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import ProgrammingError, transaction
|
from django.db import ProgrammingError, transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -275,7 +274,7 @@ def logout(request):
|
|||||||
|
|
||||||
|
|
||||||
def _profile_context(account, values):
|
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()
|
@sensitive_post_parameters()
|
||||||
@@ -302,7 +301,7 @@ def profile(request):
|
|||||||
if action not in forms:
|
if action not in forms:
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
form_key, form_class = forms[action]
|
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
|
context[form_key] = form
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
@@ -336,32 +335,6 @@ def profile(request):
|
|||||||
return render(request, "profile.html", context)
|
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
|
@require_GET
|
||||||
@account_required
|
@account_required
|
||||||
def verify_profile_email(request, token):
|
def verify_profile_email(request, token):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<header class="profile-heading">
|
<header class="profile-heading">
|
||||||
<p class="eyebrow">One account. Your own identity.</p>
|
<p class="eyebrow">One account. Your own identity.</p>
|
||||||
<h1>Your profile</h1>
|
<h1>Your profile</h1>
|
||||||
<p>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.</p>
|
<p>Your eom.dev account is shared across every service you sign in to. Services see your username, name and email address.</p>
|
||||||
{% if account %}<p class="member-small">Signed in as <strong>{{ account.uid }}</strong>. Your username cannot be changed here.</p>{% endif %}
|
{% if account %}<p class="member-small">Signed in as <strong>{{ account.uid }}</strong>. Your username cannot be changed here.</p>{% endif %}
|
||||||
</header>
|
</header>
|
||||||
{% if saved %}<p class="member-notice" role="status">Your changes have been saved.</p>{% endif %}
|
{% if saved %}<p class="member-notice" role="status">Your changes have been saved.</p>{% endif %}
|
||||||
@@ -43,56 +43,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<nav class="profile-jump-links" aria-label="Profile sections">
|
<nav class="profile-jump-links" aria-label="Profile sections">
|
||||||
<a href="#identity">Identity</a><a href="#contact">Contact</a><a href="#addresses">Addresses</a><a href="#security">SSH keys</a><a href="#email">Email</a>{% if newsletter_status %}<a href="#newsletter">Newsletter</a>{% endif %}<a href="#password">Password</a>
|
<a href="#identity">Name</a><a href="#email">Email</a>{% if newsletter_status %}<a href="#newsletter">Newsletter</a>{% endif %}<a href="#password">Password</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form action="{% url 'members:profile' %}" method="post" enctype="multipart/form-data" class="profile-edit-form">
|
<form id="identity" action="{% url 'members:profile' %}" method="post" class="member-panel profile-section profile-edit-form" aria-labelledby="identity-heading">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="profile">
|
<input type="hidden" name="action" value="profile">
|
||||||
<input type="hidden" name="revision" value="{{ revision }}">
|
<input type="hidden" name="revision" value="{{ revision }}">
|
||||||
{% if form.errors %}<div class="profile-errors" role="alert"><h2>Check your profile details</h2><p>No profile changes have been saved. Correct the fields marked below and enter your current password again.</p>{{ form.non_field_errors }}</div>{% endif %}
|
<h2 id="identity-heading">Name</h2>
|
||||||
<fieldset id="identity" class="member-panel profile-section">
|
{% if form.errors %}<div class="profile-errors" role="alert"><p>Your name has not been saved. Correct the fields marked below and enter your current password again.</p>{{ form.non_field_errors }}</div>{% endif %}
|
||||||
<legend>Identity</legend>
|
{% include "profile_fields.html" with fields=form.identity_fields only %}
|
||||||
<p>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.</p>
|
{% include "profile_fields.html" with fields=form.authorization_fields only %}
|
||||||
{% include "profile_fields.html" with fields=form.identity_fields only %}
|
<button class="button button-primary" type="submit">Save name</button>
|
||||||
{% if has_photo %}<figure class="profile-photo"><img src="{% url 'members:profile_photo' %}" width="128" height="128" alt="Your current profile photo"><figcaption>Current profile photo</figcaption></figure>{% endif %}
|
|
||||||
<div class="profile-field">
|
|
||||||
{{ form.jpegPhoto.label_tag }}
|
|
||||||
<p class="helptext" id="{{ form.jpegPhoto.auto_id }}_helptext">{{ form.jpegPhoto.help_text }}</p>
|
|
||||||
{{ form.jpegPhoto }}
|
|
||||||
{% if form.jpegPhoto.errors %}<div id="{{ form.jpegPhoto.auto_id }}_error" role="alert">{{ form.jpegPhoto.errors }}</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="profile-field profile-checkbox">{{ form.remove_photo }} {{ form.remove_photo.label_tag }}{% if form.remove_photo.errors %}<div id="{{ form.remove_photo.auto_id }}_error" role="alert">{{ form.remove_photo.errors }}</div>{% endif %}</div>
|
|
||||||
<p class="member-small">Leave the photo field empty to keep your photo. To remove it, select the checkbox without uploading a replacement.</p>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset id="contact" class="member-panel profile-section">
|
|
||||||
<legend>Contact</legend>
|
|
||||||
<p>Choose the websites and phone numbers to share. Email changes are verified separately below.</p>
|
|
||||||
{% include "profile_fields.html" with fields=form.contact_fields only %}
|
|
||||||
</fieldset>
|
|
||||||
<fieldset id="addresses" class="member-panel profile-section">
|
|
||||||
<legend>Addresses</legend>
|
|
||||||
<p>Address details are optional. For postal addresses, use a blank line between entries; other fields use one value per line.</p>
|
|
||||||
{% include "profile_fields.html" with fields=form.address_fields only %}
|
|
||||||
</fieldset>
|
|
||||||
<fieldset id="security" class="member-panel profile-section">
|
|
||||||
<legend>SSH keys</legend>
|
|
||||||
<p>Share public keys only. Never paste a private key or a password here.</p>
|
|
||||||
<div class="profile-field">
|
|
||||||
{{ form.sshPublicKey.label_tag }}
|
|
||||||
<p class="helptext" id="{{ form.sshPublicKey.auto_id }}_helptext">{{ form.sshPublicKey.help_text }}</p>
|
|
||||||
{{ form.sshPublicKey }}
|
|
||||||
{% if form.sshPublicKey.errors %}<div id="{{ form.sshPublicKey.auto_id }}_error" role="alert">{{ form.sshPublicKey.errors }}</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="member-panel profile-section">
|
|
||||||
<legend>Authorize profile changes</legend>
|
|
||||||
{% include "profile_fields.html" with fields=form.authorization_fields only %}
|
|
||||||
<button class="button button-primary" type="submit">Save profile</button>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
</form>
|
||||||
<section id="email" class="member-panel profile-section" aria-labelledby="email-heading">
|
<section id="email" class="member-panel profile-section" aria-labelledby="email-heading">
|
||||||
<h2 id="email-heading">Email address</h2>
|
<h2 id="email-heading">Email address</h2>
|
||||||
<p>Currently stored email addresses (read-only):</p>
|
<p>Current email address:</p>
|
||||||
<ul class="profile-email-values">{% for email in email_values %}<li>{{ email }}</li>{% empty %}<li>No email address stored.</li>{% endfor %}</ul>
|
<ul class="profile-email-values">{% for email in email_values %}<li>{{ email }}</li>{% empty %}<li>No email address stored.</li>{% endfor %}</ul>
|
||||||
<p>A verified change replaces <strong>all</strong> 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.</p>
|
<p>A verified change replaces <strong>all</strong> 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.</p>
|
||||||
<form action="{% url 'members:profile' %}" method="post">
|
<form action="{% url 'members:profile' %}" method="post">
|
||||||
|
|||||||
Reference in New Issue
Block a user