Clarify account session controls and add authenticator recovery guidance
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
61dc76739f
commit
a9ff77d21d
8 changed files with 395 additions and 26 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue