Implement verified public registration flow
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 11:26:18 +02:00
parent b7a57a50a4
commit c36a09bded
12 changed files with 610 additions and 7 deletions

View file

@ -10,6 +10,9 @@ from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter
from user_engine.adapters.registration_verification import (
HTTPRegistrationVerificationAdapter,
)
__all__ = [
"InMemoryUserEngineStore",
@ -20,4 +23,5 @@ __all__ = [
"HTTPTenantManagementAdapter",
"FlexAuthHTTPAdapter",
"HTTPOutboxDeliveryAdapter",
"HTTPRegistrationVerificationAdapter",
]

View file

@ -36,6 +36,7 @@ class HTTPIdentityProvisioningAdapter:
"idempotency_key": request.idempotency_key,
"correlation_id": request.correlation_id,
"roles": request.roles,
"preferred_username": request.preferred_username,
})
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
@ -88,6 +89,7 @@ class HTTPIdentityProvisioningAdapter:
"idempotency_key": request.idempotency_key,
"correlation_id": request.correlation_id,
"roles": request.roles,
"preferred_username": request.preferred_username,
"desired_status": desired_status,
})
return IdentityDriftResult(

View file

@ -0,0 +1,86 @@
"""HTTP adapter for the NetKingdom mailbox-verification issuer."""
from __future__ import annotations
import json
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from user_engine.ports import (
RegistrationVerificationReceipt,
RegistrationVerificationRequest,
VerifiedRegistrationApplicant,
)
class HTTPRegistrationVerificationAdapter:
def __init__(
self, *, base_url: str, bearer_token: str, timeout: float = 5.0
) -> None:
self.base_url = base_url.rstrip("/")
self.bearer_token = bearer_token.strip()
if not self.bearer_token:
raise ValueError("bearer token must not be empty")
self.timeout = timeout
def request(
self, request: RegistrationVerificationRequest
) -> RegistrationVerificationReceipt:
result = self._post(
"/v1/registration-verifications",
{
"registration_id": request.registration_id,
"email": request.normalized_email,
"preferred_username": request.preferred_username,
"client_id": request.client_id,
"tenant": request.tenant,
"correlation_id": request.correlation_id,
"display_name": request.display_name,
},
)
return RegistrationVerificationReceipt(
request_id=str(result["request_id"]),
accepted=bool(result.get("accepted", True)),
)
def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
if len(opaque_handle) < 32:
raise ValueError("verification handle is invalid")
result = self._post(
"/v1/registration-verifications/consume", {"handle": opaque_handle}
)
if result.get("purpose") != "public-registration":
raise RuntimeError("verification evidence has the wrong purpose")
return VerifiedRegistrationApplicant(
verification_id=str(result["verification_id"]),
registration_id=str(result["registration_id"]),
normalized_email=str(result["email"]).strip().lower(),
preferred_username=str(result["preferred_username"]),
client_id=str(result["client_id"]),
tenant=str(result["tenant"]),
source_system=str(result["source_system"]),
assurance=dict(result.get("assurance", {})),
display_name=(
str(result["display_name"]) if result.get("display_name") else None
),
)
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
request = Request(
self.base_url + path,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {self.bearer_token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(request, timeout=self.timeout) as response:
return dict(json.loads(response.read()))
except HTTPError as exc:
# Do not reflect provider details that could reveal account state.
raise RuntimeError("registration verification was rejected") from exc
except (URLError, json.JSONDecodeError, KeyError) as exc:
raise RuntimeError("registration verification is unavailable") from exc

View file

@ -577,6 +577,8 @@ class RegistrationSession:
user_id: str | None = None
netkingdom_id: str | None = None
started_by_subject: str | None = None
applicant_username: str | None = None
client_id: str | None = None
correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)

View file

@ -55,6 +55,43 @@ class ProvisioningRequest:
idempotency_key: str
correlation_id: str
roles: tuple[str, ...] = ()
preferred_username: str | None = None
@dataclass(frozen=True)
class RegistrationVerificationRequest:
"""Non-secret request for an external mailbox-control challenge."""
registration_id: str
normalized_email: str
preferred_username: str
client_id: str
tenant: str
correlation_id: str
display_name: str | None = None
@dataclass(frozen=True)
class RegistrationVerificationReceipt:
"""Opaque receipt safe to use for correlation, not authentication."""
request_id: str
accepted: bool = True
@dataclass(frozen=True)
class VerifiedRegistrationApplicant:
"""Purpose-bound evidence returned after consuming a single-use handle."""
verification_id: str
registration_id: str
normalized_email: str
preferred_username: str
client_id: str
tenant: str
source_system: str
assurance: Mapping[str, Any]
display_name: str | None = None
@dataclass(frozen=True)
@ -133,6 +170,18 @@ class IdentityProvisioningPort(Protocol):
"""Converge managed provider state toward the requested lifecycle."""
class RegistrationVerificationPort(Protocol):
"""Mailbox verification issuer; token plaintext never enters domain state."""
def request(
self, request: RegistrationVerificationRequest
) -> RegistrationVerificationReceipt:
"""Request an anti-enumerating, purpose-bound verification message."""
def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
"""Atomically consume verified, unexpired applicant evidence."""
class UserEngineStore(Protocol):
"""Durable persistence boundary for user-engine service behavior.

View file

@ -8,6 +8,7 @@ from wsgiref.simple_server import make_server
from user_engine.adapters import (
FlexAuthHTTPAdapter,
HTTPOutboxDeliveryAdapter,
HTTPRegistrationVerificationAdapter,
PostgresUserEngineStore,
VerifiedIdentityClaimsAdapter,
HTTPIdentityProvisioningAdapter,
@ -74,6 +75,33 @@ def create_application() -> PortalApplication:
),
tenant_management=tenant_management,
outbox_delivery=outbox_delivery,
registration_verification=(
HTTPRegistrationVerificationAdapter(
base_url=_required("USER_ENGINE_REGISTRATION_VERIFICATION_URL"),
bearer_token=_required("USER_ENGINE_REGISTRATION_VERIFICATION_TOKEN"),
)
if os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
== "true"
else None
),
registration_clients=tuple(
item.strip()
for item in os.environ.get("USER_ENGINE_REGISTRATION_CLIENTS", "").split(",")
if item.strip()
),
registration_tenants=tuple(
item.strip()
for item in os.environ.get("USER_ENGINE_REGISTRATION_TENANTS", "").split(",")
if item.strip()
),
registration_oidc_issuer=_required("USER_ENGINE_OIDC_ISSUER"),
registration_password_setup_origins=tuple(
item.strip().rstrip("/")
for item in os.environ.get(
"USER_ENGINE_REGISTRATION_PASSWORD_SETUP_ORIGINS", ""
).split(",")
if item.strip()
),
)

View file

@ -384,6 +384,8 @@ class UserEngineService:
IdentityFactorType.EMAIL,
),
correlation_id: str | None = None,
applicant_username: str | None = None,
client_id: str | None = None,
) -> RegistrationSession:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
@ -406,6 +408,8 @@ class UserEngineService:
),
required_factor_types=required,
started_by_subject=actor.subject,
applicant_username=applicant_username,
client_id=client_id,
correlation_id=correlation_id,
)
with self.store.transaction():

View file

@ -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)