Add public registration cancellation
This commit is contained in:
parent
8170373148
commit
d5a5fed69a
7 changed files with 174 additions and 8 deletions
|
|
@ -45,12 +45,24 @@ class HTTPRegistrationVerificationAdapter:
|
|||
)
|
||||
|
||||
def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
|
||||
return self._resolve(
|
||||
opaque_handle, "/v1/registration-verifications/consume",
|
||||
"public-registration",
|
||||
)
|
||||
|
||||
def cancel(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
|
||||
return self._resolve(
|
||||
opaque_handle, "/v1/registration-verifications/cancel",
|
||||
"public-registration-cancel",
|
||||
)
|
||||
|
||||
def _resolve(
|
||||
self, opaque_handle: str, path: str, expected_purpose: str
|
||||
) -> VerifiedRegistrationApplicant:
|
||||
if len(opaque_handle) < 32:
|
||||
raise ValueError("verification handle is invalid")
|
||||
result = self._post(
|
||||
"/v1/registration-verifications/consume", {"handle": opaque_handle}
|
||||
)
|
||||
if result.get("purpose") != "public-registration":
|
||||
result = self._post(path, {"handle": opaque_handle})
|
||||
if result.get("purpose") != expected_purpose:
|
||||
raise RuntimeError("verification evidence has the wrong purpose")
|
||||
return VerifiedRegistrationApplicant(
|
||||
verification_id=str(result["verification_id"]),
|
||||
|
|
|
|||
|
|
@ -181,6 +181,9 @@ class RegistrationVerificationPort(Protocol):
|
|||
def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
|
||||
"""Atomically consume verified, unexpired applicant evidence."""
|
||||
|
||||
def cancel(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
|
||||
"""Atomically cancel an unexpired applicant intent using mailbox evidence."""
|
||||
|
||||
|
||||
class UserEngineStore(Protocol):
|
||||
"""Durable persistence boundary for user-engine service behavior.
|
||||
|
|
|
|||
|
|
@ -133,9 +133,11 @@ class PortalApplication:
|
|||
path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/"
|
||||
if method == "POST" and path in {
|
||||
"/register", "/registration/verify", "/registration/resume",
|
||||
"/registration/cancel",
|
||||
"/api/v1/public/registrations",
|
||||
"/api/v1/public/registrations/verify",
|
||||
"/api/v1/public/registrations/resume",
|
||||
"/api/v1/public/registrations/cancel",
|
||||
} and not self._accept_registration_attempt(environ):
|
||||
return self._error(
|
||||
start_response, "429 Too Many Requests", "rate_limited",
|
||||
|
|
@ -229,6 +231,23 @@ class PortalApplication:
|
|||
return self._resume_public_registration(
|
||||
environ, start_response, correlation_id, body=body, browser=True
|
||||
)
|
||||
if path == "/registration/cancel" and method == "GET":
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
handle = str(query.get("handle", [""])[0])
|
||||
if len(handle) < 16:
|
||||
raise ValidationError("cancellation handle is invalid")
|
||||
token = secrets.token_urlsafe(32)
|
||||
return self._html(
|
||||
start_response, self._registration_cancel_form(token, handle),
|
||||
correlation_id,
|
||||
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
|
||||
)
|
||||
if path == "/registration/cancel" and method == "POST":
|
||||
body = self._form_body(environ)
|
||||
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
|
||||
return self._cancel_public_registration(
|
||||
environ, start_response, correlation_id, body=body, browser=True
|
||||
)
|
||||
|
||||
if path == "/api/v1/public/registrations" and method == "POST":
|
||||
return self._start_public_registration(
|
||||
|
|
@ -242,6 +261,10 @@ class PortalApplication:
|
|||
return self._resume_public_registration(
|
||||
environ, start_response, correlation_id
|
||||
)
|
||||
if path == "/api/v1/public/registrations/cancel" and method == "POST":
|
||||
return self._cancel_public_registration(
|
||||
environ, start_response, correlation_id
|
||||
)
|
||||
|
||||
actor = self._actor(environ)
|
||||
if path == "/api/v1/me" and method == "GET":
|
||||
|
|
@ -1058,6 +1081,51 @@ class PortalApplication:
|
|||
browser=browser,
|
||||
)
|
||||
|
||||
def _cancel_public_registration(
|
||||
self, environ, start_response, correlation_id, *,
|
||||
body: Mapping[str, Any] | None = None, browser: bool = False,
|
||||
):
|
||||
if not self.public_registration or self.registration_verification is None:
|
||||
raise NotFoundError("public registration is unavailable")
|
||||
body = body if body is not None else self._body(environ)
|
||||
evidence = self.registration_verification.cancel(
|
||||
str(body.get("handle") or "")
|
||||
)
|
||||
session = self.service.store.registration_session(evidence.registration_id)
|
||||
if session is None:
|
||||
raise NotFoundError("registration not found")
|
||||
if (
|
||||
evidence.client_id != session.client_id
|
||||
or evidence.tenant != session.tenant
|
||||
or evidence.preferred_username != session.applicant_username
|
||||
):
|
||||
raise AuthorizationDenied("cancellation evidence does not match registration")
|
||||
actor = Actor(
|
||||
issuer="urn:netkingdom:public-registration",
|
||||
subject=str(session.started_by_subject), tenant=session.tenant,
|
||||
principal_type=PrincipalType.HUMAN, audience=("user-engine",),
|
||||
roles=("registration-applicant",), authorized_party=session.client_id,
|
||||
preferred_username=session.applicant_username,
|
||||
)
|
||||
self.service.abandon_registration(
|
||||
actor, session.registration_id, correlation_id=correlation_id
|
||||
)
|
||||
if browser:
|
||||
return self._html(
|
||||
start_response,
|
||||
self._page_html(
|
||||
"Registration canceled",
|
||||
"<h1>Registration canceled.</h1>"
|
||||
"<p>The request can no longer be verified. You may start again at any time.</p>"
|
||||
'<p><a class="button" href="/register">Start again</a></p>',
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
return self._json(
|
||||
start_response, "200 OK", {"status": "registration_canceled"},
|
||||
correlation_id,
|
||||
)
|
||||
|
||||
def _provision_public_registration(
|
||||
self, start_response, actor, user, session, email, display_name,
|
||||
preferred_username, correlation_id,
|
||||
|
|
@ -1305,6 +1373,18 @@ class PortalApplication:
|
|||
<input type="hidden" name="resume_handle" value="{escape(resume_handle)}">
|
||||
<button type="submit">Try identity setup again</button></form>""",
|
||||
)
|
||||
|
||||
def _registration_cancel_form(self, csrf_token: str, handle: str) -> str:
|
||||
return self._page_html(
|
||||
"Cancel registration",
|
||||
f"""<h1>Cancel this registration?</h1>
|
||||
<p>This verification request will stop working. No login identity will be created.</p>
|
||||
<form method="post" action="/registration/cancel">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<input type="hidden" name="handle" value="{escape(handle)}">
|
||||
<button type="submit">Cancel registration</button></form>
|
||||
<p><a href="/register">Keep registration</a></p>""",
|
||||
)
|
||||
def _admin(
|
||||
self, tenant: str, memberships: tuple[Any, ...],
|
||||
invitations: tuple[Any, ...], diagnostics: Any,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue