diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 9939c3a..37a3dff 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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", + "
If the address can be registered, a verification link is on its way. " + "The link expires after 30 minutes.
", + ), + 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", + "Continue to the protected identity service to create your password.
" + f'', + ), + 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"Signed in as {escape(actor.preferred_username)}.
" '' if actor is not None - else f'' + else ( + f'' + + ( + 'New here? Create an account.
' + 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'' + for item in sorted(self.registration_clients) + ) + tenant_options = "".join( + f'' + for item in sorted(self.registration_tenants) + ) + return self._page_html( + "Create account", + f"""We will verify your email before creating an identity.
+Already have an account? Sign in
""", + ) + + def _registration_verification_form(self, csrf_token: str, handle: str) -> str: + return self._page_html( + "Verify email", + f"""Confirm to finish creating your identity. This verification link can be used once.
+""", + ) + + def _registration_resume_form( + self, csrf_token: str, registration_id: str, resume_handle: str + ) -> str: + return self._page_html( + "Finish account setup", + f"""The identity service is temporarily unavailable. Try again without repeating verification.
+""", + ) 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}}}}