diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py index 394f81a..0df0b97 100644 --- a/backend/atlas_portal/keycloak.py +++ b/backend/atlas_portal/keycloak.py @@ -106,6 +106,30 @@ class KeycloakAdminClient: user = users[0] 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]: 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: diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index ead8b24..80af303 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -71,7 +71,25 @@ def _safe_error_detail(exc: Exception, fallback: str) -> str: if msg: return msg 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): return "timeout" return fallback @@ -181,6 +199,9 @@ def provision_access_request(request_code: str) -> ProvisionResult: email = contact_email.strip() if not email: 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 # already verified an external email before approval). required_actions = ["UPDATE_PASSWORD", "VERIFY_EMAIL", "CONFIGURE_TOTP"] diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 83c9ba9..015a4d0 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -236,6 +236,8 @@ def register(app) -> None: if admin_client().ready() and admin_client().find_user(username): 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: with connect() as conn: