diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index b0b41f0..2e69d4d 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 1b6009b..938ba3b 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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'' for item in sorted(self.registration_clients) @@ -1340,6 +1380,7 @@ class PortalApplication:
We will verify your email before creating an identity.