Implement verified public registration flow
This commit is contained in:
parent
b7a57a50a4
commit
c36a09bded
12 changed files with 610 additions and 7 deletions
|
|
@ -29,6 +29,7 @@
|
||||||
| workplan | USER-WP-0019 | finished | — | workplans/USER-WP-0019-provider-backed-postgres-conformance.md |
|
| workplan | USER-WP-0019 | finished | — | workplans/USER-WP-0019-provider-backed-postgres-conformance.md |
|
||||||
| workplan | USER-WP-0020 | finished | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
|
| workplan | USER-WP-0020 | finished | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
|
||||||
| workplan | USER-WP-0021 | active | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
| workplan | USER-WP-0021 | active | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
||||||
|
| workplan | USER-WP-0022 | active | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
||||||
| task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
|
| task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
|
||||||
| task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
|
| task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
|
||||||
| task | USER-WP-0001-T3 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
|
| task | USER-WP-0001-T3 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
|
||||||
|
|
@ -159,3 +160,8 @@
|
||||||
| task | USER-WP-0021-T03 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
| task | USER-WP-0021-T03 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
||||||
| task | USER-WP-0021-T04 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
| task | USER-WP-0021-T04 | done | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
||||||
| task | USER-WP-0021-T05 | wait | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
| task | USER-WP-0021-T05 | wait | — | workplans/USER-WP-0021-portal-product-expansion.md |
|
||||||
|
| task | USER-WP-0022-T01 | wait | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
||||||
|
| task | USER-WP-0022-T02 | wait | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
||||||
|
| task | USER-WP-0022-T03 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
||||||
|
| task | USER-WP-0022-T04 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
||||||
|
| task | USER-WP-0022-T05 | todo | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
|
||||||
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
|
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
|
||||||
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
|
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
|
||||||
from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter
|
from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter
|
||||||
|
from user_engine.adapters.registration_verification import (
|
||||||
|
HTTPRegistrationVerificationAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InMemoryUserEngineStore",
|
"InMemoryUserEngineStore",
|
||||||
|
|
@ -20,4 +23,5 @@ __all__ = [
|
||||||
"HTTPTenantManagementAdapter",
|
"HTTPTenantManagementAdapter",
|
||||||
"FlexAuthHTTPAdapter",
|
"FlexAuthHTTPAdapter",
|
||||||
"HTTPOutboxDeliveryAdapter",
|
"HTTPOutboxDeliveryAdapter",
|
||||||
|
"HTTPRegistrationVerificationAdapter",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class HTTPIdentityProvisioningAdapter:
|
||||||
"idempotency_key": request.idempotency_key,
|
"idempotency_key": request.idempotency_key,
|
||||||
"correlation_id": request.correlation_id,
|
"correlation_id": request.correlation_id,
|
||||||
"roles": request.roles,
|
"roles": request.roles,
|
||||||
|
"preferred_username": request.preferred_username,
|
||||||
})
|
})
|
||||||
|
|
||||||
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
||||||
|
|
@ -88,6 +89,7 @@ class HTTPIdentityProvisioningAdapter:
|
||||||
"idempotency_key": request.idempotency_key,
|
"idempotency_key": request.idempotency_key,
|
||||||
"correlation_id": request.correlation_id,
|
"correlation_id": request.correlation_id,
|
||||||
"roles": request.roles,
|
"roles": request.roles,
|
||||||
|
"preferred_username": request.preferred_username,
|
||||||
"desired_status": desired_status,
|
"desired_status": desired_status,
|
||||||
})
|
})
|
||||||
return IdentityDriftResult(
|
return IdentityDriftResult(
|
||||||
|
|
|
||||||
86
src/user_engine/adapters/registration_verification.py
Normal file
86
src/user_engine/adapters/registration_verification.py
Normal 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
|
||||||
|
|
@ -577,6 +577,8 @@ class RegistrationSession:
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
netkingdom_id: str | None = None
|
netkingdom_id: str | None = None
|
||||||
started_by_subject: str | None = None
|
started_by_subject: str | None = None
|
||||||
|
applicant_username: str | None = None
|
||||||
|
client_id: str | None = None
|
||||||
correlation_id: str | None = None
|
correlation_id: str | None = None
|
||||||
created_at: datetime = field(default_factory=utc_now)
|
created_at: datetime = field(default_factory=utc_now)
|
||||||
updated_at: datetime = field(default_factory=utc_now)
|
updated_at: datetime = field(default_factory=utc_now)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,43 @@ class ProvisioningRequest:
|
||||||
idempotency_key: str
|
idempotency_key: str
|
||||||
correlation_id: str
|
correlation_id: str
|
||||||
roles: tuple[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)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -133,6 +170,18 @@ class IdentityProvisioningPort(Protocol):
|
||||||
"""Converge managed provider state toward the requested lifecycle."""
|
"""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):
|
class UserEngineStore(Protocol):
|
||||||
"""Durable persistence boundary for user-engine service behavior.
|
"""Durable persistence boundary for user-engine service behavior.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from wsgiref.simple_server import make_server
|
||||||
from user_engine.adapters import (
|
from user_engine.adapters import (
|
||||||
FlexAuthHTTPAdapter,
|
FlexAuthHTTPAdapter,
|
||||||
HTTPOutboxDeliveryAdapter,
|
HTTPOutboxDeliveryAdapter,
|
||||||
|
HTTPRegistrationVerificationAdapter,
|
||||||
PostgresUserEngineStore,
|
PostgresUserEngineStore,
|
||||||
VerifiedIdentityClaimsAdapter,
|
VerifiedIdentityClaimsAdapter,
|
||||||
HTTPIdentityProvisioningAdapter,
|
HTTPIdentityProvisioningAdapter,
|
||||||
|
|
@ -74,6 +75,33 @@ def create_application() -> PortalApplication:
|
||||||
),
|
),
|
||||||
tenant_management=tenant_management,
|
tenant_management=tenant_management,
|
||||||
outbox_delivery=outbox_delivery,
|
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()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,8 @@ class UserEngineService:
|
||||||
IdentityFactorType.EMAIL,
|
IdentityFactorType.EMAIL,
|
||||||
),
|
),
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
|
applicant_username: str | None = None,
|
||||||
|
client_id: str | None = None,
|
||||||
) -> RegistrationSession:
|
) -> RegistrationSession:
|
||||||
tenant_context = self.resolve_tenant_context(actor, tenant)
|
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||||
correlation_id = correlation_id or new_id("corr")
|
correlation_id = correlation_id or new_id("corr")
|
||||||
|
|
@ -406,6 +408,8 @@ class UserEngineService:
|
||||||
),
|
),
|
||||||
required_factor_types=required,
|
required_factor_types=required,
|
||||||
started_by_subject=actor.subject,
|
started_by_subject=actor.subject,
|
||||||
|
applicant_username=applicant_username,
|
||||||
|
client_id=client_id,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
with self.store.transaction():
|
with self.store.transaction():
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,28 @@ from dataclasses import asdict, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from html import escape
|
from html import escape
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Any, Callable, Iterable, Mapping
|
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.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||||
from user_engine.oidc import OIDCClient, cookie_value
|
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
|
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||||
|
|
||||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||||
|
|
@ -54,6 +68,11 @@ class PortalApplication:
|
||||||
provisioning: IdentityProvisioningPort | None = None,
|
provisioning: IdentityProvisioningPort | None = None,
|
||||||
tenant_management: TenantManagementPort | None = None,
|
tenant_management: TenantManagementPort | None = None,
|
||||||
outbox_delivery: Callable[[Any], None] | 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:
|
) -> None:
|
||||||
if len(trusted_proxy_secret) < 24:
|
if len(trusted_proxy_secret) < 24:
|
||||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
||||||
|
|
@ -65,6 +84,13 @@ class PortalApplication:
|
||||||
self.provisioning = provisioning
|
self.provisioning = provisioning
|
||||||
self.tenant_management = tenant_management
|
self.tenant_management = tenant_management
|
||||||
self.outbox_delivery = outbox_delivery
|
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]:
|
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)}"
|
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)
|
actor = self._optional_actor(environ)
|
||||||
return self._html(start_response, self._home(actor), correlation_id)
|
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)
|
actor = self._actor(environ)
|
||||||
if path == "/api/v1/me" and method == "GET":
|
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)
|
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:
|
def _actor(self, environ: Mapping[str, Any]) -> Any:
|
||||||
return self.service.identity_adapter.normalize(self._claims(environ))
|
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:
|
def _optional_actor(self, environ: Mapping[str, Any]) -> Any | None:
|
||||||
try:
|
try:
|
||||||
return self._actor(environ)
|
return self._actor(environ)
|
||||||
|
|
|
||||||
68
tests/test_registration_verification_adapter.py
Normal file
68
tests/test_registration_verification_adapter.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from user_engine.adapters.registration_verification import (
|
||||||
|
HTTPRegistrationVerificationAdapter,
|
||||||
|
)
|
||||||
|
from user_engine.ports import RegistrationVerificationRequest
|
||||||
|
|
||||||
|
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *args): return None
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationVerificationAdapterTests(unittest.TestCase):
|
||||||
|
@patch("user_engine.adapters.registration_verification.urlopen")
|
||||||
|
def test_request_sends_bound_context_and_returns_only_opaque_receipt(self, opener):
|
||||||
|
opener.return_value = Response(
|
||||||
|
json.dumps({"request_id": "vrq_opaque", "accepted": True}).encode()
|
||||||
|
)
|
||||||
|
adapter = HTTPRegistrationVerificationAdapter(
|
||||||
|
base_url="http://verification", bearer_token="secret\n"
|
||||||
|
)
|
||||||
|
receipt = adapter.request(RegistrationVerificationRequest(
|
||||||
|
registration_id="reg_1",
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
preferred_username="person",
|
||||||
|
client_id="coulomb-social",
|
||||||
|
tenant="tenant:coulomb",
|
||||||
|
correlation_id="corr_1",
|
||||||
|
))
|
||||||
|
self.assertEqual("vrq_opaque", receipt.request_id)
|
||||||
|
request = opener.call_args.args[0]
|
||||||
|
self.assertEqual("Bearer secret", request.headers["Authorization"])
|
||||||
|
self.assertNotIn(b"return_to", request.data)
|
||||||
|
|
||||||
|
@patch("user_engine.adapters.registration_verification.urlopen")
|
||||||
|
def test_consume_requires_registration_purpose_and_normalizes_email(self, opener):
|
||||||
|
opener.return_value = Response(json.dumps({
|
||||||
|
"purpose": "public-registration",
|
||||||
|
"verification_id": "fvr_1",
|
||||||
|
"registration_id": "reg_1",
|
||||||
|
"email": "Person@Example.Test ",
|
||||||
|
"preferred_username": "person",
|
||||||
|
"client_id": "coulomb-social",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
"source_system": "mail-verifier",
|
||||||
|
"assurance": {"mailbox_control": True},
|
||||||
|
}).encode())
|
||||||
|
adapter = HTTPRegistrationVerificationAdapter(
|
||||||
|
base_url="http://verification", bearer_token="secret"
|
||||||
|
)
|
||||||
|
evidence = adapter.consume("x" * 32)
|
||||||
|
self.assertEqual("person@example.test", evidence.normalized_email)
|
||||||
|
self.assertTrue(evidence.assurance["mailbox_control"])
|
||||||
|
|
||||||
|
@patch("user_engine.adapters.registration_verification.urlopen")
|
||||||
|
def test_consume_rejects_wrong_purpose(self, opener):
|
||||||
|
opener.return_value = Response(json.dumps({
|
||||||
|
"purpose": "password-reset"
|
||||||
|
}).encode())
|
||||||
|
adapter = HTTPRegistrationVerificationAdapter(
|
||||||
|
base_url="http://verification", bearer_token="secret"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "wrong purpose"):
|
||||||
|
adapter.consume("x" * 32)
|
||||||
|
|
@ -11,7 +11,13 @@ from user_engine.domain import (
|
||||||
OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff,
|
OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff,
|
||||||
)
|
)
|
||||||
from user_engine.oidc import BrowserSession, OIDCClient
|
from user_engine.oidc import BrowserSession, OIDCClient
|
||||||
from user_engine.ports import IdentityDriftResult, ProvisioningResult, TenantProvisioningResult
|
from user_engine.ports import (
|
||||||
|
IdentityDriftResult,
|
||||||
|
ProvisioningResult,
|
||||||
|
RegistrationVerificationReceipt,
|
||||||
|
TenantProvisioningResult,
|
||||||
|
VerifiedRegistrationApplicant,
|
||||||
|
)
|
||||||
from user_engine.service import UserEngineService
|
from user_engine.service import UserEngineService
|
||||||
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
|
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
|
||||||
from user_engine.web import PortalApplication
|
from user_engine.web import PortalApplication
|
||||||
|
|
@ -176,6 +182,112 @@ class PortalApplicationTests(unittest.TestCase):
|
||||||
self.assertEqual("corr_test", result["headers"]["X-Request-ID"])
|
self.assertEqual("corr_test", result["headers"]["X-Request-ID"])
|
||||||
self.assertEqual("factor_pending", json.loads(payload)["status"])
|
self.assertEqual("factor_pending", json.loads(payload)["status"])
|
||||||
|
|
||||||
|
def test_public_registration_start_and_verified_factor_are_bound(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"}
|
||||||
|
)
|
||||||
|
started, payload = invoke(
|
||||||
|
self.app,
|
||||||
|
"/api/v1/public/registrations",
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
|
"username": "New.Person",
|
||||||
|
"email": "New.Person@Example.Test",
|
||||||
|
"display_name": "New Person",
|
||||||
|
"client_id": "coulomb-social",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
"return_to": "https://evil.example",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("202 Accepted", started["status"])
|
||||||
|
self.assertEqual({"status": "verification_requested"}, json.loads(payload))
|
||||||
|
self.assertEqual("new.person", verifier.requested.preferred_username)
|
||||||
|
self.assertEqual("new.person@example.test", verifier.requested.normalized_email)
|
||||||
|
|
||||||
|
verifier.registration_id = verifier.requested.registration_id
|
||||||
|
verified, payload = invoke(
|
||||||
|
self.app,
|
||||||
|
"/api/v1/public/registrations/verify",
|
||||||
|
method="POST",
|
||||||
|
body={"handle": "x" * 32},
|
||||||
|
)
|
||||||
|
self.assertEqual("303 See Other", verified["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
"https://kc.example/setup/password?token=opaque",
|
||||||
|
verified["headers"]["Location"],
|
||||||
|
)
|
||||||
|
self.assertEqual(1, self.app.service.operability_snapshot().metrics["users"])
|
||||||
|
session = self.app.service.store.registration_session(verifier.registration_id)
|
||||||
|
self.assertEqual("completed", session.status.value)
|
||||||
|
identities = self.app.service.store.identities_for_user(session.user_id)
|
||||||
|
self.assertTrue(any(
|
||||||
|
identity.issuer == "https://kc.example"
|
||||||
|
and identity.subject == "new.person"
|
||||||
|
for identity in identities
|
||||||
|
))
|
||||||
|
provision = self.app.provisioning.requests[-1]
|
||||||
|
self.assertEqual(("user",), provision.roles)
|
||||||
|
self.assertEqual("new.person", provision.preferred_username)
|
||||||
|
self.assertEqual(
|
||||||
|
f"public-registration-{verifier.registration_id}",
|
||||||
|
provision.idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_registration_rejects_untrusted_password_setup_origin(self):
|
||||||
|
verifier = FakeRegistrationVerification()
|
||||||
|
self.app.registration_verification = verifier
|
||||||
|
self.app.provisioning = FakeProvisioning(
|
||||||
|
password_setup_url="https://evil.example/setup"
|
||||||
|
)
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
invoke(
|
||||||
|
self.app,
|
||||||
|
"/api/v1/public/registrations",
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
|
"username": "person",
|
||||||
|
"email": "person@example.test",
|
||||||
|
"client_id": "coulomb-social",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
verifier.registration_id = verifier.requested.registration_id
|
||||||
|
result, _ = invoke(
|
||||||
|
self.app,
|
||||||
|
"/api/v1/public/registrations/verify",
|
||||||
|
method="POST",
|
||||||
|
body={"handle": "x" * 32},
|
||||||
|
)
|
||||||
|
self.assertEqual("400 Bad Request", result["status"])
|
||||||
|
|
||||||
|
def test_public_registration_rejects_unregistered_client(self):
|
||||||
|
self.app.registration_verification = FakeRegistrationVerification()
|
||||||
|
self.app.registration_clients = frozenset({"coulomb-social"})
|
||||||
|
self.app.registration_tenants = frozenset({"tenant:coulomb"})
|
||||||
|
result, _ = invoke(
|
||||||
|
self.app,
|
||||||
|
"/api/v1/public/registrations",
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
|
"username": "person",
|
||||||
|
"email": "person@example.test",
|
||||||
|
"client_id": "unknown",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("400 Bad Request", result["status"])
|
||||||
|
|
||||||
def test_provision_api_links_provider_subject(self):
|
def test_provision_api_links_provider_subject(self):
|
||||||
self.app.provisioning = FakeProvisioning()
|
self.app.provisioning = FakeProvisioning()
|
||||||
created, payload = invoke(
|
created, payload = invoke(
|
||||||
|
|
@ -602,17 +714,43 @@ class PortalApplicationTests(unittest.TestCase):
|
||||||
self.assertIsNotNone(user.profile_completed_at)
|
self.assertIsNotNone(user.profile_completed_at)
|
||||||
|
|
||||||
|
|
||||||
class FakeProvisioning:
|
class FakeRegistrationVerification:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.requested = None
|
||||||
|
self.registration_id = None
|
||||||
|
|
||||||
|
def request(self, request):
|
||||||
|
self.requested = request
|
||||||
|
return RegistrationVerificationReceipt(request_id="vrq_test")
|
||||||
|
|
||||||
|
def consume(self, opaque_handle):
|
||||||
|
return VerifiedRegistrationApplicant(
|
||||||
|
verification_id="fvr_test",
|
||||||
|
registration_id=self.registration_id,
|
||||||
|
normalized_email=self.requested.normalized_email,
|
||||||
|
preferred_username=self.requested.preferred_username,
|
||||||
|
client_id=self.requested.client_id,
|
||||||
|
tenant=self.requested.tenant,
|
||||||
|
source_system="mail-verifier",
|
||||||
|
assurance={"mailbox_control": True},
|
||||||
|
display_name=self.requested.display_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProvisioning:
|
||||||
|
def __init__(self, password_setup_url="https://kc.example/setup/password?token=opaque"):
|
||||||
self.actions = []
|
self.actions = []
|
||||||
|
self.requests = []
|
||||||
|
self.password_setup_url = password_setup_url
|
||||||
|
|
||||||
def provision(self, request):
|
def provision(self, request):
|
||||||
|
self.requests.append(request)
|
||||||
self.actions.append(("provision", request.primary_email))
|
self.actions.append(("provision", request.primary_email))
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
provider="netkingdom-lldap",
|
provider="netkingdom-lldap",
|
||||||
external_subject=request.primary_email.split("@")[0],
|
external_subject=request.preferred_username or request.primary_email.split("@")[0],
|
||||||
status="password_setup_required",
|
status="password_setup_required",
|
||||||
password_setup_url="https://kc.example/setup/password?token=opaque",
|
password_setup_url=self.password_setup_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
def suspend(self, *, external_subject, idempotency_key, correlation_id):
|
def suspend(self, *, external_subject, idempotency_key, correlation_id):
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,22 @@ slice added `RegistrationVerificationPort`, bound request/receipt/evidence
|
||||||
types, and a fail-closed HTTP adapter with purpose checking and normalized
|
types, and a fail-closed HTTP adapter with purpose checking and normalized
|
||||||
mailbox evidence. Adapter tests and the full 123-test suite pass.
|
mailbox evidence. Adapter tests and the full 123-test suite pass.
|
||||||
|
|
||||||
|
Second code slice adds explicit allow-listed anonymous endpoints:
|
||||||
|
`POST /api/v1/public/registrations` returns only a generic 202 status, while
|
||||||
|
`POST /api/v1/public/registrations/verify` consumes purpose-bound evidence and
|
||||||
|
attaches the verified email factor. Persisted registration sessions bind the
|
||||||
|
random applicant subject, canonical username, client, and tenant. Evidence
|
||||||
|
mismatch fails closed, browser-supplied return URLs are ignored, and this
|
||||||
|
transition creates neither a user nor an LLDAP identity. Full suite now passes
|
||||||
|
125 tests with 3 environment-dependent skips.
|
||||||
|
|
||||||
|
Third code slice completes the verified happy path: create the local user,
|
||||||
|
call identity-provisioner with deterministic registration idempotency and only
|
||||||
|
the baseline `user` role, link the returned subject under the configured
|
||||||
|
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.
|
||||||
|
|
||||||
## T02 - Orchestrate provider identity creation
|
## T02 - Orchestrate provider identity creation
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue