Implement verified public registration flow
This commit is contained in:
parent
b7a57a50a4
commit
c36a09bded
12 changed files with 610 additions and 7 deletions
|
|
@ -13,14 +13,28 @@ from dataclasses import asdict, is_dataclass
|
|||
from enum import Enum
|
||||
from html import escape
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
from user_engine.domain import AccountStatus, FamilyMemberSpec
|
||||
from user_engine.domain import (
|
||||
AccountStatus,
|
||||
Actor,
|
||||
FactorVerification,
|
||||
FamilyMemberSpec,
|
||||
IdentityFactorType,
|
||||
PrincipalType,
|
||||
)
|
||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||
from user_engine.oidc import OIDCClient, cookie_value
|
||||
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest, TenantManagementPort
|
||||
from user_engine.ports import (
|
||||
IdentityProvisioningPort,
|
||||
ProvisioningRequest,
|
||||
RegistrationVerificationPort,
|
||||
RegistrationVerificationRequest,
|
||||
TenantManagementPort,
|
||||
)
|
||||
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||
|
||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||
|
|
@ -54,6 +68,11 @@ class PortalApplication:
|
|||
provisioning: IdentityProvisioningPort | None = None,
|
||||
tenant_management: TenantManagementPort | None = None,
|
||||
outbox_delivery: Callable[[Any], None] | None = None,
|
||||
registration_verification: RegistrationVerificationPort | None = None,
|
||||
registration_clients: tuple[str, ...] = (),
|
||||
registration_tenants: tuple[str, ...] = (),
|
||||
registration_oidc_issuer: str = "",
|
||||
registration_password_setup_origins: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
if len(trusted_proxy_secret) < 24:
|
||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
||||
|
|
@ -65,6 +84,13 @@ class PortalApplication:
|
|||
self.provisioning = provisioning
|
||||
self.tenant_management = tenant_management
|
||||
self.outbox_delivery = outbox_delivery
|
||||
self.registration_verification = registration_verification
|
||||
self.registration_clients = frozenset(registration_clients)
|
||||
self.registration_tenants = frozenset(registration_tenants)
|
||||
self.registration_oidc_issuer = registration_oidc_issuer.rstrip("/")
|
||||
self.registration_password_setup_origins = frozenset(
|
||||
origin.rstrip("/") for origin in registration_password_setup_origins
|
||||
)
|
||||
|
||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
||||
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||
|
|
@ -143,6 +169,15 @@ class PortalApplication:
|
|||
actor = self._optional_actor(environ)
|
||||
return self._html(start_response, self._home(actor), correlation_id)
|
||||
|
||||
if path == "/api/v1/public/registrations" and method == "POST":
|
||||
return self._start_public_registration(
|
||||
environ, start_response, correlation_id
|
||||
)
|
||||
if path == "/api/v1/public/registrations/verify" and method == "POST":
|
||||
return self._verify_public_registration(
|
||||
environ, start_response, correlation_id
|
||||
)
|
||||
|
||||
actor = self._actor(environ)
|
||||
if path == "/api/v1/me" and method == "GET":
|
||||
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
|
||||
|
|
@ -774,6 +809,171 @@ class PortalApplication:
|
|||
def _actor(self, environ: Mapping[str, Any]) -> Any:
|
||||
return self.service.identity_adapter.normalize(self._claims(environ))
|
||||
|
||||
def _start_public_registration(
|
||||
self,
|
||||
environ: Mapping[str, Any],
|
||||
start_response: StartResponse,
|
||||
correlation_id: str,
|
||||
) -> Iterable[bytes]:
|
||||
if not self.public_registration or self.registration_verification is None:
|
||||
raise NotFoundError("public registration is unavailable")
|
||||
body = 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
|
||||
if display_name is not None and len(display_name) > 200:
|
||||
raise ValidationError("display_name is too long")
|
||||
client_id = str(body.get("client_id") or "")
|
||||
tenant = str(body.get("tenant") or "")
|
||||
if client_id not in self.registration_clients:
|
||||
raise ValidationError("client_id is not eligible for registration")
|
||||
if tenant not in self.registration_tenants:
|
||||
raise ValidationError("tenant is not eligible for registration")
|
||||
|
||||
applicant_subject = f"applicant_{secrets.token_hex(16)}"
|
||||
actor = Actor(
|
||||
issuer="urn:netkingdom:public-registration",
|
||||
subject=applicant_subject,
|
||||
tenant=tenant,
|
||||
principal_type=PrincipalType.HUMAN,
|
||||
audience=("user-engine",),
|
||||
roles=("registration-applicant",),
|
||||
authorized_party=client_id,
|
||||
preferred_username=username,
|
||||
)
|
||||
session = self.service.start_registration(
|
||||
actor,
|
||||
tenant=tenant,
|
||||
correlation_id=correlation_id,
|
||||
applicant_username=username,
|
||||
client_id=client_id,
|
||||
)
|
||||
self.registration_verification.request(
|
||||
RegistrationVerificationRequest(
|
||||
registration_id=session.registration_id,
|
||||
normalized_email=email,
|
||||
preferred_username=username,
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
correlation_id=correlation_id,
|
||||
display_name=display_name,
|
||||
)
|
||||
)
|
||||
return self._json(
|
||||
start_response,
|
||||
"202 Accepted",
|
||||
{"status": "verification_requested"},
|
||||
correlation_id,
|
||||
)
|
||||
|
||||
def _verify_public_registration(
|
||||
self,
|
||||
environ: Mapping[str, Any],
|
||||
start_response: StartResponse,
|
||||
correlation_id: str,
|
||||
) -> Iterable[bytes]:
|
||||
if not self.public_registration or self.registration_verification is None:
|
||||
raise NotFoundError("public registration is unavailable")
|
||||
body = 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:
|
||||
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("verification 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,
|
||||
)
|
||||
updated = self.service.attach_registration_factor(
|
||||
actor,
|
||||
session.registration_id,
|
||||
FactorVerification(
|
||||
factor_type=IdentityFactorType.EMAIL,
|
||||
normalized_value=evidence.normalized_email,
|
||||
verification_id=evidence.verification_id,
|
||||
source_system=evidence.source_system,
|
||||
assurance=dict(evidence.assurance),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if self.provisioning is None or not self.registration_oidc_issuer:
|
||||
raise RuntimeError("public registration provisioning is unavailable")
|
||||
completion = self.service.complete_registration(
|
||||
actor,
|
||||
updated.registration_id,
|
||||
display_name=evidence.display_name,
|
||||
primary_email=evidence.normalized_email,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
provisioned = self.provisioning.provision(
|
||||
ProvisioningRequest(
|
||||
user_id=completion.user.user_id,
|
||||
tenant=updated.tenant,
|
||||
primary_email=evidence.normalized_email,
|
||||
display_name=evidence.display_name,
|
||||
preferred_username=evidence.preferred_username,
|
||||
idempotency_key=f"public-registration-{updated.registration_id}",
|
||||
correlation_id=correlation_id,
|
||||
roles=("user",),
|
||||
)
|
||||
)
|
||||
self.service.link_identity(
|
||||
actor,
|
||||
completion.user.user_id,
|
||||
issuer=self.registration_oidc_issuer,
|
||||
subject=provisioned.external_subject,
|
||||
provider=provisioned.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if provisioned.password_setup_url:
|
||||
self._validate_registration_handoff(provisioned.password_setup_url)
|
||||
return self._redirect(
|
||||
start_response, provisioned.password_setup_url, correlation_id
|
||||
)
|
||||
return self._json(
|
||||
start_response,
|
||||
"200 OK",
|
||||
{"status": "identity_ready", "login_required": True},
|
||||
correlation_id,
|
||||
)
|
||||
|
||||
def _validate_registration_handoff(self, setup_url: str) -> None:
|
||||
parsed = urlsplit(setup_url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.netloc
|
||||
or origin not in self.registration_password_setup_origins
|
||||
):
|
||||
raise ValidationError("password setup handoff is not allow-listed")
|
||||
|
||||
@staticmethod
|
||||
def _registration_username(value: Any) -> str:
|
||||
username = str(value or "").strip().lower()
|
||||
if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", username):
|
||||
raise ValidationError("username is invalid")
|
||||
if username in {"admin", "administrator", "platform-root", "root", "system"}:
|
||||
raise ValidationError("username is reserved")
|
||||
return username
|
||||
|
||||
@staticmethod
|
||||
def _registration_email(value: Any) -> str:
|
||||
email = str(value or "").strip().lower()
|
||||
if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
|
||||
raise ValidationError("email is invalid")
|
||||
return email
|
||||
|
||||
def _optional_actor(self, environ: Mapping[str, Any]) -> Any | None:
|
||||
try:
|
||||
return self._actor(environ)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue