Make registration start idempotent
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-10 19:53:39 +02:00
parent e0399cf235
commit d6873b84ae
4 changed files with 99 additions and 7 deletions

View file

@ -198,8 +198,10 @@ class PortalApplication:
if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable")
token = secrets.token_urlsafe(32)
idempotency_key = secrets.token_urlsafe(24)
return self._html(
start_response, self._registration_form(token), correlation_id,
start_response,
self._registration_form(token, idempotency_key), correlation_id,
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
)
if path == "/register" and method == "POST":
@ -919,6 +921,34 @@ class PortalApplication:
if tenant not in self.registration_tenants:
raise ValidationError("tenant is not eligible for registration")
raw_idempotency_key = str(
body.get("idempotency_key") if browser
else environ.get("HTTP_IDEMPOTENCY_KEY", "")
)
if len(raw_idempotency_key) < 16 or len(raw_idempotency_key) > 256:
raise ValidationError("registration idempotency key is invalid")
idempotency_hash = hmac.new(
self.trusted_proxy_secret.encode(), raw_idempotency_key.encode(),
hashlib.sha256,
).hexdigest()
request_hash = hashlib.sha256(json.dumps({
"username": username, "email": email, "display_name": display_name,
"client_id": client_id, "tenant": tenant,
}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
previous = next((
item for item in self.service.store.all_registration_sessions()
if item.start_idempotency_hash
and hmac.compare_digest(item.start_idempotency_hash, idempotency_hash)
), None)
if previous is not None:
if not previous.start_request_hash or not hmac.compare_digest(
previous.start_request_hash, request_hash
):
raise ConflictError("registration idempotency key was reused")
return self._registration_requested_response(
start_response, correlation_id, browser
)
applicant_subject = f"applicant_{secrets.token_hex(16)}"
actor = Actor(
issuer="urn:netkingdom:public-registration",
@ -937,6 +967,11 @@ class PortalApplication:
applicant_username=username,
client_id=client_id,
)
session = replace(
session, start_idempotency_hash=idempotency_hash,
start_request_hash=request_hash,
)
self.service.store.save_registration_session(session)
self.registration_verification.request(
RegistrationVerificationRequest(
registration_id=session.registration_id,
@ -948,6 +983,13 @@ class PortalApplication:
display_name=display_name,
)
)
return self._registration_requested_response(
start_response, correlation_id, browser
)
def _registration_requested_response(
self, start_response: StartResponse, correlation_id: str, browser: bool
) -> Iterable[bytes]:
if browser:
return self._html(
start_response,
@ -960,10 +1002,8 @@ class PortalApplication:
correlation_id,
)
return self._json(
start_response,
"202 Accepted",
{"status": "verification_requested"},
correlation_id,
start_response, "202 Accepted",
{"status": "verification_requested"}, correlation_id,
)
def _verify_public_registration(
@ -1325,7 +1365,7 @@ class PortalApplication:
+ identity,
)
def _registration_form(self, csrf_token: str) -> str:
def _registration_form(self, csrf_token: str, idempotency_key: str) -> str:
client_options = "".join(
f'<option value="{escape(item)}">{escape(item)}</option>'
for item in sorted(self.registration_clients)
@ -1340,6 +1380,7 @@ class PortalApplication:
<p>We will verify your email before creating an identity.</p>
<form method="post" action="/register">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="idempotency_key" value="{escape(idempotency_key)}">
<label>Username <input name="username" required minlength="3" maxlength="32" pattern="[A-Za-z][A-Za-z0-9._-]+" autocomplete="username"></label>
<label>Email <input name="email" type="email" required autocomplete="email"></label>
<label>Display name <input name="display_name" maxlength="200" autocomplete="name"></label>