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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue