Make registration start idempotent
This commit is contained in:
parent
e0399cf235
commit
d6873b84ae
4 changed files with 99 additions and 7 deletions
|
|
@ -579,6 +579,8 @@ class RegistrationSession:
|
|||
started_by_subject: str | None = None
|
||||
applicant_username: str | None = None
|
||||
client_id: str | None = None
|
||||
start_idempotency_hash: str | None = None
|
||||
start_request_hash: str | None = None
|
||||
provisioning_resume_hash: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import io
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
|
|
@ -53,6 +54,8 @@ def invoke(
|
|||
if claims is not None:
|
||||
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
|
||||
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
|
||||
if path == "/api/v1/public/registrations" and method == "POST":
|
||||
environ["HTTP_IDEMPOTENCY_KEY"] = hashlib.sha256(payload).hexdigest()
|
||||
environ.update(headers or {})
|
||||
captured = {}
|
||||
|
||||
|
|
@ -255,6 +258,9 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
self.assertIn(b"Create your account", html)
|
||||
cookie = page["headers"]["Set-Cookie"]
|
||||
token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode()
|
||||
idempotency_key = re.search(
|
||||
rb'name="idempotency_key" value="([^"]+)"', html
|
||||
).group(1).decode()
|
||||
denied, _ = invoke(self.app, "/register", method="POST", form={
|
||||
"csrf_token": "wrong", "username": "new.person",
|
||||
"email": "new@example.test", "client_id": "coulomb-social",
|
||||
|
|
@ -262,7 +268,8 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
}, cookie=cookie)
|
||||
self.assertEqual("403 Forbidden", denied["status"])
|
||||
started, html = invoke(self.app, "/register", method="POST", form={
|
||||
"csrf_token": token, "username": "new.person",
|
||||
"csrf_token": token, "idempotency_key": idempotency_key,
|
||||
"username": "new.person",
|
||||
"email": "new@example.test", "display_name": "New Person",
|
||||
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
|
||||
}, cookie=cookie)
|
||||
|
|
@ -436,6 +443,38 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual("400 Bad Request", result["status"])
|
||||
|
||||
def test_public_registration_start_is_idempotent_and_key_is_payload_bound(self):
|
||||
verifier = FakeRegistrationVerification()
|
||||
self.app.registration_verification = verifier
|
||||
self.app.registration_clients = frozenset({"coulomb-social"})
|
||||
self.app.registration_tenants = frozenset({"tenant:coulomb"})
|
||||
body = {
|
||||
"username": "idem.person", "email": "idem@example.test",
|
||||
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
|
||||
}
|
||||
headers = {"HTTP_IDEMPOTENCY_KEY": "registration-key-123456789"}
|
||||
first, _ = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST",
|
||||
body=body, headers=headers,
|
||||
)
|
||||
registration_id = verifier.requested.registration_id
|
||||
replay, _ = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST",
|
||||
body=body, headers=headers,
|
||||
)
|
||||
conflicting, _ = invoke(
|
||||
self.app, "/api/v1/public/registrations", method="POST",
|
||||
body={**body, "email": "different@example.test"}, headers=headers,
|
||||
)
|
||||
sessions = self.app.service.store.all_registration_sessions()
|
||||
self.assertEqual("202 Accepted", first["status"])
|
||||
self.assertEqual("202 Accepted", replay["status"])
|
||||
self.assertEqual("409 Conflict", conflicting["status"])
|
||||
self.assertEqual(1, len(sessions))
|
||||
self.assertEqual(1, verifier.request_count)
|
||||
self.assertEqual(registration_id, sessions[0].registration_id)
|
||||
self.assertNotIn("registration-key-123456789", repr(sessions[0]))
|
||||
|
||||
def test_public_registration_rate_limit_uses_peer_not_forwarded_header(self):
|
||||
self.app.registration_verification = FakeRegistrationVerification()
|
||||
self.app.registration_rate_limit = 2
|
||||
|
|
@ -896,8 +935,10 @@ class FakeRegistrationVerification:
|
|||
def __init__(self):
|
||||
self.requested = None
|
||||
self.registration_id = None
|
||||
self.request_count = 0
|
||||
|
||||
def request(self, request):
|
||||
self.request_count += 1
|
||||
self.requested = request
|
||||
return RegistrationVerificationReceipt(request_id="vrq_test")
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ atomically invalidate one another, replay fails closed, and no identity is
|
|||
created from cancellation. The full user-engine suite passes 133 tests with
|
||||
three environment-dependent skips; email-connect passes all 24 tests.
|
||||
|
||||
2026-08-10 idempotency completion: public API starts now require an
|
||||
`Idempotency-Key`, while the browser form receives a cryptographically random
|
||||
hidden key. user-engine persists only a keyed HMAC of that value plus a hash of
|
||||
the normalized request. Exact retries return the same generic response without
|
||||
creating another session or requesting another email; reuse with different
|
||||
inputs returns 409. Raw keys and applicant fields are absent from idempotency
|
||||
evidence. The full suite passes 134 tests with three external skips.
|
||||
|
||||
## T02 - Orchestrate provider identity creation
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue