Add public registration browser journey
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 15:59:50 +02:00
parent c12bc604a8
commit 0669fa7a85
3 changed files with 231 additions and 14 deletions

View file

@ -171,6 +171,44 @@ class PortalApplication:
actor = self._optional_actor(environ)
return self._html(start_response, self._home(actor), correlation_id)
if path == "/register" and method == "GET":
if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable")
token = secrets.token_urlsafe(32)
return self._html(
start_response, self._registration_form(token), correlation_id,
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
)
if path == "/register" and method == "POST":
body = self._form_body(environ)
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
return self._start_public_registration(
environ, start_response, correlation_id, body=body, browser=True
)
if path == "/registration/verify" and method == "GET":
query = parse_qs(str(environ.get("QUERY_STRING", "")))
handle = str(query.get("handle", [""])[0])
if len(handle) < 16:
raise ValidationError("verification handle is invalid")
token = secrets.token_urlsafe(32)
return self._html(
start_response, self._registration_verification_form(token, handle),
correlation_id,
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
)
if path == "/registration/verify" and method == "POST":
body = self._form_body(environ)
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
return self._verify_public_registration(
environ, start_response, correlation_id, body=body, browser=True
)
if path == "/registration/resume" and method == "POST":
body = self._form_body(environ)
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
return self._resume_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(
environ, start_response, correlation_id
@ -820,10 +858,11 @@ class PortalApplication:
environ: Mapping[str, Any],
start_response: StartResponse,
correlation_id: str,
*, body: Mapping[str, Any] | None = None, browser: bool = False,
) -> Iterable[bytes]:
if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable")
body = self._body(environ)
body = body if body is not None else self._body(environ)
username = self._registration_username(body.get("username"))
email = self._registration_email(body.get("email"))
display_name = str(body.get("display_name") or "").strip() or None
@ -865,6 +904,17 @@ class PortalApplication:
display_name=display_name,
)
)
if browser:
return self._html(
start_response,
self._page_html(
"Check your email",
"<h1>Check your email.</h1>"
"<p>If the address can be registered, a verification link is on its way. "
"The link expires after 30 minutes.</p>",
),
correlation_id,
)
return self._json(
start_response,
"202 Accepted",
@ -877,10 +927,11 @@ class PortalApplication:
environ: Mapping[str, Any],
start_response: StartResponse,
correlation_id: str,
*, body: Mapping[str, Any] | None = None, browser: bool = False,
) -> Iterable[bytes]:
if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable")
body = self._body(environ)
body = body if body is not None else self._body(environ)
evidence = self.registration_verification.consume(str(body.get("handle") or ""))
session = self.service.store.registration_session(evidence.registration_id)
if session is None:
@ -932,19 +983,32 @@ class PortalApplication:
return self._provision_public_registration(
start_response, actor, completion.user, completion.session,
evidence.normalized_email, evidence.display_name,
evidence.preferred_username, correlation_id,
evidence.preferred_username, correlation_id, browser=browser,
)
except RuntimeError:
if browser:
token = secrets.token_urlsafe(32)
return self._html(
start_response,
self._registration_resume_form(
token, updated.registration_id, resume_handle
),
correlation_id,
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
)
return self._json(start_response, "202 Accepted", {
"status": "provisioning_pending",
"registration_id": updated.registration_id,
"resume_handle": resume_handle,
}, correlation_id)
def _resume_public_registration(self, environ, start_response, correlation_id):
def _resume_public_registration(
self, environ, start_response, correlation_id, *,
body: Mapping[str, Any] | None = None, browser: bool = False,
):
if not self.public_registration:
raise NotFoundError("public registration is unavailable")
body = self._body(environ)
body = body if body is not None else self._body(environ)
session = self.service.store.registration_session(str(body.get("registration_id") or ""))
handle = str(body.get("resume_handle") or "")
digest = hashlib.sha256(handle.encode()).hexdigest()
@ -970,11 +1034,13 @@ class PortalApplication:
return self._provision_public_registration(
start_response, actor, user, session, email_factor.normalized_value,
user.display_name, str(session.applicant_username), correlation_id,
browser=browser,
)
def _provision_public_registration(
self, start_response, actor, user, session, email, display_name,
preferred_username, correlation_id,
*, browser: bool = False,
):
provisioned = self.provisioning.provision(
ProvisioningRequest(
@ -1001,6 +1067,17 @@ class PortalApplication:
)
if provisioned.password_setup_url:
self._validate_registration_handoff(provisioned.password_setup_url)
if browser:
return self._html(
start_response,
self._page_html(
"Create your password",
"<h1>Your identity is ready.</h1>"
"<p>Continue to the protected identity service to create your password.</p>"
f'<p><a class="button" rel="noreferrer" href="{escape(provisioned.password_setup_url)}">Create password</a></p>',
),
correlation_id,
)
return self._redirect(
start_response, provisioned.password_setup_url, correlation_id
)
@ -1075,6 +1152,22 @@ class PortalApplication:
if not supplied or not secrets.compare_digest(supplied, expected):
raise AuthorizationDenied("invalid CSRF token")
@staticmethod
def _registration_csrf_cookie(token: str) -> str:
return (
f"ue_registration_csrf={token}; Path=/; HttpOnly; Secure; "
"SameSite=Strict; Max-Age=1800"
)
def _require_registration_csrf(
self, environ: Mapping[str, Any], supplied: str
) -> None:
expected = cookie_value(
str(environ.get("HTTP_COOKIE", "")), "ue_registration_csrf"
)
if not supplied or not expected or not secrets.compare_digest(supplied, expected):
raise AuthorizationDenied("invalid registration CSRF token")
@staticmethod
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
query = parse_qs(str(environ.get("QUERY_STRING", "")))
@ -1101,7 +1194,14 @@ class PortalApplication:
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
if actor is not None
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
else (
f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
+ (
'<p>New here? <a href="/register">Create an account</a>.</p>'
if self.public_registration and self.registration_verification is not None
else ""
)
)
)
return self._page_html(
"Identity & access",
@ -1110,6 +1210,54 @@ class PortalApplication:
+ identity,
)
def _registration_form(self, csrf_token: str) -> str:
client_options = "".join(
f'<option value="{escape(item)}">{escape(item)}</option>'
for item in sorted(self.registration_clients)
)
tenant_options = "".join(
f'<option value="{escape(item)}">{escape(item.removeprefix("tenant:"))}</option>'
for item in sorted(self.registration_tenants)
)
return self._page_html(
"Create account",
f"""<h1>Create your account.</h1>
<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)}">
<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>
<label>Application <select name="client_id" required>{client_options}</select></label>
<label>Community <select name="tenant" required>{tenant_options}</select></label>
<button type="submit">Send verification email</button>
</form><p><a href="/login">Already have an account? Sign in</a></p>""",
)
def _registration_verification_form(self, csrf_token: str, handle: str) -> str:
return self._page_html(
"Verify email",
f"""<h1>Verify your email.</h1>
<p>Confirm to finish creating your identity. This verification link can be used once.</p>
<form method="post" action="/registration/verify">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="handle" value="{escape(handle)}">
<button type="submit">Verify and continue</button></form>""",
)
def _registration_resume_form(
self, csrf_token: str, registration_id: str, resume_handle: str
) -> str:
return self._page_html(
"Finish account setup",
f"""<h1>Your email is verified.</h1>
<p>The identity service is temporarily unavailable. Try again without repeating verification.</p>
<form method="post" action="/registration/resume">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="registration_id" value="{escape(registration_id)}">
<input type="hidden" name="resume_handle" value="{escape(resume_handle)}">
<button type="submit">Try identity setup again</button></form>""",
)
def _admin(
self, tenant: str, memberships: tuple[Any, ...],
invitations: tuple[Any, ...], diagnostics: Any,
@ -1328,9 +1476,17 @@ input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accen
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]:
def _html(
self, start_response: StartResponse, body: str, correlation_id: str,
*, extra_headers: list[tuple[str, str]] | None = None,
) -> list[bytes]:
data = body.encode()
start_response("200 OK", [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
start_response("200 OK", [
("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(data))),
*(extra_headers or []),
*self._security_headers(correlation_id),
])
return [data]
def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]: