This commit is contained in:
2026-10-29 13:28:11 -04:00
parent 7ec64194e5
commit 0bffd7d993
19 changed files with 1750 additions and 7 deletions
+18 -1
View File
@@ -10,6 +10,7 @@
"test",
"members.tests.test_membership",
"members.tests.test_oidc",
"members.tests.test_profiles",
"members.tests.test_postgres",
"members.tests.test_shkeeper",
"--settings=config.test_settings",
@@ -28,6 +29,7 @@
"test",
"members.tests.test_membership",
"members.tests.test_oidc",
"members.tests.test_profiles",
"members.tests.test_postgres",
"members.tests.test_shkeeper",
"--settings=config.test_settings",
@@ -118,6 +120,21 @@
"isBackground": false,
"problemMatcher": [],
"group": "test"
},
{
"label": "Test membership profiles",
"type": "shell",
"command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe",
"args": [
"${workspaceFolder}/membership/manage.py",
"test",
"members.tests.test_profiles",
"--settings=config.test_settings",
"--verbosity=1"
],
"isBackground": false,
"problemMatcher": [],
"group": "test"
}
]
}
}
+97
View File
@@ -210,6 +210,103 @@ but membership no longer checks directory recreation during login. Preserve the
connector mapping for other services; migrating Dex to immutable user IDs is a
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.
| 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) |
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.
**Directory prerequisites:**
- The service account needs read/search access to the profile attributes above,
plus `uid`, `entryUUID`, `objectClass` 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`,
`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
control must fail closed; do not silently retry without it.
- Maintain directory-side unique `mail` and `uid` enforcement across all
registrars. A client-side email preflight cannot prevent concurrent claims
and may be restricted by self-read ACLs; the server uniqueness overlay is
required. Review which services use `mail` as an account/recovery identifier.
Email is changed separately: the user enters their current password, receives
a one-hour verification link at the new mailbox, then signs into the same local
account and enters their password again. GET/scanner visits never modify LDAP.
Tokens are stored only as hashes, bound to account/LDAP UUID, and consumed once.
Confirming replaces **all existing mail values** with the verified address and
updates the local contact address; it does not attach another user's billing
history. Old Dex/downstream tokens may retain the prior email until refreshed.
Password changes send current/new passwords through Password Modify as the
authenticated user, never a service-account reset or direct `userPassword`
write. **Hashing remains OpenLDAP's policy**: no MD5-Crypt hash is generated by
the application, no stored hash is exposed, and the application does not force
or silently change the server's scheme. A legacy MD5-Crypt directory can still
verify current passwords; do not introduce MD5-Crypt as a new application-side
hashing scheme. Audit/migrate weak server hashing separately. The existing
Argon2 gate for new registration remains unchanged. Password changes sign out
the current membership browser; other membership/Dex/service sessions are not
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
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.
### Database ProgrammingError during callback
`Dex callback rejected kind=ProgrammingError` in older images is a database
+4
View File
@@ -251,6 +251,10 @@ ProxyPassReverse "/members/" "http://membership:8000/members/"
RequestHeader set X-Membership-Client-IP "expr=%{REMOTE_ADDR}"
LimitRequestBody 262144
</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
+30
View File
@@ -40,3 +40,33 @@ button.button { cursor: pointer; font-size: inherit; line-height: inherit; }
.member-faq details p { color: var(--muted); margin: 1rem 0 .5rem; }
.member-footer { padding-block: 2rem; border-top: 1px solid var(--border); }
@media (max-width: 540px) { .member-nav, .member-main, .member-footer { width: calc(100% - 2rem); } .member-nav nav { gap: .8rem; font-size: .85rem; } .member-panel { padding: 1.4rem; } .member-actions { align-items: stretch; flex-direction: column; } .member-actions .button { width: 100%; } }
/* Shared-account profile: use the home page's Catppuccin theme variables. */
.profile-main { max-width: 880px; }
.profile-heading { padding-block: clamp(2rem, 6vw, 4rem) 1rem; }
.profile-heading h1 { font-size: clamp(2.5rem, 6vw, 4rem); }
.profile-heading > p { max-width: 70ch; }
.profile-jump-links { display: flex; flex-wrap: wrap; gap: .6rem; margin-block: 1rem 2rem; }
.profile-jump-links a { padding: .45rem .85rem; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); text-decoration: none; }
.profile-section { min-width: 0; margin: 0 0 1.5rem; scroll-margin-top: 2rem; }
.profile-section legend { padding-inline: .5rem; color: var(--accent); font-size: 1.35rem; font-weight: 700; }
.profile-section h2, .profile-errors h2 { font-size: clamp(1.5rem, 3vw, 2rem); }
.profile-field { min-width: 0; margin-block: 0 1.5rem; }
.profile-field label { display: inline-block; margin-bottom: .35rem; font-weight: 650; }
.profile-required { color: var(--muted); font-size: .85rem; }
.profile-field input:not([type="checkbox"]), .profile-field textarea { display: block; width: 100%; min-width: 0; padding: .8rem 1rem; border: 1px solid var(--border); border-radius: .65rem; background: var(--page-deep); color: var(--text); font: inherit; }
.profile-field textarea { min-height: 7rem; resize: vertical; line-height: 1.5; }
.profile-field input:focus-visible, .profile-field textarea:focus-visible, .profile-section button:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.profile-field .helptext { margin-block: 0 .6rem; color: var(--muted); font-size: .9rem; }
.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; } }
@media (prefers-reduced-motion: reduce) { html:has(.profile-main) { scroll-behavior: auto; } }
+2
View File
@@ -63,6 +63,8 @@ SECURE_HSTS_SECONDS = 31536000 if not ALLOW_HTTP else 0
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
DATA_UPLOAD_MAX_MEMORY_SIZE = 262144
FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
DATA_UPLOAD_MAX_NUMBER_FILES = 1
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 12}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+10 -2
View File
@@ -21,11 +21,17 @@ class LDAPServiceBindError(LDAPException):
@contextmanager
def connection():
with _connection(settings.LDAP_BIND_DN, settings.LDAP_BIND_PASSWORD, service=True) as conn:
yield conn
@contextmanager
def _connection(bind_dn, password, *, service=False):
url = urlsplit(settings.LDAP_URL)
if url.scheme not in {"ldap", "ldaps"} or not url.hostname or not settings.LDAP_BIND_DN or not settings.LDAP_BIND_PASSWORD or not settings.LDAP_ACCOUNT_BASE_DN:
if url.scheme not in {"ldap", "ldaps"} or not url.hostname or not bind_dn or not password or not settings.LDAP_ACCOUNT_BASE_DN:
raise ImproperlyConfigured("Configure LDAP URL, service bind, and account base DN.")
server = Server(url.hostname, port=url.port or (636 if url.scheme == "ldaps" else 389), use_ssl=url.scheme == "ldaps", tls=Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=settings.LDAP_CA_CERT_FILE, version=ssl.PROTOCOL_TLS_CLIENT), connect_timeout=5)
conn = Connection(server, user=settings.LDAP_BIND_DN, password=settings.LDAP_BIND_PASSWORD, raise_exceptions=True, receive_timeout=8, auto_referrals=False)
conn = Connection(server, user=bind_dn, password=password, raise_exceptions=True, receive_timeout=8, auto_referrals=False)
try:
conn.open()
# Plaintext requires explicit operator consent; LDAPS is never downgraded.
@@ -34,6 +40,8 @@ def connection():
try:
bound = conn.bind()
except LDAPInvalidCredentialsResult:
if not service:
raise ValueError("Current password could not be verified.") from None
# Do not expose server diagnostics or retry with a user's password.
raise LDAPServiceBindError("Check LDAP_BIND_DN and LDAP_BIND_PASSWORD: use the service account's plaintext password, not its stored hash.") from None
if not bound:
@@ -7,7 +7,7 @@ from django.contrib.sessions.models import Session
from django.utils import timezone
from members.billing import sync_account
from members.models import Account, RateWindow, Registration, WebhookEvent
from members.models import Account, ProfileEmailChange, RateWindow, Registration, WebhookEvent
logger = logging.getLogger(__name__)
@@ -44,6 +44,7 @@ class Command(BaseCommand):
now = timezone.now()
Session.objects.filter(expire_date__lte=now).delete()
Registration.objects.filter(expires_at__lte=now).delete()
ProfileEmailChange.objects.filter(expires_at__lte=now).delete()
RateWindow.objects.filter(expires_at__lte=now).delete()
WebhookEvent.objects.filter(processed_at__lt=now - timedelta(days=30)).delete()
if not options["loop"]:
+15
View File
@@ -0,0 +1,15 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("members", "0004_dex_login_identity")]
operations = [
migrations.CreateModel(name="ProfileEmailChange", fields=[
("token_hash", models.CharField(max_length=64, primary_key=True, serialize=False)),
("email", models.EmailField(max_length=254)),
("ldap_uuid", models.CharField(max_length=64)),
("expires_at", models.DateTimeField(db_index=True)),
("account", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="members.account")),
]),
]
+8
View File
@@ -28,6 +28,14 @@ class Registration(models.Model):
expires_at = models.DateTimeField(db_index=True)
class ProfileEmailChange(models.Model):
token_hash = models.CharField(max_length=64, primary_key=True)
account = models.ForeignKey(Account, on_delete=models.CASCADE)
email = models.EmailField()
ldap_uuid = models.CharField(max_length=64)
expires_at = models.DateTimeField(db_index=True)
class RateWindow(models.Model):
key = models.CharField(max_length=64, primary_key=True)
count = models.PositiveIntegerField(default=0)
+236
View File
@@ -0,0 +1,236 @@
"""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:
# Normalize browser line endings before removing unsafe control characters.
value = value.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
return "".join(
char for char in value
if char == "\n" or unicodedata.category(char) not in {"Cc", "Cf", "Cs"}
)
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):
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.")
super().__init__(
max_length=MAX_INPUT_LENGTH,
widget=forms.Textarea(attrs={"rows": 3, "maxlength": MAX_INPUT_LENGTH}),
**kwargs,
)
def clean(self, value) -> list[str]:
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()]
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):
raise ValidationError(f"Each value must be at most {self.value_length:,} characters.")
if self.required and not entries:
raise ValidationError("Enter at least one value.")
return list(dict.fromkeys(entries))
def _current_password_field() -> forms.CharField:
return forms.CharField(
label="Current password", max_length=1024, strip=False,
widget=forms.PasswordInput(
render_value=False, attrs={"autocomplete": "current-password"}
),
help_text="Enter your current eom.dev password to authorize this change.",
)
class CurrentPasswordForm(forms.Form):
current_password = _current_password_field()
def __init__(self, *args, **kwargs):
kwargs.setdefault("auto_id", "id_confirmation_%s")
super().__init__(*args, **kwargs)
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.",
)
remove_photo = forms.BooleanField(label="Remove current photo", required=False)
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)
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")]
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"}))
field_order = ["email", "current_password"]
def __init__(self, *args, **kwargs):
kwargs.setdefault("auto_id", "id_email_%s")
super().__init__(*args, **kwargs)
def clean_email(self) -> str:
return self.cleaned_data["email"].casefold()
class ProfilePasswordForm(PasswordForm, CurrentPasswordForm):
field_order = ["current_password", "password", "confirm_password"]
def __init__(self, *args, **kwargs):
kwargs.setdefault("auto_id", "id_password_%s")
super().__init__(*args, **kwargs)
for name in ("password", "confirm_password"):
self.fields[name].strip = False
self.fields[name].widget = forms.PasswordInput(
render_value=False, attrs={"autocomplete": "new-password"}
)
+161
View File
@@ -0,0 +1,161 @@
"""Self-service directory operations: pinned identity, user binds, fixed attributes.
Dex authenticates the session; LDAP remains authoritative for editable values.
No service-account writes, password hashes, tokens or profile snapshots are stored.
"""
import hashlib
import json
import re
from contextlib import contextmanager
from django.conf import settings
from django.core import signing
from django.db import transaction
from ldap3 import BASE, MODIFY_ADD, MODIFY_REPLACE
from ldap3.core.exceptions import LDAPException
from ldap3.operation.search import compile_filter, parse_filter
from ldap3.protocol.controls import build_control
from ldap3.utils.conv import escape_filter_chars
from . import directory
from .models import Account
TEXT_ATTRIBUTES = (
"description", "displayName", "givenName", "l", "labeledURI", "mobile", "o",
"postalAddress", "postalCode", "roomNumber", "sn", "sshPublicKey", "st",
"street", "telephoneNumber", "title",
)
READ_ATTRIBUTES = (*TEXT_ATTRIBUTES, "mail", "jpegPhoto", "entryUUID", "uid", "modifyTimestamp", "objectClass")
def _values(entry):
values = {name: list(entry[name].values) if name in entry else [] for name in READ_ATTRIBUTES}
# Schema-aware ldap3 may convert this to datetime; assertion filters need
# the original LDAP GeneralizedTime syntax rather than Python's rendering.
if "modifyTimestamp" in entry:
values["modifyTimestamp"] = [value.decode("ascii") for value in entry["modifyTimestamp"].raw_values]
if len(values["entryUUID"]) != 1 or len(values["uid"]) != 1:
raise ValueError("A unique directory identity is required.")
return values
def _assertion(values):
# Critical RFC 4528 assertion: concurrent edits or a recreated DN fail
# atomically rather than modifying the wrong entry or overwriting new data.
if len(values["modifyTimestamp"]) != 1:
raise ValueError("Read access to modifyTimestamp is required for safe profile edits.")
query = f"(entryUUID={escape_filter_chars(str(values['entryUUID'][0]))})"
query = f"(&{query}(modifyTimestamp={escape_filter_chars(str(values['modifyTimestamp'][0]))}))"
node = parse_filter(query, None, False, False, None, False)
return build_control("1.3.6.1.1.12", True, compile_filter(node.elements[0]))
def _digest(values):
def encoded(value):
return hashlib.sha256(value).hexdigest() if isinstance(value, bytes) else str(value)
return hashlib.sha256(json.dumps({key: [encoded(v) for v in values[key]] for key in READ_ATTRIBUTES}, sort_keys=True).encode()).hexdigest()
def read_profile(account):
"""Resolve once from the signed uid, then pin UUID; never accept a browser DN."""
with transaction.atomic():
member = Account.objects.select_for_update().get(pk=account.pk)
if not member.oidc_sub or member.oidc_issuer != settings.DEX_ISSUER:
raise ValueError("A verified Dex identity is required.")
with directory.connection() as conn:
query = f"(entryUUID={escape_filter_chars(member.ldap_uuid)})" if member.ldap_uuid else f"(&(objectClass=inetOrgPerson)(uid={escape_filter_chars(member.uid)}))"
conn.search(settings.LDAP_SEARCH_BASE_DN, query, attributes=list(READ_ATTRIBUTES))
if len(conn.entries) != 1:
raise ValueError("Profile is missing or ambiguous.")
entry = conn.entries[0]
values = _values(entry)
entry_uuid = str(values["entryUUID"][0])
if str(values["uid"][0]) != member.uid or not entry_uuid or entry_uuid == "None" or len(entry_uuid) > 64:
raise ValueError("Directory identity does not match the account.")
if Account.objects.filter(ldap_uuid=entry_uuid).exclude(pk=member.pk).exists():
raise ValueError("Directory identity belongs to another account.")
if entry.entry_dn.casefold() == settings.LDAP_GROUP_PLACEHOLDER_DN.casefold():
raise ValueError("System entries cannot be edited.")
member.ldap_uuid, member.ldap_dn = entry_uuid, entry.entry_dn
member.save(update_fields=["ldap_uuid", "ldap_dn"])
account.ldap_uuid, account.ldap_dn = member.ldap_uuid, member.ldap_dn
return values
def revision(account, values):
return signing.dumps({"account": str(account.pk), "uuid": account.ldap_uuid, "digest": _digest(values)}, salt="ldap-profile")
def initial(values):
def postal(value):
# Decode once: a literal '\24' represented by '\5c24' must not become '$'.
return re.sub(r"\\(24|5[cC])", lambda match: "$" if match.group(1) == "24" else "\\", str(value).replace("$", "\n"))
return {name: ("\n\n".join(postal(value) for value in values[name]) if name == "postalAddress" else "\n".join(str(value) for value in values[name])) for name in TEXT_ATTRIBUTES}
@contextmanager
def _authorized(account, password):
if not password or not account.ldap_uuid or not account.ldap_dn:
raise ValueError("A pinned directory entry and current password are required.")
# Authenticate as this user, not as the service identity. slapd self ACLs
# authorize each modification; a compromised service cannot reset passwords.
with directory._connection(account.ldap_dn, password) as conn:
conn.search(account.ldap_dn, f"(entryUUID={escape_filter_chars(account.ldap_uuid)})", search_scope=BASE, attributes=list(READ_ATTRIBUTES))
if len(conn.entries) != 1:
raise ValueError("Directory identity changed; refusing to edit.")
values = _values(conn.entries[0])
if str(values["entryUUID"][0]) != account.ldap_uuid or str(values["uid"][0]) != account.uid:
raise ValueError("Directory identity changed; refusing to edit.")
yield conn, values
def verify_password(account, password):
with _authorized(account, password):
pass
def update_profile(account, cleaned, expected_revision):
proof = signing.loads(expected_revision, salt="ldap-profile", max_age=3600)
with _authorized(account, cleaned["current_password"]) as (conn, values):
if proof != {"account": str(account.pk), "uuid": account.ldap_uuid, "digest": _digest(values)}:
raise ValueError("Your profile changed. Reload it before saving.")
changes = {}
for name in TEXT_ATTRIBUTES:
new_values = cleaned[name]
if new_values != values[name]:
changes[name] = [(MODIFY_REPLACE, new_values)]
if cleaned["sshPublicKey"] and "ldappublickey" not in {str(value).casefold() for value in values["objectClass"]}:
# Fixed auxiliary class only, never arbitrary browser-supplied
# schema/classes. Install the SSH schema before enabling edits.
changes["objectClass"] = [(MODIFY_ADD, ["ldapPublicKey"])]
photo = cleaned.get("jpegPhoto")
if photo is not None:
changes["jpegPhoto"] = [(MODIFY_REPLACE, [photo])]
elif cleaned.get("remove_photo") and values["jpegPhoto"]:
changes["jpegPhoto"] = [(MODIFY_REPLACE, [])]
if changes and not conn.modify(account.ldap_dn, changes, controls=[_assertion(values)]):
raise LDAPException("Profile modification rejected.")
def change_password(account, current_password, new_password):
with _authorized(account, current_password) as (conn, _values):
# RFC 3062 delegates hashing to slapd (including existing server policies).
# No client-side MD5-Crypt, plaintext attribute write or admin reset.
# No userIdentity targets: operate on the authenticated user. RFC 4528
# assertions are not defined for this extended operation; supplying a
# critical assertion here can make slapd reject an otherwise valid change.
if not conn.extend.standard.modify_password(old_password=current_password, new_password=new_password):
raise LDAPException("Password modification rejected.")
def change_email(account, current_password, email):
with _authorized(account, current_password) as (conn, values):
# Enforce directory-side mail uniqueness too: only an LDAP uniqueness
# overlay can prevent another registrar racing this preflight search.
conn.search(settings.LDAP_SEARCH_BASE_DN, f"(mail={escape_filter_chars(email)})", attributes=["entryUUID"])
if any(str(entry.entryUUID.value) != account.ldap_uuid for entry in conn.entries):
raise ValueError("Email change cannot be completed.")
if not conn.modify(account.ldap_dn, {"mail": [(MODIFY_REPLACE, [email])]}, controls=[_assertion(values)]):
raise LDAPException("Email modification rejected.")
+2 -1
View File
@@ -715,6 +715,7 @@ class DexIdentityMigrationTests(TransactionTestCase):
previous = [("members", "0003_membership_levels")]
current = [("members", "0004_dex_login_identity")]
executor = MigrationExecutor(connection)
latest = executor.loader.graph.leaf_nodes()
executor.migrate(previous)
try:
OldAccount = executor.loader.project_state(previous).apps.get_model("members", "Account")
@@ -733,4 +734,4 @@ class DexIdentityMigrationTests(TransactionTestCase):
new = NewAccount.objects.create(uid=uid, email=f"{uid}@example.org", oidc_issuer=settings.DEX_ISSUER, oidc_sub=f"dex-{uid}")
self.assertIsNone(NewAccount.objects.values_list("ldap_uuid", flat=True).get(pk=new.pk))
finally:
MigrationExecutor(connection).migrate(current)
MigrationExecutor(connection).migrate(latest)
+892
View File
@@ -0,0 +1,892 @@
"""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
ephemeral test data, not service credentials. Directory writes use mocks; LDAP
reads additionally exercise ldap3's in-memory strategy.
"""
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.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,
ProfilePasswordForm,
ProfileValuesField,
)
ALICE_DN = "uid=alice,ou=people,dc=example,dc=org"
SEARCH_DN = "ou=people,dc=example,dc=org"
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"],
"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 test_friendly_labels_and_single_gamer_tag(self):
form = self.form({"displayName": "AliceInGame"})
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)
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_field_groups_and_unique_password_ids(self):
form = self.form()
grouped = [
*form.identity_fields(), *form.contact_fields(),
*form.address_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,
)]
self.assertEqual(len(set(ids)), 4)
def test_multivalues_deduplicate_and_strip_control_characters(self):
field = ProfileValuesField()
self.assertEqual(field.clean(" Alice\r\nBob\rAlice\n\tCarol\x00\u200b "), ["Alice", "Bob", "Carol"])
self.assertEqual(field.clean(None), [])
self.assertEqual(field.clean(" \n "), [])
def test_value_count_length_and_total_input_limits(self):
field = ProfileValuesField(value_length=3)
self.assertEqual(field.clean("abc"), ["abc"])
for value in ("abcd", "\n".join(str(i) for i in range(17)), "x" * (MAX_INPUT_LENGTH + 1)):
with self.subTest(length=len(value)), self.assertRaises(ValidationError):
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})
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)
def test_current_and_new_password_whitespace_preserved_not_rendered(self):
new_password = " " + secrets.token_urlsafe(24) + " "
form = ProfilePasswordForm(data={
"current_password": self.password,
"password": new_password, "confirm_password": new_password,
})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data["current_password"], self.password)
self.assertEqual(form.cleaned_data["password"], new_password)
self.assertEqual(form.cleaned_data["confirm_password"], new_password)
self.assertNotIn(self.password, form.as_p())
self.assertNotIn(new_password, form.as_p())
def test_password_required_length_and_confirmation(self):
for data, field in (
({"current_password": ""}, "current_password"),
({"current_password": "x" * 1025}, "current_password"),
):
with self.subTest(field=field, length=len(data[field])):
form = CurrentPasswordForm(data=data)
self.assertFalse(form.is_valid())
self.assertIn(field, form.errors)
for password, confirm in (("short", "short"), (self.password, self.password.strip())):
form = ProfilePasswordForm(data={"current_password": self.password, "password": password, "confirm_password": confirm})
self.assertFalse(form.is_valid())
def test_email_casefold_and_current_password_required(self):
form = ProfileEmailForm(data={"email": "Alice.New@EXAMPLE.ORG", "current_password": self.password})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data["email"], "alice.new@example.org")
form = ProfileEmailForm(data={"email": "alice.new@example.org"})
self.assertFalse(form.is_valid())
self.assertIn("current_password", form.errors)
class ProfileViewTests(TestCase):
def setUp(self):
self.account = Account.objects.create(
uid="alice", email="alice@example.org", oidc_issuer=settings.DEX_ISSUER,
oidc_sub="sub-alice", ldap_uuid="uuid-alice", ldap_dn=ALICE_DN,
)
self.password = " " + secrets.token_urlsafe(24) + " "
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)
self.update = self.start_patch("members.views.profiles.update_profile")
self.change_password = self.start_patch("members.views.profiles.change_password")
self.verify_password = self.start_patch("members.views.profiles.verify_password")
self.change_email = self.start_patch("members.views.profiles.change_email")
self.start_patch("members.directory.connection", side_effect=AssertionError("Offline test attempted service LDAP"))
self.start_patch("members.directory._connection", side_effect=AssertionError("Offline test attempted user LDAP"))
def start_patch(self, name, **kwargs):
patcher = patch(name, **kwargs)
mock = patcher.start()
self.addCleanup(patcher.stop)
return mock
def sign_in(self, client, account):
session = client.session
session["account_id"] = str(account.pk)
session.save()
def read_profile(self, account):
self.assertEqual(account.pk, self.account.pk)
saved = Account.objects.get(pk=account.pk)
self.assertEqual((account.uid, account.ldap_uuid, account.ldap_dn), (saved.uid, "uuid-alice", ALICE_DN))
return copy.deepcopy(self.values)
def profile_data(self, **overrides):
return {
**profiles.initial(self.values), "action": "profile",
"revision": profiles.revision(self.account, self.values),
"current_password": self.password, **overrides,
}
def assert_no_writes(self):
for operation in (self.update, self.change_password, self.verify_password, self.change_email):
operation.assert_not_called()
def pending(self, *, account=None, expires_at=None, ldap_uuid="uuid-alice"):
token = secrets.token_urlsafe(32)
pending = ProfileEmailChange.objects.create(
token_hash=hashlib.sha256(token.encode()).hexdigest(),
account=account or self.account, email="alice.new@example.org",
ldap_uuid=ldap_uuid, expires_at=expires_at or timezone.now() + timedelta(hours=1),
)
return token, pending
def exchange(self, token):
return self.client.get(reverse("members:verify_profile_email", args=[token]))
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)]))
for url in urls:
with self.subTest(url=url):
self.assertRedirects(client.get(url), reverse("members:home"), fetch_redirect_response=False)
self.read.assert_not_called()
self.assert_no_writes()
def test_get_loads_full_profile_and_revision_for_signed_in_account_only(self):
response = self.client.get(self.profile_url, {"uid": "mallory", "dn": "cn=admin", "account_id": str(secrets.token_hex(16))})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["profile_loaded"])
self.assertEqual(response.context["email_values"], self.values["mail"])
self.assertEqual(response.context["form"].initial, profiles.initial(self.values))
proof = signing.loads(response.context["revision"], salt="ldap-profile", max_age=3600)
self.assertEqual(proof["account"], str(self.account.pk))
self.assertEqual(proof["uuid"], "uuid-alice")
self.assertContains(response, 'name="csrfmiddlewaretoken"')
self.assert_no_writes()
def test_csrf_required_for_each_mutating_action_and_email_confirmation(self):
client = Client(enforce_csrf_checks=True)
self.sign_in(client, self.account)
token, pending = self.pending()
self.assertEqual(client.get(reverse("members:verify_profile_email", args=[token])).status_code, 302)
for action in ("profile", "email", "password"):
with self.subTest(action=action):
self.assertEqual(client.post(self.profile_url, self.profile_data(action=action)).status_code, 403)
self.assertEqual(client.post(self.confirm_url, {"current_password": self.password}).status_code, 403)
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
self.assert_no_writes()
# A real cookie/token pair passes middleware; unknown action reaches 400.
client.get(self.profile_url)
csrf = client.cookies[settings.CSRF_COOKIE_NAME].value
self.assertEqual(client.post(self.profile_url, {"action": "unknown", "csrfmiddlewaretoken": csrf}).status_code, 400)
def test_unknown_or_missing_action_is_bad_request(self):
for data in ({}, {"action": "delete"}, {"action": "userPassword"}):
with self.subTest(data=data):
self.assertEqual(self.client.post(self.profile_url, data).status_code, 400)
self.assert_no_writes()
def test_load_failure_disables_all_editing_get_and_post_and_redacts_logs(self):
self.read.side_effect = LDAPException(self.password)
with self.assertLogs("members.views", level="ERROR") as captured:
for method in (self.client.get, self.client.post):
response = method(self.profile_url, self.profile_data())
self.assertEqual(response.status_code, 503)
self.assertFalse(response.context["profile_loaded"])
self.assertNotContains(response, "<form", status_code=503)
for key in ("form", "email_form", "password_form", "revision"):
self.assertNotIn(key, response.context.flatten())
self.assertNotIn(self.password, "\n".join(captured.output))
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"))
self.assertEqual(response.status_code, 200)
self.assertIn("sn", response.context["form"].errors)
self.assertEqual(response.context["revision"], "original-revision")
self.assertNotContains(response, self.password)
self.assert_no_writes()
def test_invalid_email_and_password_do_not_authorize_or_write(self):
for data, key in (
({"action": "email", "email": "not-email", "current_password": self.password}, "email_form"),
({"action": "email", "email": "new@example.org"}, "email_form"),
({"action": "password", "current_password": self.password, "password": "short", "confirm_password": "short"}, "password_form"),
):
with self.subTest(action=data["action"]):
response = self.client.post(self.profile_url, data)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context[key].errors)
self.assert_no_writes()
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")
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["current_password"], self.password)
for name in ("uid", "dn", "ldap_uuid", "userPassword", "mail"):
self.assertNotIn(name, cleaned)
self.assertNotIn(self.password, "\n".join(captured.output))
self.account.refresh_from_db()
self.assertEqual(self.account.uid, "alice")
self.assertEqual(self.account.email, "alice@example.org")
def test_profile_write_failure_is_generic_and_password_never_logged(self):
self.update.side_effect = LDAPException("private server diagnostic " + self.password)
with self.assertLogs("members.views", level="ERROR") as captured:
response = self.client.post(self.profile_url, self.profile_data())
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].non_field_errors())
output = "\n".join(captured.output)
self.assertIn("kind=LDAPException", output)
self.assertNotIn(self.password, output)
self.assertNotIn("private server diagnostic", output)
self.assertNotContains(response, self.password)
def test_password_change_uses_current_password_then_flushes_local_session(self):
new_password = " " + secrets.token_urlsafe(24) + " "
old_key = self.client.session.session_key
with self.assertLogs("members.views", level="INFO") as captured:
response = self.client.post(self.profile_url, {
"action": "password", "current_password": self.password,
"password": new_password, "confirm_password": new_password,
"uid": "mallory", "dn": "cn=admin",
})
self.assertEqual(response.status_code, 200)
account, current, new = self.change_password.call_args.args
self.assertEqual(account.pk, self.account.pk)
self.assertEqual((current, new), (self.password, new_password))
self.assertNotIn("account_id", self.client.session)
self.assertNotEqual(self.client.session.session_key, old_key)
from django.contrib.sessions.models import Session
self.assertFalse(Session.objects.filter(session_key=old_key).exists())
for secret in (self.password, new_password):
self.assertNotIn(secret, "\n".join(captured.output))
self.assertNotContains(response, secret)
self.update.assert_not_called()
self.change_email.assert_not_called()
def test_failed_password_change_retains_session(self):
self.change_password.side_effect = ValueError(self.password)
new_password = secrets.token_urlsafe(24)
with self.assertLogs("members.views", level="ERROR") as captured:
response = self.client.post(self.profile_url, {
"action": "password", "current_password": self.password,
"password": new_password, "confirm_password": new_password,
})
self.assertTrue(response.context["password_form"].non_field_errors())
self.assertEqual(self.client.session["account_id"], str(self.account.pk))
self.assertNotIn(self.password, "\n".join(captured.output))
def test_email_request_verifies_password_stores_only_hash_and_sends_link_not_write(self):
before = timezone.now()
response = self.client.post(self.profile_url, {"action": "email", "email": "Alice.New@EXAMPLE.ORG", "current_password": self.password})
self.assertRedirects(response, self.profile_url + "?email_sent=1", fetch_redirect_response=False)
account, password = self.verify_password.call_args.args
self.assertEqual(account.pk, self.account.pk)
self.assertEqual(password, self.password)
pending = ProfileEmailChange.objects.get(account=self.account)
self.assertEqual((pending.email, pending.ldap_uuid), ("alice.new@example.org", "uuid-alice"))
self.assertGreaterEqual(pending.expires_at, before + timedelta(hours=1))
self.assertLessEqual(pending.expires_at, timezone.now() + timedelta(hours=1))
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.to, ["alice.new@example.org"])
link = next(line for line in message.body.splitlines() if line.startswith(settings.PUBLIC_URL))
token = resolve(urlsplit(link).path).kwargs["token"]
self.assertEqual(pending.token_hash, hashlib.sha256(token.encode()).hexdigest())
self.assertNotEqual(pending.token_hash, token)
self.assertNotIn(token, repr(list(ProfileEmailChange.objects.values())))
self.assertNotIn(self.password, message.body)
self.account.refresh_from_db()
self.assertEqual(self.account.email, "alice@example.org")
self.change_email.assert_not_called()
self.update.assert_not_called()
def test_new_email_request_replaces_previous_pending_token(self):
_, old = self.pending()
self.client.post(self.profile_url, {"action": "email", "email": "next@example.org", "current_password": self.password})
self.assertFalse(ProfileEmailChange.objects.filter(pk=old.pk).exists())
self.assertEqual(ProfileEmailChange.objects.get().email, "next@example.org")
def test_bad_current_password_does_not_create_email_token_or_send_mail(self):
self.verify_password.side_effect = ValueError(self.password)
with patch("members.views.send_mail") as send, self.assertLogs("members.views", level="ERROR"):
response = self.client.post(self.profile_url, {"action": "email", "email": "new@example.org", "current_password": self.password})
self.assertTrue(response.context["email_form"].non_field_errors())
self.assertFalse(ProfileEmailChange.objects.exists())
send.assert_not_called()
self.change_email.assert_not_called()
def test_smtp_failure_rolls_back_new_token_and_redacts_exception(self):
with patch("members.views.send_mail", side_effect=OSError(self.password)), self.assertLogs("members.views", level="ERROR") as captured:
response = self.client.post(self.profile_url, {"action": "email", "email": "new@example.org", "current_password": self.password})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["email_form"].non_field_errors())
self.assertFalse(ProfileEmailChange.objects.exists())
self.assertNotIn(self.password, "\n".join(captured.output))
self.change_email.assert_not_called()
def test_email_token_get_only_exchanges_for_server_side_hash(self):
token, pending = self.pending()
response = self.exchange(token)
self.assertRedirects(response, self.confirm_url, fetch_redirect_response=False)
self.assertNotIn(token, response.url)
self.assertEqual(self.client.session["profile_email_hash"], pending.pk)
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
self.assert_no_writes()
response = self.client.get(self.confirm_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, pending.email)
self.assert_no_writes()
def test_confirmation_requires_current_password_and_consumes_token_once(self):
token, pending = self.pending()
self.exchange(token)
response = self.client.post(self.confirm_url, {})
self.assertIn("current_password", response.context["confirmation_form"].errors)
self.change_email.assert_not_called()
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
old_key = self.client.session.session_key
response = self.client.post(self.confirm_url, {"current_password": self.password, "email": "attacker@example.org", "uid": "mallory"})
self.assertRedirects(response, self.profile_url + "?saved=1", fetch_redirect_response=False)
account, password, email = self.change_email.call_args.args
self.assertEqual((account.pk, account.ldap_uuid, password, email), (self.account.pk, "uuid-alice", self.password, pending.email))
self.account.refresh_from_db()
self.assertEqual(self.account.email, pending.email)
self.assertFalse(ProfileEmailChange.objects.filter(account=self.account).exists())
self.assertNotIn("profile_email_hash", self.client.session)
self.assertEqual(self.client.session["account_id"], str(self.account.pk))
self.assertNotEqual(self.client.session.session_key, old_key)
self.assertContains(self.exchange(token), "Link unavailable")
self.assertContains(self.client.post(self.confirm_url, {"current_password": self.password}), "Link unavailable")
self.change_email.assert_called_once()
def test_confirmation_rejects_another_signed_in_account_even_with_session_hash(self):
token, pending = self.pending()
bob = Account.objects.create(uid="bob", email="bob@example.org", oidc_issuer=settings.DEX_ISSUER, oidc_sub="sub-bob", ldap_uuid="uuid-bob", ldap_dn="uid=bob," + SEARCH_DN)
self.sign_in(self.client, bob)
self.assertContains(self.exchange(token), "Link unavailable")
session = self.client.session
session["profile_email_hash"] = pending.pk
session.save()
self.assertContains(self.client.post(self.confirm_url, {"current_password": self.password}), "Link unavailable")
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
self.read.assert_not_called()
self.assert_no_writes()
def test_expired_unknown_and_expired_after_exchange_tokens_cannot_write(self):
expired_token, _ = self.pending(expires_at=timezone.now() - timedelta(seconds=1))
self.assertContains(self.exchange(expired_token), "Link unavailable")
self.assertContains(self.exchange(secrets.token_urlsafe(32)), "Link unavailable")
token, pending = self.pending()
self.exchange(token)
ProfileEmailChange.objects.filter(pk=pending.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
self.assertContains(self.client.post(self.confirm_url, {"current_password": self.password}), "Link unavailable")
self.assert_no_writes()
def test_confirmation_rejects_changed_uuid_and_keeps_old_email(self):
token, pending = self.pending(ldap_uuid="uuid-before-recreation")
self.exchange(token)
with self.assertLogs("members.views", level="ERROR"):
response = self.client.post(self.confirm_url, {"current_password": self.password})
self.assertTrue(response.context["confirmation_form"].non_field_errors())
self.change_email.assert_not_called()
self.account.refresh_from_db()
self.assertEqual(self.account.email, "alice@example.org")
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
def test_confirmation_ldap_failure_preserves_pending_and_redacts_logs(self):
token, pending = self.pending()
self.exchange(token)
self.change_email.side_effect = ValueError(self.password)
with self.assertLogs("members.views", level="ERROR") as captured:
response = self.client.post(self.confirm_url, {"current_password": self.password})
self.assertTrue(response.context["confirmation_form"].non_field_errors())
self.assertTrue(ProfileEmailChange.objects.filter(pk=pending.pk).exists())
self.assertNotIn(self.password, "\n".join(captured.output))
self.assertNotContains(response, self.password)
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_get_only_endpoints_reject_post(self):
token, _ = self.pending()
for url in (self.photo_url, reverse("members:verify_profile_email", args=[token])):
self.assertEqual(self.client.post(url).status_code, 405)
self.assert_no_writes()
@override_settings(LDAP_SEARCH_BASE_DN=SEARCH_DN, LDAP_GROUP_PLACEHOLDER_DN="cn=sentinel,dc=example,dc=org")
class ProfileDirectoryTests(TestCase):
def setUp(self):
self.account = Account.objects.create(
uid="alice", email="alice@example.org", oidc_issuer=settings.DEX_ISSUER,
oidc_sub="sub-alice", ldap_uuid="uuid-alice", ldap_dn=ALICE_DN,
)
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.bind()
self.addCleanup(self.conn.unbind)
# No network access can escape this module, even if a test regresses.
patcher = patch("members.directory.Connection", side_effect=AssertionError("Real LDAP prohibited"))
patcher.start()
self.addCleanup(patcher.stop)
def service(self):
manager = MagicMock()
manager.__enter__.return_value = self.conn
return patch("members.profiles.directory.connection", return_value=manager)
def authorized(self, *, values=None):
conn = MagicMock()
conn.modify.return_value = True
conn.extend.standard.modify_password.return_value = True
conn.entries = []
authorized = MagicMock()
authorized.return_value.__enter__.return_value = (conn, self.values if values is None else values)
return conn, authorized
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,
**changes,
}
def assert_critical_assertion(self, call):
controls = call.kwargs["controls"]
self.assertEqual(len(controls), 1)
self.assertEqual(str(controls[0]["controlType"]), "1.3.6.1.1.12")
self.assertTrue(bool(controls[0]["criticality"]))
encoded = bytes(controls[0]["controlValue"])
self.assertIn(b"entryUUID", encoded)
self.assertIn(b"uuid-alice", encoded)
self.assertIn(b"modifyTimestamp", encoded)
self.assertIn(b"20261029120000Z", encoded)
def test_read_uses_uuid_filter_fixed_attributes_and_persists_renamed_dn(self):
self.account.ldap_dn = "uid=old," + SEARCH_DN
self.account.save(update_fields=["ldap_dn"])
with self.service(), patch.object(self.conn, "search", wraps=self.conn.search) as search:
values = profiles.read_profile(self.account)
self.assertEqual(values["uid"], ["alice"])
self.assertEqual(values["entryUUID"], ["uuid-alice"])
self.assertEqual(set(values), set(profiles.READ_ATTRIBUTES))
search.assert_called_once_with(SEARCH_DN, "(entryUUID=uuid-alice)", attributes=list(profiles.READ_ATTRIBUTES))
self.assertNotIn("userPassword", search.call_args.kwargs["attributes"])
self.account.refresh_from_db()
self.assertEqual(self.account.ldap_dn, ALICE_DN)
def test_unpinned_verified_identity_resolves_uid_and_saves_uuid(self):
self.account.ldap_uuid = None
self.account.ldap_dn = ""
self.account.save(update_fields=["ldap_uuid", "ldap_dn"])
with self.service(), patch.object(self.conn, "search", wraps=self.conn.search) as search:
profiles.read_profile(self.account)
self.assertEqual(search.call_args.args[1], "(&(objectClass=inetOrgPerson)(uid=alice))")
self.account.refresh_from_db()
self.assertEqual((self.account.ldap_uuid, self.account.ldap_dn), ("uuid-alice", ALICE_DN))
def test_missing_pinned_uuid_never_falls_back_to_matching_uid(self):
self.account.ldap_uuid = "uuid-deleted"
self.account.save(update_fields=["ldap_uuid"])
with self.service(), patch.object(self.conn, "search", wraps=self.conn.search) as search:
with self.assertRaisesRegex(ValueError, "missing or ambiguous"):
profiles.read_profile(self.account)
search.assert_called_once_with(SEARCH_DN, "(entryUUID=uuid-deleted)", attributes=list(profiles.READ_ATTRIBUTES))
self.account.refresh_from_db()
self.assertEqual(self.account.ldap_uuid, "uuid-deleted")
def test_unverified_subject_or_wrong_issuer_refused_before_directory_read(self):
for changes in ({"oidc_sub": None}, {"oidc_issuer": "https://other.example.org/"}):
with self.subTest(changes=changes):
Account.objects.filter(pk=self.account.pk).update(**changes)
with patch("members.profiles.directory.connection") as service:
with self.assertRaisesRegex(ValueError, "verified Dex"):
profiles.read_profile(self.account)
service.assert_not_called()
Account.objects.filter(pk=self.account.pk).update(oidc_sub="sub-alice", oidc_issuer=settings.DEX_ISSUER)
def test_uid_mismatch_duplicate_uuid_and_sentinel_refused(self):
self.conn.strategy.entries[ALICE_DN]["uid"] = [b"mallory"]
with self.service(), self.assertRaisesRegex(ValueError, "does not match"):
profiles.read_profile(self.account)
self.conn.strategy.entries[ALICE_DN]["uid"] = [b"alice"]
self.account.ldap_uuid = None
self.account.save(update_fields=["ldap_uuid"])
bob = Account.objects.create(uid="bob", email="bob@example.org", ldap_uuid="uuid-alice")
with self.service(), self.assertRaisesRegex(ValueError, "another account"):
profiles.read_profile(self.account)
bob.delete()
with self.service(), override_settings(LDAP_GROUP_PLACEHOLDER_DN=ALICE_DN.upper()), self.assertRaisesRegex(ValueError, "System entries"):
profiles.read_profile(self.account)
def test_authorized_uses_user_bind_base_scope_and_fixed_read_allowlist(self):
manager = MagicMock()
manager.__enter__.return_value = self.conn
with patch("members.profiles.directory._connection", return_value=manager) as connection, patch.object(self.conn, "search", wraps=self.conn.search) as search:
profiles.verify_password(self.account, self.password)
connection.assert_called_once_with(ALICE_DN, self.password)
search.assert_called_once_with(ALICE_DN, "(entryUUID=uuid-alice)", search_scope=BASE, attributes=list(profiles.READ_ATTRIBUTES))
def test_authorized_requires_current_password_uuid_dn_and_matching_identity(self):
for attribute in ("ldap_uuid", "ldap_dn"):
saved = getattr(self.account, attribute)
setattr(self.account, attribute, "")
with self.subTest(attribute=attribute), patch("members.profiles.directory._connection") as connection:
with self.assertRaises(ValueError):
profiles.verify_password(self.account, self.password)
connection.assert_not_called()
setattr(self.account, attribute, saved)
with patch("members.profiles.directory._connection") as connection:
with self.assertRaises(ValueError):
profiles.verify_password(self.account, "")
connection.assert_not_called()
self.conn.strategy.entries[ALICE_DN]["uid"] = [b"mallory"]
manager = MagicMock()
manager.__enter__.return_value = self.conn
with patch("members.profiles.directory._connection", return_value=manager), self.assertRaisesRegex(ValueError, "identity changed"):
profiles.verify_password(self.account, self.password)
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"])
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.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))
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):
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)
def test_revision_bound_to_account_uuid_and_all_read_values(self):
valid = profiles.revision(self.account, self.values)
variants = [valid + "tampered", "", signing.dumps({"account": "other", "uuid": "uuid-alice", "digest": profiles._digest(self.values)}, salt="ldap-profile")]
with patch("members.profiles.signing.time.time", return_value=timezone.now().timestamp() - 3601):
variants.append(profiles.revision(self.account, self.values))
for revision in variants:
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)
conn.modify.assert_not_called()
for changed in (profile_values(displayName=["Concurrent edit"]), profile_values(jpegPhoto=[b"new photo"]), 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)
conn.modify.assert_not_called()
self.account.ldap_uuid = "uuid-recreated"
conn, authorized = self.authorized()
with patch("members.profiles._authorized", authorized), self.assertRaises(ValueError):
profiles.update_profile(self.account, self.cleaned(), valid)
conn.modify.assert_not_called()
def test_password_uses_rfc3062_not_userpassword_attribute_or_local_hashing(self):
conn, authorized = self.authorized()
new_password = secrets.token_urlsafe(24)
with patch("members.profiles._authorized", authorized):
profiles.change_password(self.account, self.password, new_password)
authorized.assert_called_once_with(self.account, self.password)
call = conn.extend.standard.modify_password.call_args
self.assertEqual(call.args, ())
self.assertEqual(call.kwargs["old_password"], self.password)
self.assertEqual(call.kwargs["new_password"], new_password)
self.assertNotIn("controls", call.kwargs)
conn.modify.assert_not_called()
def test_email_change_replaces_mail_with_critical_assertion(self):
conn, authorized = self.authorized()
with patch("members.profiles._authorized", authorized):
profiles.change_email(self.account, self.password, "new@example.org")
conn.search.assert_called_once_with(SEARCH_DN, "(mail=new@example.org)", attributes=["entryUUID"])
self.assertEqual(conn.modify.call_args.args, (ALICE_DN, {"mail": [(MODIFY_REPLACE, ["new@example.org"])]}))
self.assert_critical_assertion(conn.modify.call_args)
def test_email_uniqueness_rejects_other_uuid_but_allows_current_entry(self):
for uuid, allowed in (("uuid-bob", False), ("uuid-alice", True)):
conn, authorized = self.authorized()
entry = MagicMock()
entry.entryUUID.value = uuid
conn.entries = [entry]
with self.subTest(uuid=uuid), patch("members.profiles._authorized", authorized):
if allowed:
profiles.change_email(self.account, self.password, "new@example.org")
conn.modify.assert_called_once()
else:
with self.assertRaises(ValueError):
profiles.change_email(self.account, self.password, "new@example.org")
conn.modify.assert_not_called()
def test_rejected_ldap_writes_raise_instead_of_reporting_success(self):
conn, authorized = self.authorized()
conn.modify.return_value = False
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))
with self.assertRaises(LDAPException):
profiles.change_password(self.account, self.password, secrets.token_urlsafe(24))
with self.assertRaises(LDAPException):
profiles.change_email(self.account, self.password, "new@example.org")
+4
View File
@@ -11,6 +11,10 @@ urlpatterns = [
path("login/", views.login, name="login"),
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/<str:token>/", views.verify_profile_email, name="verify_profile_email"),
path("profile/email/confirm/", views.confirm_profile_email, name="confirm_profile_email"),
path("checkout/<slug:tier>/", views.checkout, name="checkout"),
path("billing/", views.portal, name="portal"),
path("stripe/webhook/", views.webhook, name="webhook"),
+137 -2
View File
@@ -11,6 +11,7 @@ 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
@@ -23,9 +24,10 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from . import billing, directory, shkeeper
from . import billing, directory, profiles, shkeeper
from .forms import PasswordForm, RegistrationForm
from .models import Account, CryptoInvoice, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
logger = logging.getLogger(__name__)
@@ -240,6 +242,139 @@ def logout(request):
return redirect("members:home")
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)}
@sensitive_post_parameters()
@require_http_methods(["GET", "POST"])
@account_required
def profile(request):
member = request.member
# Bound before any password verification; never log posted values or LDAP
# diagnostic messages. Account and IP limits protect directory binds.
if limited("profile-read", str(member.pk), 120):
return HttpResponse("Please try again later.", status=429)
try:
values = profiles.read_profile(member)
except Exception as exc:
logger.error("Profile read failed account=%s kind=%s", member.pk, type(exc).__name__)
return render(request, "profile.html", {"account": member, "profile_loaded": False}, status=503)
context = _profile_context(member, values)
context.update(saved=request.GET.get("saved") == "1", email_sent=request.GET.get("email_sent") == "1")
if request.method == "POST":
if limited("profile-write", str(member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
return HttpResponse("Please try again later.", status=429)
action = request.POST.get("action")
forms = {"profile": ("form", ProfileForm), "email": ("email_form", ProfileEmailForm), "password": ("password_form", ProfilePasswordForm)}
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)
context[form_key] = form
if form.is_valid():
try:
# Serialize directory pinning and edits against the worker.
with transaction.atomic():
member = Account.objects.select_for_update().get(pk=member.pk)
if action == "profile":
profiles.update_profile(member, form.cleaned_data, request.POST.get("revision", ""))
elif action == "password":
profiles.change_password(member, form.cleaned_data["current_password"], form.cleaned_data["password"])
else:
profiles.verify_password(member, form.cleaned_data["current_password"])
token = secrets.token_urlsafe(32)
digest = hashlib.sha256(token.encode()).hexdigest()
ProfileEmailChange.objects.filter(account=member).delete()
ProfileEmailChange.objects.create(token_hash=digest, account=member, email=form.cleaned_data["email"], ldap_uuid=member.ldap_uuid, expires_at=timezone.now() + timedelta(hours=1))
url = settings.PUBLIC_URL + reverse("members:verify_profile_email", args=[token])
send_mail("Verify your new eom.dev email", f"Confirm this email address for your account (expires in one hour):\n\n{url}\n\nYou must sign in to the same account and enter your current password to confirm. If you did not request this, ignore this message.", settings.DEFAULT_FROM_EMAIL, [form.cleaned_data["email"]])
logger.info("Profile change completed account=%s action=%s", member.pk, action)
if action == "password":
request.session.flush()
return page(request, title="Password changed", message="Your password was changed. Sign in again. Existing Dex and other service sessions are not automatically signed out.")
return redirect(reverse("members:profile") + ("?email_sent=1" if action == "email" else "?saved=1"))
except Exception as exc:
logger.error("Profile change failed account=%s kind=%s", member.pk, type(exc).__name__)
form.add_error(None, "Changes could not be completed. Check your current password and reload your profile before retrying. Contact the administrator if this continues.")
# Keep the original revision on validation errors, never silently allow
# a stale bound form to overwrite concurrently updated directory data.
if action == "profile":
context["revision"] = request.POST.get("revision", "")
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):
if limited("profile-email-verify", client_ip(request), 60):
return HttpResponse(status=429)
digest = hashlib.sha256(token.encode()).hexdigest()
if not ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).exists():
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
request.session["profile_email_hash"] = digest
return redirect("members:confirm_profile_email")
@sensitive_post_parameters("current_password")
@require_http_methods(["GET", "POST"])
@account_required
def confirm_profile_email(request):
digest = request.session.get("profile_email_hash")
pending = ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).first()
if not pending:
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
form = CurrentPasswordForm(request.POST if request.method == "POST" else None)
if request.method == "POST":
if limited("profile-write", str(request.member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
return HttpResponse("Please try again later.", status=429)
if form.is_valid():
try:
with transaction.atomic():
member = Account.objects.select_for_update().get(pk=request.member.pk)
pending = ProfileEmailChange.objects.select_for_update().get(pk=digest, account=member, expires_at__gt=timezone.now())
profiles.read_profile(member)
if member.ldap_uuid != pending.ldap_uuid:
raise ValueError("Directory identity changed.")
profiles.change_email(member, form.cleaned_data["current_password"], pending.email)
member.email = pending.email
member.save(update_fields=["email"])
ProfileEmailChange.objects.filter(account=member).delete()
request.session.pop("profile_email_hash", None)
request.session.cycle_key()
return redirect(reverse("members:profile") + "?saved=1")
except Exception as exc:
logger.error("Profile email confirmation failed account=%s kind=%s", request.member.pk, type(exc).__name__)
form.add_error(None, "Email change could not be completed. Check your current password or request a new verification link.")
return render(request, "profile.html", {"account": request.member, "confirm_email": True, "confirmation_email": pending.email, "confirmation_form": form})
def stripe_redirect(url, host):
parsed = urlsplit(url or "")
if parsed.scheme != "https" or parsed.hostname != host or parsed.username:
+2
View File
@@ -5,3 +5,5 @@ ldap3==2.9.1
stripe==14.4.1
psycopg[binary]>=3.2,<4
gunicorn>=23,<24
Pillow>=12.0.0,<13
cryptography>=50,<51
+1
View File
@@ -48,6 +48,7 @@
<div class="member-panel account-panel">
<div><p class="eyebrow">Your eom.dev account · Membership level {{ account.membership_level }}</p><h2>{{ account.uid }}</h2><p>{{ account.email }}</p></div>
<div class="member-actions">
<a class="button button-secondary" href="{% url 'members:profile' %}">Edit profile</a>
{% if account.stripe_customer %}<form action="{% url 'members:portal' %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Manage billing</button></form>{% endif %}
<form action="{% url 'members:logout' %}" method="post">{% csrf_token %}<button class="button button-secondary" type="submit">Sign out</button></form>
</div>
+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Your profile · eom.dev</title>
<link rel="stylesheet" href="/static/home.css">
<link rel="stylesheet" href="/static/membership.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
</head>
<body>
<header class="member-nav">
<a class="brand" href="/">eom.dev</a>
<nav aria-label="Membership navigation"><a href="#main-content">Skip to content</a><a href="{% url 'members:home' %}">Membership</a></nav>
</header>
<main id="main-content" class="member-main profile-main">
<header class="profile-heading">
<p class="eyebrow">One account. Your own identity.</p>
<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>
{% if account %}<p class="member-small">Signed in as <strong>{{ account.uid }}</strong>. Your username cannot be changed here.</p>{% endif %}
</header>
{% if saved %}<p class="member-notice" role="status">Your changes have been saved.</p>{% endif %}
{% if email_sent %}<p class="member-notice" role="status">Check your new email inbox for a verification link. Your stored email addresses stay unchanged until you confirm.</p>{% endif %}
{% if confirm_email %}
<section class="member-panel profile-section" aria-labelledby="confirm-heading">
<h2 id="confirm-heading">Confirm your new email</h2>
<p>{% if confirmation_email %}Confirm <strong>{{ confirmation_email }}</strong> as your email address. {% endif %}After verification, this replaces all currently stored email addresses with the new address.</p>
<p>Enter your current password to finish. Opening this link alone does not change your account.</p>
<form method="post">
{% csrf_token %}
{% if confirmation_form.non_field_errors %}<div class="profile-errors" role="alert">{{ confirmation_form.non_field_errors }}</div>{% endif %}
{% include "profile_fields.html" with fields=confirmation_form only %}
<button class="button button-primary" type="submit">Confirm email change</button>
</form>
</section>
{% elif not profile_loaded %}
<section class="member-panel profile-section" aria-labelledby="unavailable-heading">
<h2 id="unavailable-heading">Your profile is temporarily unavailable</h2>
<p>We could not load your stored profile. Editing is unavailable so that missing data cannot overwrite your details. Please try again later.</p>
<a class="button button-secondary" href="{% url 'members:profile' %}">Try loading again</a>
</section>
{% else %}
<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><a href="#password">Password</a>
</nav>
<form action="{% url 'members:profile' %}" method="post" enctype="multipart/form-data" class="profile-edit-form">
{% csrf_token %}
<input type="hidden" name="action" value="profile">
<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 %}
<fieldset id="identity" class="member-panel profile-section">
<legend>Identity</legend>
<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.identity_fields only %}
{% 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>
<section id="email" class="member-panel profile-section" aria-labelledby="email-heading">
<h2 id="email-heading">Email address</h2>
<p>Currently stored email addresses (read-only):</p>
<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>
<form action="{% url 'members:profile' %}" method="post">
{% csrf_token %}<input type="hidden" name="action" value="email">
{% if email_form.non_field_errors %}<div class="profile-errors" role="alert">{{ email_form.non_field_errors }}</div>{% endif %}
{% include "profile_fields.html" with fields=email_form only %}
<button class="button button-primary" type="submit">Send verification email</button>
</form>
</section>
<section id="password" class="member-panel profile-section" aria-labelledby="password-heading">
<h2 id="password-heading">Password</h2>
<p>Choose a strong, unique password for your shared eom.dev account. Your current password is needed to authorize the change. Spaces in passwords are preserved.</p>
<p class="member-small">Password hashing policy is controlled by the server. Legacy MD5Crypt is insecure; changing a password here does not guarantee that the server upgrades its hashing policy.</p>
<form action="{% url 'members:profile' %}" method="post">
{% csrf_token %}<input type="hidden" name="action" value="password">
{% if password_form.non_field_errors %}<div class="profile-errors" role="alert">{{ password_form.non_field_errors }}</div>{% endif %}
{% include "profile_fields.html" with fields=password_form only %}
<button class="button button-primary" type="submit">Change password</button>
</form>
</section>
{% endif %}
<p class="profile-back"><a href="{% url 'members:home' %}">Back to membership</a></p>
</main>
<footer class="member-footer">Your account. Across the community. <a href="{% url 'members:home' %}">Membership</a></footer>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{% for field in fields %}
<div class="profile-field{% if field.errors %} profile-field-invalid{% endif %}">
{{ field.label_tag }}{% if field.field.required %} <span class="profile-required">(required)</span>{% endif %}
{% if field.help_text %}<p class="helptext" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</p>{% endif %}
{{ field }}
{% if field.errors %}<div id="{{ field.auto_id }}_error" role="alert">{{ field.errors }}</div>{% endif %}
</div>
{% endfor %}