Clarify account session controls and add authenticator recovery guidance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 50s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 10:09:01 +02:00
parent 61dc76739f
commit a9ff77d21d
8 changed files with 395 additions and 26 deletions

View file

@ -66,6 +66,7 @@ def create_application() -> PortalApplication:
service,
trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"),
login_url=_required("USER_ENGINE_LOGIN_URL"),
mfa_management_url=os.environ.get("USER_ENGINE_MFA_MANAGEMENT_URL", ""),
public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
== "true",
oidc_client=OIDCClient(

View file

@ -48,6 +48,7 @@ StartResponse = Callable[[str, list[tuple[str, str]]], Any]
# Rendering state is scoped to one request, including concurrent WSGI requests.
_ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="")
_BROWSER_REQUEST: ContextVar[bool] = ContextVar("browser_request", default=False)
def _jsonable(value: Any) -> Any:
@ -74,6 +75,7 @@ class PortalApplication:
trusted_proxy_secret: str,
login_url: str,
public_registration: bool = True,
mfa_management_url: str = "",
oidc_client: OIDCClient | None = None,
provisioning: IdentityProvisioningPort | None = None,
tenant_management: TenantManagementPort | None = None,
@ -92,6 +94,14 @@ class PortalApplication:
self.trusted_proxy_secret = trusted_proxy_secret
self.login_url = login_url
self.public_registration = public_registration
if mfa_management_url:
destination = urlsplit(mfa_management_url)
if (destination.scheme != "https" or not destination.hostname
or destination.username or destination.password
or destination.query or destination.fragment
or any(c.isspace() for c in mfa_management_url)):
raise ValueError("MFA management URL must be a fixed HTTPS destination without credentials or query")
self.mfa_management_url = mfa_management_url
self.oidc_client = oidc_client
self.provisioning = provisioning
self.tenant_management = tenant_management
@ -113,7 +123,15 @@ class PortalApplication:
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)}"
navigation_token = _ACCOUNT_NAVIGATION.set("")
browser_token = _BROWSER_REQUEST.set(
"text/html" in str(environ.get("HTTP_ACCEPT", ""))
and not str(environ.get("PATH_INFO", "/")).startswith("/api/")
and str(environ.get("PATH_INFO", "/")) not in {"/healthz", "/readyz", "/metrics"}
)
try:
if (not str(environ.get("PATH_INFO", "/")).startswith("/api/")
and str(environ.get("PATH_INFO", "/")) not in {"/healthz", "/readyz", "/metrics"}):
self._set_account_navigation(environ, self._optional_actor(environ))
return self._dispatch(environ, start_response, str(correlation_id))
except ConflictError as exc:
return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id)
@ -135,6 +153,7 @@ class PortalApplication:
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
finally:
_ACCOUNT_NAVIGATION.reset(navigation_token)
_BROWSER_REQUEST.reset(browser_token)
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
@ -202,24 +221,25 @@ class PortalApplication:
identity = (
f'<p>This portal is signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>'
'<p><a class="button" href="/onboarding">View my account and access</a></p>'
if actor else '<p>Your identity has not been verified in this portal.</p>'
if actor else '<p>You are not signed in to this portal. Sign in to verify your identity and access.</p>'
)
return self._html(start_response, self._page_html(
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
'<p>The application may not allow this account, or the sign-in service may have failed.</p>'
'<p>Your account may not have access to the application, or sign-in may have been interrupted.</p>'
+ identity
+ '<p><a href="/login">Verify my current identity</a></p>'
'<p><a href="/logout">Log out or use another account</a></p>',
+ '<p><a href="/security">Help with passwords and verification codes</a></p>'
+ (self._identity_switch_help() if actor is None else ""),
), correlation_id)
if path == "/security" and method == "GET":
return self._html(start_response, self._security_page(), correlation_id)
if path == "/logged-out" and method == "GET":
if self._optional_actor(environ) is not None:
return self._redirect(start_response, "/", correlation_id)
return self._html(start_response, self._page_html(
"Logged out",
'<h1>You have logged out.</h1>'
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>'
+ self._shared_logout_link()
+ '<p><a class="button" href="/login">Sign in</a></p>',
"Not signed in",
'<h1>You are not signed in to this portal.</h1>'
'<p>Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.</p>'
+ self._identity_switch_help(),
), correlation_id)
if path == "/logout" and method == "GET":
actor = self._optional_actor(environ)
@ -252,6 +272,8 @@ class PortalApplication:
return self._html(start_response, self._home(actor), correlation_id)
if path == "/register" and method == "GET":
if self._optional_actor(environ) is not None:
return self._redirect(start_response, "/onboarding", correlation_id)
if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable")
token = secrets.token_urlsafe(32)
@ -1544,21 +1566,56 @@ class PortalApplication:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
return value
def _shared_logout_link(self) -> str:
def _identity_switch_help(self) -> str:
if not self.oidc_client:
return ""
return (
f'<p><a class="button" href="{escape(self.oidc_client.issuer)}/account/logout">'
'Sign out of NetKingdom to use another account</a></p>'
'<details><summary>Wrong account appears when signing in?</summary>'
'<p>You can clear the shared sign-in before choosing another account. '
'Other applications may keep their own sessions.</p>'
f'<p><a href="{escape(self.oidc_client.issuer)}/account/logout">'
'Use another account</a></p></details>'
)
def _security_page(self) -> str:
handoff = (
'<p><a class="button" rel="noreferrer" href="'
+ escape(self.mfa_management_url)
+ '">Manage authenticator app</a></p>'
'<p>The sign-in service will ask you to verify your identity. Check the account name there before making changes.</p>'
if self.mfa_management_url else
'<p role="status">Authenticator setup is temporarily unavailable. '
'If a code is requested before you have set up an authenticator, contact your tenant administrator.</p>'
)
return self._page_html("Sign-in security", """
<h1>Sign-in security</h1>
<p>Use this page for help with your password and authenticator app.</p>
<section><h2>Two-step verification</h2>
<p>An authenticator app generates a short-lived code to enter after your password.
This portal cannot currently confirm whether an authenticator is enabled for your account.</p>
""" + handoff + """
<details><summary>How to set up an authenticator when setup is available</summary>
<ol><li>Open authenticator management and check that it shows your account.</li>
<li>Choose to add an authenticator and scan its QR code with your authenticator app.</li>
<li>Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.</li>
<li>Follow the recovery instructions shown there, then test a new sign-in before closing your current session.</li></ol>
<p>Opening the setup page does not activate two-step verification. If you cancel, check the status in authenticator management before leaving.</p></details>
<details><summary>A code is rejected, or I have lost my authenticator</summary>
<p>Use the newest code for the correct account and check that your device sets its time automatically.
If you cannot use your authenticator, follow the sign-in service's recovery instructions or ask your tenant administrator for account recovery.
Never send your password, QR code, or verification codes to an administrator.</p></details></section>
<section><h2>Password help</h2><p>Use password recovery on the sign-in page.
If the recovery message does not arrive, ask your tenant administrator for a new password setup link.
Use the login name they provide; it may differ from your display name.</p></section>
<p><a href="/access-recovery">Back to sign-in help</a></p>""")
def _home(self, actor: Any | None) -> str:
identity = (
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
'<p><a class="button" href="/onboarding">View my account</a></p>'
if actor is not None
else (
f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
'<p>You are not signed in to this portal.</p>'
+ (
'<p>New here? <a href="/register">Create an account</a>.</p>'
if self.public_registration and self.registration_verification is not None
@ -1842,7 +1899,7 @@ class PortalApplication:
return self._page_html(
"Onboarding",
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
<section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section>
<section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p><a href="/security">Password and two-step verification help</a></p></section>
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
@ -1911,9 +1968,9 @@ class PortalApplication:
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
if actor is None:
_ACCOUNT_NAVIGATION.set("")
_ACCOUNT_NAVIGATION.set('<p>Not signed in to this portal</p><nav aria-label="Account navigation"><a href="/">Home</a><a href="/security">Sign-in help</a><a class="button" href="/login">Sign in</a></nav>')
return
links = '<a href="/">Home</a><a href="/onboarding">My account</a>'
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
if "platform-operator" in actor.roles:
links += '<a href="/platform">Platform administration</a>'
elif "tenant-admin" in actor.roles:
@ -1921,12 +1978,9 @@ class PortalApplication:
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
csrf = self.oidc_client.csrf_token(session_id or "") if self.oidc_client else None
if csrf:
links += (
'<form method="post" action="/logout">'
f'<input type="hidden" name="csrf_token" value="{escape(csrf)}">'
'<button type="submit">Log out</button></form>'
)
_ACCOUNT_NAVIGATION.set('<nav aria-label="Account navigation">' + links + '</nav>')
links += '<a href="/logout">Log out</a>'
identity = escape(actor.preferred_username or actor.subject)
_ACCOUNT_NAVIGATION.set(f'<p>Signed in to this portal as <strong>{identity}</strong></p><nav aria-label="Account navigation">' + links + '</nav>')
@staticmethod
def _page_html(title: str, body: str) -> str:
@ -1993,6 +2047,20 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o
return [data]
def _error(self, start_response: StartResponse, status: str, code: str, message: str, correlation_id: str) -> list[bytes]:
if _BROWSER_REQUEST.get():
title = "This action could not be completed"
guidance = "Check your account and access, then try again."
if code == "access_denied":
title = "Access is not available"
guidance = "You may need to sign in again, or your account may not have permission for this action."
elif code == "provisioning_unavailable":
guidance = "Account services are temporarily unavailable. Check the account status before retrying."
page = self._page_html(title, f'<h1>{title}</h1><p role="alert">{guidance}</p>'
'<p><a href="/access-recovery">Account and sign-in help</a></p>'
f'<p>If you need help, give your administrator this reference: <code>{escape(str(correlation_id))}</code>.</p>')
data = page.encode()
start_response(status, [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
return [data]
return self._json(start_response, status, {"error": {"code": code, "message": message, "correlation_id": correlation_id}}, correlation_id)
@staticmethod