portal: reject duplicate external emails

This commit is contained in:
Brad Stein 2026-01-04 07:29:37 -03:00
parent 315dab839f
commit 632cb9c17b
3 changed files with 48 additions and 1 deletions

View File

@ -106,6 +106,30 @@ class KeycloakAdminClient:
user = users[0] user = users[0]
return user if isinstance(user, dict) else None return user if isinstance(user, dict) else None
def find_user_by_email(self, email: str) -> dict[str, Any] | None:
email = (email or "").strip()
if not email:
return None
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
# Match the portal's username query behavior: set a low `max` and post-filter for exact matches.
params = {"email": email, "exact": "true", "max": "2"}
email_norm = email.lower()
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=self._headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list) or not users:
return None
for user in users:
if not isinstance(user, dict):
continue
candidate = user.get("email")
if isinstance(candidate, str) and candidate.strip().lower() == email_norm:
return user
return None
def get_user(self, user_id: str) -> dict[str, Any]: def get_user(self, user_id: str) -> dict[str, Any]:
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:

View File

@ -71,7 +71,25 @@ def _safe_error_detail(exc: Exception, fallback: str) -> str:
if msg: if msg:
return msg return msg
if isinstance(exc, httpx.HTTPStatusError): if isinstance(exc, httpx.HTTPStatusError):
return f"http {exc.response.status_code}" detail = f"http {exc.response.status_code}"
try:
payload = exc.response.json()
msg: str | None = None
if isinstance(payload, dict):
raw = payload.get("errorMessage") or payload.get("error") or payload.get("message")
if isinstance(raw, str) and raw.strip():
msg = raw.strip()
elif isinstance(payload, str) and payload.strip():
msg = payload.strip()
if msg:
msg = " ".join(msg.split())
detail = f"{detail}: {msg[:200]}"
except Exception:
text = (exc.response.text or "").strip()
if text:
text = " ".join(text.split())
detail = f"{detail}: {text[:200]}"
return detail
if isinstance(exc, httpx.TimeoutException): if isinstance(exc, httpx.TimeoutException):
return "timeout" return "timeout"
return fallback return fallback
@ -181,6 +199,9 @@ def provision_access_request(request_code: str) -> ProvisionResult:
email = contact_email.strip() email = contact_email.strip()
if not email: if not email:
raise RuntimeError("missing verified email address") raise RuntimeError("missing verified email address")
existing_email_user = admin_client().find_user_by_email(email)
if existing_email_user and (existing_email_user.get("username") or "") != username:
raise RuntimeError("email is already associated with an existing Atlas account")
# Always enforce email verification in Keycloak itself (even if the portal # Always enforce email verification in Keycloak itself (even if the portal
# already verified an external email before approval). # already verified an external email before approval).
required_actions = ["UPDATE_PASSWORD", "VERIFY_EMAIL", "CONFIGURE_TOTP"] required_actions = ["UPDATE_PASSWORD", "VERIFY_EMAIL", "CONFIGURE_TOTP"]

View File

@ -236,6 +236,8 @@ def register(app) -> None:
if admin_client().ready() and admin_client().find_user(username): if admin_client().ready() and admin_client().find_user(username):
return jsonify({"error": "username already exists"}), 409 return jsonify({"error": "username already exists"}), 409
if admin_client().ready() and admin_client().find_user_by_email(email):
return jsonify({"error": "email is already associated with an existing Atlas account"}), 409
try: try:
with connect() as conn: with connect() as conn: