This commit is contained in:
2026-10-29 11:51:44 -04:00
parent 27a6305b22
commit e4be5bfdf4
10 changed files with 432 additions and 71 deletions
+34 -21
View File
@@ -1,5 +1,6 @@
import hashlib
import logging
import re
import secrets
import uuid
from datetime import timedelta
@@ -9,6 +10,7 @@ from urllib.parse import urlsplit
import stripe
from authlib.integrations.django_client import OAuth
from django.conf import settings
from django.core.validators import validate_email
from django.core.mail import send_mail
from django.db import transaction
from django.http import HttpResponse
@@ -129,6 +131,9 @@ def finish_registration(request):
remember_groups(account)
Registration.objects.filter(uid=registration.uid).delete()
request.session.pop("registration_hash", None)
# Server-side, short-lived proof permits first binding of ONLY the
# account just provisioned in this browser, never an email match.
request.session["registration_identity"] = {"account_id": str(account.pk), "expires_at": (timezone.now() + timedelta(hours=1)).timestamp()}
return page(request, title="Account created", message="Your free eom.dev account is ready. Sign in to explore the community and its services, or choose an optional paid membership for additional benefits.")
except ValueError:
form.add_error(None, "An account with those details may already exist. Try signing in or contact us for help.")
@@ -163,32 +168,37 @@ def login(request):
return page(request, title="Sign-in unavailable", message="Please try again later.")
def link_identity(claims):
def link_identity(claims, *, registration_account_id=None):
if claims.get("iss") != settings.DEX_ISSUER or claims.get("email_verified") is not True or not isinstance(claims.get("sub"), str) or not claims["sub"] or len(claims["sub"]) > 512:
raise ValueError("A verified Dex identity is required.")
identity = directory.identity_for_login(claims.get("preferred_username"))
uid, email = claims.get("preferred_username"), claims.get("email")
if not isinstance(uid, str) or not re.fullmatch(r"[a-z][a-z0-9._-]{2,47}", uid):
raise ValueError("Dex must return a valid uid as preferred_username.")
if not isinstance(email, str) or len(email) > 254:
raise ValueError("A verified Dex email is required.")
validate_email(email)
federated = claims.get("federated_claims") or {}
if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["uid"]:
if not isinstance(federated, dict) or federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != uid:
raise ValueError("Dex must authenticate the configured LDAP connector with uid as idAttr.")
if identity["email"].casefold() != str(claims.get("email", "")).casefold():
raise ValueError("Dex and LDAP email disagree.")
# Resolve by immutable LDAP entryUUID, never by email. Dex's federated user
# ID remains uid so the unified connector preserves existing OIDC subjects.
# Authentication is entirely OIDC. Never contact LDAP or silently rebind an
# existing account to a different subject based on username/email matches.
with transaction.atomic():
uid_owner = Account.objects.filter(uid=identity["uid"]).first()
if uid_owner and uid_owner.ldap_uuid != identity["ldap_uuid"]:
raise ValueError("Username is already linked to another directory entry.")
account, _ = Account.objects.get_or_create(ldap_uuid=identity["ldap_uuid"], defaults=identity)
account = Account.objects.select_for_update().get(pk=account.pk)
subject_owner = Account.objects.filter(oidc_sub=claims["sub"]).exclude(pk=account.pk).exists()
if subject_owner:
raise ValueError("Dex subject is already linked to another directory entry.")
# A previous login may have used the retired www-ldap connector, whose
# subjects used entryUUID. Rebind after validating the same LDAP entry.
account = Account.objects.select_for_update().filter(oidc_issuer=claims["iss"], oidc_sub=claims["sub"]).first()
if account:
if account.uid != uid:
raise ValueError("Dex identity username changed; an explicit migration is required.")
else:
account, created = Account.objects.select_for_update().get_or_create(uid=uid, defaults={"email": email, "oidc_issuer": claims["iss"], "oidc_sub": claims["sub"]})
if not created:
if account.oidc_sub:
if account.oidc_issuer != claims["iss"] or account.oidc_sub != claims["sub"]:
raise ValueError("Existing account requires an explicit Dex identity mapping.")
elif str(account.pk) != registration_account_id:
raise ValueError("Existing account requires an explicit Dex identity mapping.")
account.oidc_issuer = claims["iss"]
account.oidc_sub = claims["sub"]
account.email = identity["email"]
account.ldap_dn = identity["ldap_dn"]
account.save(update_fields=["oidc_sub", "email", "ldap_dn"])
account.email = email
account.save(update_fields=["oidc_issuer", "oidc_sub", "email"])
remember_groups(account)
return account
@@ -199,7 +209,10 @@ def callback(request):
token = dex().authorize_access_token(request, claims_options={"iss": {"essential": True, "value": settings.DEX_ISSUER}}, leeway=30)
if not token.get("id_token"):
raise ValueError("ID token is required.")
account = link_identity(token.get("userinfo") or {})
proof = request.session.get("registration_identity") or {}
expires_at = proof.get("expires_at") if isinstance(proof, dict) else None
registration_account_id = proof.get("account_id") if isinstance(expires_at, (int, float)) and expires_at > timezone.now().timestamp() else None
account = link_identity(token.get("userinfo") or {}, registration_account_id=registration_account_id)
except Exception as exc:
logger.error("Dex callback rejected kind=%s", type(exc).__name__)
return page(request, title="Sign-in could not be verified", message="Try signing in again, or contact the administrator.")