A UK buyer can now create their own account at /portal/signup/. The screen creates a Keycloak user through a least-privilege service account (ixg-provisioning: manage-users, view-users) and the matching Django row as a buyer, then sends them through the normal sign-in flow to the portal. Staff status is derived from the role in the OIDC backend rather than asserted, so a self-registered buyer is not staff and does not become staff on a later sign-in. The platform root and the RBAC middleware now route each role to its own portal, which also fixes a redirect loop that the staff fix exposed for non-staff buyers.
120 lines
4.4 KiB
Python
120 lines
4.4 KiB
Python
"""Create Keycloak users from the platform.
|
|
|
|
The public buyer sign-up screen needs a Keycloak account before it can write the
|
|
matching Django row, because that row stores the Keycloak subject id. Realm
|
|
self-registration is off (``registrationAllowed = false``) and could not capture
|
|
the company name anyway, so the platform creates the user itself through the
|
|
Keycloak Admin API, using a dedicated service account
|
|
(``KEYCLOAK_PROVISIONING_CLIENT_ID``) that holds only ``manage-users`` and
|
|
``view-users`` on ``realm-management``.
|
|
|
|
The account it creates is deliberately bare: **no realm roles and no client
|
|
roles**, so it grants nothing on the platform. The Django row's ``role`` is the
|
|
authority; Keycloak is only the identity store.
|
|
|
|
Credentials are read from settings (django-environ), never hard-coded, and no
|
|
token or secret is ever logged.
|
|
"""
|
|
import requests
|
|
from django.conf import settings
|
|
|
|
|
|
class KeycloakError(Exception):
|
|
"""Base class for provisioning failures the sign-up screen can report."""
|
|
|
|
|
|
class DuplicateEmailError(KeycloakError):
|
|
"""Keycloak already has a user with this email address."""
|
|
|
|
|
|
class KeycloakUnavailableError(KeycloakError):
|
|
"""Keycloak could not be reached, or refused the service account."""
|
|
|
|
|
|
def _realm_url():
|
|
return f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}/realms/{settings.KEYCLOAK_REALM}"
|
|
|
|
|
|
def _admin_url(path):
|
|
return (
|
|
f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}"
|
|
f"/admin/realms/{settings.KEYCLOAK_REALM}{path}"
|
|
)
|
|
|
|
|
|
def _split_name(full_name):
|
|
"""Split a full name for Keycloak's required firstName/lastName fields.
|
|
|
|
The realm's user profile marks both as required for users, and Keycloak
|
|
inserts a "Update Account Information" (``VERIFY_PROFILE``) step at sign-in
|
|
whenever one is empty. A single-word name therefore fills both fields: the
|
|
platform shows ``full_name`` from its own row, so the split only has to
|
|
satisfy Keycloak, not be a perfect decomposition.
|
|
"""
|
|
parts = full_name.strip().split()
|
|
if not parts:
|
|
return "", ""
|
|
if len(parts) == 1:
|
|
return parts[0], parts[0]
|
|
return " ".join(parts[:-1]), parts[-1]
|
|
|
|
|
|
def _service_account_token(timeout=10):
|
|
"""Exchange the provisioning client's credentials for an admin token."""
|
|
try:
|
|
response = requests.post(
|
|
f"{_realm_url()}/protocol/openid-connect/token",
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": settings.KEYCLOAK_PROVISIONING_CLIENT_ID,
|
|
"client_secret": settings.KEYCLOAK_PROVISIONING_CLIENT_SECRET,
|
|
},
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
|
|
if response.status_code != 200:
|
|
raise KeycloakUnavailableError("Keycloak refused the provisioning account.")
|
|
return response.json()["access_token"]
|
|
|
|
|
|
def create_keycloak_user(*, email, full_name, password, timeout=10):
|
|
"""Create an enabled, unverified Keycloak user and return its subject id.
|
|
|
|
``emailVerified`` is left false on purpose: the realm has ``verifyEmail``
|
|
off and no mail service exists, so an unverified user can still sign in.
|
|
A duplicate email (``duplicateEmailsAllowed`` is off) surfaces as
|
|
:class:`DuplicateEmailError` rather than a raw 409.
|
|
"""
|
|
token = _service_account_token(timeout=timeout)
|
|
first_name, last_name = _split_name(full_name)
|
|
payload = {
|
|
"username": email,
|
|
"email": email,
|
|
"firstName": first_name,
|
|
"lastName": last_name,
|
|
"enabled": True,
|
|
"emailVerified": False,
|
|
"credentials": [
|
|
{"type": "password", "value": password, "temporary": False}
|
|
],
|
|
}
|
|
try:
|
|
response = requests.post(
|
|
_admin_url("/users"),
|
|
json=payload,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=timeout,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
|
|
|
|
if response.status_code == 409:
|
|
raise DuplicateEmailError(email)
|
|
if response.status_code not in (201, 204):
|
|
raise KeycloakUnavailableError("Keycloak could not create the account.")
|
|
|
|
location = response.headers.get("Location", "")
|
|
if not location:
|
|
raise KeycloakUnavailableError("Keycloak did not return the new account.")
|
|
return location.rstrip("/").rsplit("/", 1)[-1]
|