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
|
started_by_subject: str | None = None
|
||||||
applicant_username: str | None = None
|
applicant_username: str | None = None
|
||||||
client_id: 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
|
provisioning_resume_hash: str | None = None
|
||||||
correlation_id: str | None = None
|
correlation_id: str | None = None
|
||||||
created_at: datetime = field(default_factory=utc_now)
|
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:
|
if not self.public_registration or self.registration_verification is None:
|
||||||
raise NotFoundError("public registration is unavailable")
|
raise NotFoundError("public registration is unavailable")
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
|
idempotency_key = secrets.token_urlsafe(24)
|
||||||
return self._html(
|
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))],
|
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
|
||||||
)
|
)
|
||||||
if path == "/register" and method == "POST":
|
if path == "/register" and method == "POST":
|
||||||
|
|
@ -919,6 +921,34 @@ class PortalApplication:
|
||||||
if tenant not in self.registration_tenants:
|
if tenant not in self.registration_tenants:
|
||||||
raise ValidationError("tenant is not eligible for registration")
|
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)}"
|
applicant_subject = f"applicant_{secrets.token_hex(16)}"
|
||||||
actor = Actor(
|
actor = Actor(
|
||||||
issuer="urn:netkingdom:public-registration",
|
issuer="urn:netkingdom:public-registration",
|
||||||
|
|
@ -937,6 +967,11 @@ class PortalApplication:
|
||||||
applicant_username=username,
|
applicant_username=username,
|
||||||
client_id=client_id,
|
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(
|
self.registration_verification.request(
|
||||||
RegistrationVerificationRequest(
|
RegistrationVerificationRequest(
|
||||||
registration_id=session.registration_id,
|
registration_id=session.registration_id,
|
||||||
|
|
@ -948,6 +983,13 @@ class PortalApplication:
|
||||||
display_name=display_name,
|
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:
|
if browser:
|
||||||
return self._html(
|
return self._html(
|
||||||
start_response,
|
start_response,
|
||||||
|
|
@ -960,10 +1002,8 @@ class PortalApplication:
|
||||||
correlation_id,
|
correlation_id,
|
||||||
)
|
)
|
||||||
return self._json(
|
return self._json(
|
||||||
start_response,
|
start_response, "202 Accepted",
|
||||||
"202 Accepted",
|
{"status": "verification_requested"}, correlation_id,
|
||||||
{"status": "verification_requested"},
|
|
||||||
correlation_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _verify_public_registration(
|
def _verify_public_registration(
|
||||||
|
|
@ -1325,7 +1365,7 @@ class PortalApplication:
|
||||||
+ identity,
|
+ identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _registration_form(self, csrf_token: str) -> str:
|
def _registration_form(self, csrf_token: str, idempotency_key: str) -> str:
|
||||||
client_options = "".join(
|
client_options = "".join(
|
||||||
f'<option value="{escape(item)}">{escape(item)}</option>'
|
f'<option value="{escape(item)}">{escape(item)}</option>'
|
||||||
for item in sorted(self.registration_clients)
|
for item in sorted(self.registration_clients)
|
||||||
|
|
@ -1340,6 +1380,7 @@ class PortalApplication:
|
||||||
<p>We will verify your email before creating an identity.</p>
|
<p>We will verify your email before creating an identity.</p>
|
||||||
<form method="post" action="/register">
|
<form method="post" action="/register">
|
||||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
<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>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>Email <input name="email" type="email" required autocomplete="email"></label>
|
||||||
<label>Display name <input name="display_name" maxlength="200" autocomplete="name"></label>
|
<label>Display name <input name="display_name" maxlength="200" autocomplete="name"></label>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import io
|
import io
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -53,6 +54,8 @@ def invoke(
|
||||||
if claims is not None:
|
if claims is not None:
|
||||||
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
|
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
|
||||||
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
|
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 {})
|
environ.update(headers or {})
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
|
|
@ -255,6 +258,9 @@ class PortalApplicationTests(unittest.TestCase):
|
||||||
self.assertIn(b"Create your account", html)
|
self.assertIn(b"Create your account", html)
|
||||||
cookie = page["headers"]["Set-Cookie"]
|
cookie = page["headers"]["Set-Cookie"]
|
||||||
token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode()
|
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={
|
denied, _ = invoke(self.app, "/register", method="POST", form={
|
||||||
"csrf_token": "wrong", "username": "new.person",
|
"csrf_token": "wrong", "username": "new.person",
|
||||||
"email": "new@example.test", "client_id": "coulomb-social",
|
"email": "new@example.test", "client_id": "coulomb-social",
|
||||||
|
|
@ -262,7 +268,8 @@ class PortalApplicationTests(unittest.TestCase):
|
||||||
}, cookie=cookie)
|
}, cookie=cookie)
|
||||||
self.assertEqual("403 Forbidden", denied["status"])
|
self.assertEqual("403 Forbidden", denied["status"])
|
||||||
started, html = invoke(self.app, "/register", method="POST", form={
|
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",
|
"email": "new@example.test", "display_name": "New Person",
|
||||||
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
|
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
|
||||||
}, cookie=cookie)
|
}, cookie=cookie)
|
||||||
|
|
@ -436,6 +443,38 @@ class PortalApplicationTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual("400 Bad Request", result["status"])
|
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):
|
def test_public_registration_rate_limit_uses_peer_not_forwarded_header(self):
|
||||||
self.app.registration_verification = FakeRegistrationVerification()
|
self.app.registration_verification = FakeRegistrationVerification()
|
||||||
self.app.registration_rate_limit = 2
|
self.app.registration_rate_limit = 2
|
||||||
|
|
@ -896,8 +935,10 @@ class FakeRegistrationVerification:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.requested = None
|
self.requested = None
|
||||||
self.registration_id = None
|
self.registration_id = None
|
||||||
|
self.request_count = 0
|
||||||
|
|
||||||
def request(self, request):
|
def request(self, request):
|
||||||
|
self.request_count += 1
|
||||||
self.requested = request
|
self.requested = request
|
||||||
return RegistrationVerificationReceipt(request_id="vrq_test")
|
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
|
created from cancellation. The full user-engine suite passes 133 tests with
|
||||||
three environment-dependent skips; email-connect passes all 24 tests.
|
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
|
## T02 - Orchestrate provider identity creation
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue