Add public registration browser journey
This commit is contained in:
parent
c12bc604a8
commit
0669fa7a85
3 changed files with 231 additions and 14 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import io
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
|
|
@ -29,7 +30,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
|
|||
|
||||
def invoke(
|
||||
app, path, *, method="GET", claims=None, marker=SECRET, body=None,
|
||||
form=None, cookie=None, headers=None,
|
||||
form=None, cookie=None, headers=None, query="",
|
||||
):
|
||||
payload = (
|
||||
urlencode(form).encode()
|
||||
|
|
@ -39,7 +40,7 @@ def invoke(
|
|||
environ = {
|
||||
"REQUEST_METHOD": method,
|
||||
"PATH_INFO": path,
|
||||
"QUERY_STRING": "",
|
||||
"QUERY_STRING": query,
|
||||
"CONTENT_LENGTH": str(len(payload)),
|
||||
"wsgi.input": io.BytesIO(payload),
|
||||
"HTTP_X_REQUEST_ID": "corr_test",
|
||||
|
|
@ -239,6 +240,49 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
provision.idempotency_key,
|
||||
)
|
||||
|
||||
def test_public_registration_browser_journey_uses_csrf_and_confirmation(self):
|
||||
verifier = FakeRegistrationVerification()
|
||||
self.app.registration_verification = verifier
|
||||
self.app.provisioning = FakeProvisioning()
|
||||
self.app.registration_clients = frozenset({"coulomb-social"})
|
||||
self.app.registration_tenants = frozenset({"tenant:coulomb"})
|
||||
self.app.registration_oidc_issuer = "https://kc.example"
|
||||
self.app.registration_password_setup_origins = frozenset({"https://kc.example"})
|
||||
|
||||
page, html = invoke(self.app, "/register")
|
||||
self.assertEqual("200 OK", page["status"])
|
||||
self.assertIn(b"Create your account", html)
|
||||
cookie = page["headers"]["Set-Cookie"]
|
||||
token = re.search(rb'name="csrf_token" 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",
|
||||
"tenant": "tenant:coulomb",
|
||||
}, cookie=cookie)
|
||||
self.assertEqual("403 Forbidden", denied["status"])
|
||||
started, html = invoke(self.app, "/register", method="POST", form={
|
||||
"csrf_token": token, "username": "new.person",
|
||||
"email": "new@example.test", "display_name": "New Person",
|
||||
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
|
||||
}, cookie=cookie)
|
||||
self.assertEqual("200 OK", started["status"])
|
||||
self.assertIn(b"Check your email", html)
|
||||
|
||||
verifier.registration_id = verifier.requested.registration_id
|
||||
confirmation, html = invoke(
|
||||
self.app, "/registration/verify", query="handle=" + "x" * 32
|
||||
)
|
||||
self.assertEqual("200 OK", confirmation["status"])
|
||||
self.assertIn(b"Verify and continue", html)
|
||||
cookie = confirmation["headers"]["Set-Cookie"]
|
||||
token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode()
|
||||
completed, html = invoke(
|
||||
self.app, "/registration/verify", method="POST", cookie=cookie,
|
||||
form={"csrf_token": token, "handle": "x" * 32},
|
||||
)
|
||||
self.assertEqual("200 OK", completed["status"])
|
||||
self.assertIn(b"Create password", html)
|
||||
|
||||
def test_public_registration_rejects_untrusted_password_setup_origin(self):
|
||||
verifier = FakeRegistrationVerification()
|
||||
self.app.registration_verification = verifier
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ status: active
|
|||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-08-09"
|
||||
updated: "2026-08-09"
|
||||
updated: "2026-08-10"
|
||||
depends_on:
|
||||
- USER-WP-0021
|
||||
- NK-WP-0025
|
||||
|
|
@ -26,7 +26,7 @@ identity creation to NetKingdom.
|
|||
|
||||
```task
|
||||
id: USER-WP-0022-T01
|
||||
status: wait
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "43dd49b7-6dbc-4117-a401-6d9f6e59aa26"
|
||||
```
|
||||
|
|
@ -65,11 +65,21 @@ KeyCape issuer, and redirect only to an allow-listed HTTPS provider password
|
|||
setup origin. Full suite now passes 126 tests with 3 skips. Automated recovery
|
||||
after local completion/provider failure remains open before production enablement.
|
||||
|
||||
2026-08-10 browser increment: `/register` now provides the accessible public
|
||||
account form and the home page links to it only when registration is configured.
|
||||
Anonymous mutations use a short-lived Secure/HttpOnly/SameSite double-submit
|
||||
CSRF cookie. Verification links land on an explicit one-time confirmation page,
|
||||
successful provisioning presents the allow-listed provider password handoff,
|
||||
and provider outages render a retry form backed by the separate resume handle.
|
||||
Responses remain non-enumerating. The full suite passes 128 tests with three
|
||||
environment-dependent skips. Cancellation, ingress rate limiting, and the
|
||||
credential-gated production enablement remain.
|
||||
|
||||
## T02 - Orchestrate provider identity creation
|
||||
|
||||
```task
|
||||
id: USER-WP-0022-T02
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "74239ce4-5c1e-46cf-8abf-3cbec3f3f989"
|
||||
```
|
||||
|
|
@ -97,6 +107,13 @@ digest, retries with the same registration idempotency key and local user,
|
|||
and invalidates the handle after provider linking. Replay is denied. The full
|
||||
suite passes 127 tests with 3 environment-dependent skips.
|
||||
|
||||
2026-08-10 completion: identity-provisioner now validates and honors the
|
||||
canonical preferred username instead of silently deriving it from the mailbox.
|
||||
The corrected image is live at immutable digest
|
||||
`sha256:4a6ec93d74eed6e17065a7e5d4c3d8d0ed14078bdfda0112738438528c6b2680`.
|
||||
Together with the deterministic retry path, this completes the provider
|
||||
orchestration boundary without exposing provider credentials or passwords.
|
||||
|
||||
## T03 - Create application profiles on first login
|
||||
|
||||
```task
|
||||
|
|
@ -143,7 +160,7 @@ step-up. user-engine must not become the token assurance authority.
|
|||
|
||||
```task
|
||||
id: USER-WP-0022-T05
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue