Show an existing NetKingdom sign-in before the account site continues it.
The account site said "Not signed in" while Authelia still had a session, and Sign in reused that identity. Ask Authelia who the session cookie is, show that name, and send a fresh sign-in only when a different identity is requested. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
This commit is contained in:
parent
b987a3de9e
commit
560cdeed46
9 changed files with 500 additions and 19 deletions
114
src/user_engine/identity_state.py
Normal file
114
src/user_engine/identity_state.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Read an existing NetKingdom sign-in without creating an account-site session.
|
||||
|
||||
The account site receives the Authelia session cookie because that cookie is
|
||||
set for the parent domain. This module asks Authelia who that cookie is and
|
||||
returns only a confirmed username. It does not store the cookie, follow
|
||||
redirects, or treat the answer as an account-site session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
from user_engine.oidc import cookie_value
|
||||
|
||||
_IDENTITY_NAME = re.compile(r"^[A-Za-z0-9._@+-]{1,200}$")
|
||||
_SESSION_TOKEN = re.compile(r"^[A-Za-z0-9._~+/=-]{1,4096}$")
|
||||
_CLUSTER_HOST = ".svc.cluster.local"
|
||||
|
||||
|
||||
def is_identity_name(value: object) -> bool:
|
||||
return isinstance(value, str) and _IDENTITY_NAME.fullmatch(value) is not None
|
||||
|
||||
|
||||
def validate_identity_state_url(url: str) -> str:
|
||||
"""Accept the fixed Authelia state endpoint and reject anything else."""
|
||||
if any(character.isspace() for character in url):
|
||||
raise ValueError("identity state URL must not contain whitespace")
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or ""
|
||||
# Hostname matching is on the DNS label boundary. A name that only
|
||||
# contains the suffix, such as "svc.cluster.local.example", does not end
|
||||
# with ".svc.cluster.local".
|
||||
cluster = host.endswith(_CLUSTER_HOST)
|
||||
allowed = (parts.scheme == "https" and bool(host)) or (parts.scheme == "http" and cluster)
|
||||
if (
|
||||
not allowed
|
||||
or parts.username
|
||||
or parts.password
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
or parts.path != "/api/state"
|
||||
):
|
||||
raise ValueError(
|
||||
"identity state URL must be https://host/api/state "
|
||||
"or http://name.svc.cluster.local/api/state"
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def authelia_session_token(cookie_header: str) -> str | None:
|
||||
value = cookie_value(cookie_header, "authelia_session")
|
||||
if value is None or _SESSION_TOKEN.fullmatch(value) is None:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def username_from_state(payload: object) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if "status" in payload and payload.get("status") != "OK":
|
||||
return None
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
data = payload
|
||||
username = data.get("username")
|
||||
level = data.get("authentication_level")
|
||||
if type(level) is not int or level < 1 or not is_identity_name(username):
|
||||
return None
|
||||
return username
|
||||
|
||||
|
||||
class _RefuseRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
||||
raise URLError("identity state endpoint must not redirect")
|
||||
|
||||
|
||||
def _read_state(url: str, token: str, timeout: float) -> bytes:
|
||||
request = Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Cookie": f"authelia_session={token}",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
opener = build_opener(_RefuseRedirects)
|
||||
with opener.open(request, timeout=timeout) as response:
|
||||
return response.read(8192)
|
||||
|
||||
|
||||
class AutheliaIdentityState:
|
||||
"""Confirm the username on one Authelia session cookie."""
|
||||
|
||||
def __init__(self, state_url: str, *, timeout: float = 2.0, reader=_read_state) -> None:
|
||||
if timeout <= 0:
|
||||
raise ValueError("identity state timeout must be positive")
|
||||
self.state_url = validate_identity_state_url(state_url)
|
||||
self.timeout = timeout
|
||||
self._reader = reader
|
||||
|
||||
def username(self, cookie_header: str) -> str | None:
|
||||
token = authelia_session_token(cookie_header)
|
||||
if token is None:
|
||||
return None
|
||||
try:
|
||||
raw = self._reader(self.state_url, token, self.timeout)
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (HTTPError, URLError, TimeoutError, OSError, UnicodeError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return username_from_state(payload)
|
||||
|
|
@ -53,7 +53,14 @@ class OIDCClient:
|
|||
self.pending: dict[str, PendingLogin] = {}
|
||||
self.sessions: dict[str, BrowserSession] = {}
|
||||
|
||||
def begin(self, *, tenant_hint: str | None = None, recovery: bool = False, return_path: str = "/") -> str:
|
||||
def begin(
|
||||
self,
|
||||
*,
|
||||
tenant_hint: str | None = None,
|
||||
recovery: bool = False,
|
||||
fresh: bool = False,
|
||||
return_path: str = "/",
|
||||
) -> str:
|
||||
if return_path not in {"/", "/platform", "/platform/factor-recovery", "/platform/authentication-policy"}:
|
||||
raise ValueError("unsupported login return path")
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
|
@ -72,6 +79,10 @@ class OIDCClient:
|
|||
}
|
||||
if recovery:
|
||||
parameters.update(prompt="login", max_age="0", acr_values="aal2")
|
||||
elif fresh:
|
||||
# KeyCape asks Authelia for a session inside its short fresh window
|
||||
# and then requires that authentication to be newer than this request.
|
||||
parameters.update(prompt="login", max_age="0")
|
||||
if tenant_hint:
|
||||
parameters["tenant_hint"] = tenant_hint
|
||||
return f"{self.issuer}/authorize?{urlencode(parameters)}"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from user_engine.adapters import (
|
|||
HTTPTenantManagementAdapter,
|
||||
)
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.identity_state import AutheliaIdentityState
|
||||
from user_engine.oidc import OIDCClient
|
||||
from user_engine.web import PortalApplication
|
||||
from user_engine.factor_recovery import FactorRecoveryClient
|
||||
|
|
@ -118,6 +119,11 @@ def create_application() -> PortalApplication:
|
|||
registration_rate_window_seconds=int(
|
||||
os.environ.get("USER_ENGINE_REGISTRATION_RATE_WINDOW_SECONDS", "60")
|
||||
),
|
||||
identity_lookup=(
|
||||
AutheliaIdentityState(os.environ["USER_ENGINE_IDENTITY_STATE_URL"])
|
||||
if os.environ.get("USER_ENGINE_IDENTITY_STATE_URL")
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
from user_engine.operations_status import check_services
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from user_engine.domain import (
|
|||
PrincipalType,
|
||||
)
|
||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||
from user_engine.identity_state import is_identity_name
|
||||
from user_engine.oidc import OIDCClient, cookie_value
|
||||
from user_engine.ports import (
|
||||
IdentityProvisioningPort,
|
||||
|
|
@ -50,6 +51,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)
|
||||
_REQUEST_CACHE: ContextVar[dict[str, Any] | None] = ContextVar("portal_request_cache", default=None)
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
|
|
@ -89,6 +91,7 @@ class PortalApplication:
|
|||
registration_password_setup_origins: tuple[str, ...] = (),
|
||||
registration_rate_limit: int = 10,
|
||||
registration_rate_window_seconds: int = 60,
|
||||
identity_lookup: Any | None = None,
|
||||
) -> None:
|
||||
if len(trusted_proxy_secret) < 24:
|
||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
||||
|
|
@ -124,10 +127,12 @@ class PortalApplication:
|
|||
self.registration_rate_window_seconds = registration_rate_window_seconds
|
||||
self._registration_attempts: dict[str, deque[float]] = {}
|
||||
self._registration_attempts_lock = Lock()
|
||||
self.identity_lookup = identity_lookup
|
||||
|
||||
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("")
|
||||
cache_token = _REQUEST_CACHE.set({})
|
||||
browser_token = _BROWSER_REQUEST.set(
|
||||
"text/html" in str(environ.get("HTTP_ACCEPT", ""))
|
||||
and not str(environ.get("PATH_INFO", "/")).startswith("/api/")
|
||||
|
|
@ -158,6 +163,7 @@ class PortalApplication:
|
|||
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
|
||||
finally:
|
||||
_ACCOUNT_NAVIGATION.reset(navigation_token)
|
||||
_REQUEST_CACHE.reset(cache_token)
|
||||
_BROWSER_REQUEST.reset(browser_token)
|
||||
|
||||
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
|
||||
|
|
@ -192,8 +198,21 @@ class PortalApplication:
|
|||
tenant_hint = query.get("tenant_hint", [None])[0]
|
||||
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
|
||||
raise ValidationError("tenant_hint must be a tenant identifier")
|
||||
recovery = query.get("recovery") == ["1"]
|
||||
fresh = query.get("fresh") == ["1"] and not recovery
|
||||
begin_arguments: dict[str, Any] = {}
|
||||
if recovery:
|
||||
begin_arguments = {
|
||||
"recovery": True,
|
||||
"return_path": query.get("return_path", ["/platform/factor-recovery"])[0],
|
||||
}
|
||||
elif fresh:
|
||||
begin_arguments = {"fresh": True}
|
||||
location = (
|
||||
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None, **({"recovery": True, "return_path": query.get("return_path", ["/platform/factor-recovery"])[0]} if query.get("recovery") == ["1"] else {}))
|
||||
self.oidc_client.begin(
|
||||
tenant_hint=str(tenant_hint) if tenant_hint else None,
|
||||
**begin_arguments,
|
||||
)
|
||||
if self.oidc_client else self.login_url
|
||||
)
|
||||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
|
|
@ -225,11 +244,18 @@ class PortalApplication:
|
|||
except AuthorizationDenied:
|
||||
actor = None
|
||||
self._set_account_navigation(environ, actor)
|
||||
identity = (
|
||||
f'<p>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>You are not signed in. Open the account site with your NetKingdom identity.</p>'
|
||||
)
|
||||
if actor is not None:
|
||||
identity = (
|
||||
f'<p>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>'
|
||||
)
|
||||
else:
|
||||
pending = self._pending_identity_copy(self._netkingdom_name())
|
||||
identity = (
|
||||
pending
|
||||
if pending
|
||||
else '<p>You are not signed in. Open the account site with your NetKingdom identity.</p>'
|
||||
)
|
||||
return self._html(start_response, self._page_html(
|
||||
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
|
||||
'<p>Your account may not have access to the application, or sign-in may have been interrupted.</p>'
|
||||
|
|
@ -242,11 +268,23 @@ class PortalApplication:
|
|||
if path == "/logged-out" and method == "GET":
|
||||
if self._optional_actor(environ) is not None:
|
||||
return self._redirect(start_response, "/", correlation_id)
|
||||
pending = self._pending_identity_copy(self._netkingdom_name())
|
||||
if pending:
|
||||
title = "NetKingdom sign-in"
|
||||
signed_out = (
|
||||
"<h1>This account site has no session yet.</h1>"
|
||||
+ pending
|
||||
)
|
||||
else:
|
||||
title = "Not signed in"
|
||||
signed_out = (
|
||||
'<h1>You are not signed in.</h1>'
|
||||
'<p>Your shared NetKingdom sign-in may still be active. '
|
||||
'Opening the account site again may reuse that identity.</p>'
|
||||
)
|
||||
return self._html(start_response, self._page_html(
|
||||
"Not signed in",
|
||||
'<h1>You are not signed in.</h1>'
|
||||
'<p>Your shared NetKingdom sign-in may still be active. Opening the account site again may reuse that identity.</p>'
|
||||
+ self._identity_switch_help(),
|
||||
title,
|
||||
signed_out + self._identity_switch_help(),
|
||||
), correlation_id)
|
||||
if path == "/logout" and method == "GET":
|
||||
actor = self._optional_actor(environ)
|
||||
|
|
@ -2195,13 +2233,18 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _login_state(actor: Any | None) -> str:
|
||||
def _login_state(self, actor: Any | None) -> str:
|
||||
level = (
|
||||
"<p>A one-time code raises the security level of a NetKingdom sign-in. "
|
||||
"It is not another sign-in.</p>"
|
||||
)
|
||||
if actor is None:
|
||||
pending = self._pending_identity_copy(self._netkingdom_name())
|
||||
if pending:
|
||||
return (
|
||||
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
|
||||
f"{pending}{level}</section>"
|
||||
)
|
||||
return (
|
||||
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
|
||||
"<p>You are not signed in.</p>"
|
||||
|
|
@ -2376,8 +2419,48 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
return [b""]
|
||||
|
||||
def _netkingdom_name(self, environ: Mapping[str, Any] | None = None) -> str | None:
|
||||
cache = _REQUEST_CACHE.get()
|
||||
if cache is None:
|
||||
return None
|
||||
if "netkingdom_name" in cache:
|
||||
return cache["netkingdom_name"]
|
||||
name: str | None = None
|
||||
if environ is not None and self.identity_lookup is not None:
|
||||
try:
|
||||
found = self.identity_lookup.username(str(environ.get("HTTP_COOKIE", "")))
|
||||
except Exception:
|
||||
found = None
|
||||
if is_identity_name(found):
|
||||
name = found
|
||||
cache["netkingdom_name"] = name
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _pending_identity_copy(name: str | None) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
safe = escape(name)
|
||||
return (
|
||||
f"<p>NetKingdom sign-in is <strong>{safe}</strong>.</p>"
|
||||
"<p>This account site has no session yet.</p>"
|
||||
f'<p><a class="button" href="/login">Continue as {safe}</a> '
|
||||
'<a href="/login?fresh=1">Use a different identity</a></p>'
|
||||
)
|
||||
|
||||
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
|
||||
if actor is None:
|
||||
name = self._netkingdom_name(environ)
|
||||
if name:
|
||||
safe = escape(name)
|
||||
_ACCOUNT_NAVIGATION.set(
|
||||
f'<p>NetKingdom sign-in is <strong>{safe}</strong>. This account site has no session yet.</p>'
|
||||
'<nav aria-label="Account navigation"><a href="/">Home</a>'
|
||||
'<a href="/security">Sign-in help</a>'
|
||||
f'<a class="button" href="/login">Continue as {safe}</a>'
|
||||
'<a href="/login?fresh=1">Use a different identity</a></nav>'
|
||||
)
|
||||
return
|
||||
_ACCOUNT_NAVIGATION.set('<p>Not signed in</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><a href="/security">Sign-in security</a>'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue