messagefoundry-webconsole 0.2.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- messagefoundry_webconsole/__init__.py +128 -0
- messagefoundry_webconsole/_auth.py +841 -0
- messagefoundry_webconsole/_html.py +539 -0
- messagefoundry_webconsole/_security.py +253 -0
- messagefoundry_webconsole/_service.py +22 -0
- messagefoundry_webconsole/_static.py +78 -0
- messagefoundry_webconsole/mount.py +103 -0
- messagefoundry_webconsole/pages/__init__.py +25 -0
- messagefoundry_webconsole/pages/_common.py +21 -0
- messagefoundry_webconsole/pages/account.py +771 -0
- messagefoundry_webconsole/pages/admin.py +480 -0
- messagefoundry_webconsole/pages/audit.py +66 -0
- messagefoundry_webconsole/pages/config.py +113 -0
- messagefoundry_webconsole/pages/connections.py +519 -0
- messagefoundry_webconsole/pages/messages.py +689 -0
- messagefoundry_webconsole/pages/monitoring.py +853 -0
- messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
- messagefoundry_webconsole/routes/__init__.py +6 -0
- messagefoundry_webconsole/routes/_common.py +45 -0
- messagefoundry_webconsole/routes/account.py +451 -0
- messagefoundry_webconsole/routes/admin.py +505 -0
- messagefoundry_webconsole/routes/audit.py +39 -0
- messagefoundry_webconsole/routes/config.py +67 -0
- messagefoundry_webconsole/routes/connection_writes.py +248 -0
- messagefoundry_webconsole/routes/core.py +1077 -0
- messagefoundry_webconsole/routes/monitoring.py +94 -0
- messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
- messagefoundry_webconsole/routes/oidc.py +172 -0
- messagefoundry_webconsole/routes/search.py +204 -0
- messagefoundry_webconsole/routes/sso.py +88 -0
- messagefoundry_webconsole/routes/status.py +178 -0
- messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
- messagefoundry_webconsole/static/app.css +345 -0
- messagefoundry_webconsole/static/app.js +1506 -0
- messagefoundry_webconsole/static/csp-probe.js +9 -0
- messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
- messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
- messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Cookie-based auth for the /ui ops dashboard, CONFINED to /ui (ADR 0065 §3).
|
|
4
|
+
|
|
5
|
+
``require_ui(*perms, phi=...)`` mirrors ``api.security.require`` / ``require_phi_read`` but reads the
|
|
6
|
+
``mf_session`` HttpOnly cookie **instead of** the ``Authorization`` header. It is used **only** by /ui
|
|
7
|
+
HTML routes. The shared ``bearer_token()`` stays header-only, so a JSON API route presented with only
|
|
8
|
+
the cookie still 401s (the hard boundary the security review flagged, test-enforced): the cookie is not
|
|
9
|
+
a JSON-API credential and SameSite is never the sole CSRF defense for the JSON API.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from urllib.parse import quote, urlsplit
|
|
19
|
+
|
|
20
|
+
from fastapi import HTTPException, Request, Response, WebSocket, status
|
|
21
|
+
from fastapi.responses import RedirectResponse
|
|
22
|
+
|
|
23
|
+
from messagefoundry.api.security import get_auth
|
|
24
|
+
from messagefoundry.auth import Identity, Permission
|
|
25
|
+
from messagefoundry.auth.service import AuthService
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BROWSER_HARDENING_OPT_OUT_ENV",
|
|
29
|
+
"CLEAR_SITE_DATA_HEADER",
|
|
30
|
+
"CLEAR_SITE_DATA_VALUE",
|
|
31
|
+
"COOKIE_NAME",
|
|
32
|
+
"FLOW_COOKIE_NAME",
|
|
33
|
+
"HOST_COOKIE_NAME",
|
|
34
|
+
"HOST_FLOW_COOKIE_NAME",
|
|
35
|
+
"UI_CSP",
|
|
36
|
+
"UiWriteAction",
|
|
37
|
+
"allow_reauth_attempt",
|
|
38
|
+
"assert_not_cross_site",
|
|
39
|
+
"assert_same_origin",
|
|
40
|
+
"authorize_ui_ws",
|
|
41
|
+
"browser_hardening_enabled",
|
|
42
|
+
"clear_oidc_flow_cookie",
|
|
43
|
+
"clear_session_cookie",
|
|
44
|
+
"effective_https",
|
|
45
|
+
"is_safe_ui_action",
|
|
46
|
+
"is_unlock_action",
|
|
47
|
+
"login_redirect_response",
|
|
48
|
+
"lookup_ui_action",
|
|
49
|
+
"oidc_flow_cookie_name",
|
|
50
|
+
"register_ui_action",
|
|
51
|
+
"require_ui",
|
|
52
|
+
"require_ui_reauth_only",
|
|
53
|
+
"require_ui_reauth_only_action",
|
|
54
|
+
"require_ui_step_up",
|
|
55
|
+
"require_ui_step_up_action",
|
|
56
|
+
"security_headers_context",
|
|
57
|
+
"session_cookie_name",
|
|
58
|
+
"session_token",
|
|
59
|
+
"set_oidc_flow_cookie",
|
|
60
|
+
"set_session_cookie",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
COOKIE_NAME = "mf_session"
|
|
64
|
+
|
|
65
|
+
#: The ``__Host-`` prefixed session cookie name, used ONLY in an effective-https context (ADR 0065
|
|
66
|
+
#: §hardening / BACKLOG #192, ASVS 3.4.3). A browser REJECTS a ``__Host-`` cookie unless it is Secure +
|
|
67
|
+
#: Path=/ + carries no Domain — all three hold here — so the prefix is a browser-enforced binding of the
|
|
68
|
+
#: session to THIS exact host over TLS. Over cleartext loopback the plain :data:`COOKIE_NAME` is kept
|
|
69
|
+
#: (byte-identity): ``__Host-`` can never be set without Secure, which cleartext cannot carry.
|
|
70
|
+
HOST_COOKIE_NAME = "__Host-mf_session"
|
|
71
|
+
|
|
72
|
+
#: Org opt-out for the #192 /ui browser hardening. DEFAULT is hardening ON (secure-by-default); set this
|
|
73
|
+
#: env truthy to REVERT the /ui surface to the pre-#192 posture — plain :data:`COOKIE_NAME` (still Secure
|
|
74
|
+
#: over https, so transport security is never downgraded) + the engine's static self-CSP, and no
|
|
75
|
+
#: per-response nonce / COOP / CSP-reporting. The escape hatch for a legacy proxy/browser that cannot
|
|
76
|
+
#: tolerate ``__Host-``/nonce-CSP, per the secure-by-default-with-explicit-opt-out rule.
|
|
77
|
+
BROWSER_HARDENING_OPT_OUT_ENV = "MEFOR_WEBCONSOLE_DISABLE_BROWSER_HARDENING"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def browser_hardening_enabled() -> bool:
|
|
81
|
+
"""Whether the #192 /ui browser hardening is active (default ``True``). Disabled only by an explicit
|
|
82
|
+
truthy :data:`BROWSER_HARDENING_OPT_OUT_ENV`."""
|
|
83
|
+
return os.environ.get(BROWSER_HARDENING_OPT_OUT_ENV, "").strip().lower() not in (
|
|
84
|
+
"1",
|
|
85
|
+
"true",
|
|
86
|
+
"yes",
|
|
87
|
+
"on",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def effective_https(app_state: object, scheme: str) -> bool:
|
|
92
|
+
"""Whether this connection is an EFFECTIVE-https context — the single signal the cookie name +
|
|
93
|
+
Secure flag + the /ui security-header hardening all key on (ADR 0065 §hardening / #192).
|
|
94
|
+
|
|
95
|
+
Mirrors the engine's ``api.app._cookie_secure`` decision READ-ONLY: the per-request/handshake scheme
|
|
96
|
+
is https/wss, OR the operator declared the browser-facing scheme https via
|
|
97
|
+
``app.state.exposure_protected`` (set once in ``create_app`` — the proxy-TLS case where a request
|
|
98
|
+
that omits ``X-Forwarded-Proto`` would otherwise read as cleartext). Reads only the public
|
|
99
|
+
``app.state`` attribute the engine already exposes; imports no engine module.
|
|
100
|
+
"""
|
|
101
|
+
return scheme in ("https", "wss") or bool(getattr(app_state, "exposure_protected", False))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def security_headers_context(app_state: object, scheme: str) -> bool:
|
|
105
|
+
"""Whether this connection may receive the http-SAFE /ui browser-security headers — the nonce CSP,
|
|
106
|
+
COOP, CORP and Reporting-Endpoints bundle (ADR 0143). True in an :func:`effective_https` context OR
|
|
107
|
+
over a **loopback secure-context** (``http://127.0.0.1`` — a W3C *potentially-trustworthy* origin,
|
|
108
|
+
signalled read-only by ``app.state.loopback``).
|
|
109
|
+
|
|
110
|
+
Deliberately BROADER than :func:`effective_https`, which still SOLELY gates the session cookie's
|
|
111
|
+
Secure flag + ``__Host-`` prefix. Splitting the two lets the header hardening engage over cleartext
|
|
112
|
+
loopback (a trustworthy origin where a conformant browser honours these headers) while the cookie
|
|
113
|
+
stays the plain ``mf_session`` — a browser REJECTS a Secure / ``__Host-`` cookie over http, so keying
|
|
114
|
+
the cookie on loopback would break login. HSTS is unaffected (the engine emits it only over real
|
|
115
|
+
https / ``exposure_protected``, never on loopback). Reads only the public ``app.state`` attribute the
|
|
116
|
+
engine exposes; imports no engine module (a graceful default keeps it compatible with an older
|
|
117
|
+
engine that predates the ``loopback`` seam).
|
|
118
|
+
"""
|
|
119
|
+
return effective_https(app_state, scheme) or bool(getattr(app_state, "loopback", False))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def session_cookie_name(conn: Request | WebSocket) -> str:
|
|
123
|
+
"""The session cookie name for this connection: ``__Host-mf_session`` in an effective-https context
|
|
124
|
+
(unless the org opt-out is set), else the plain ``mf_session`` (unchanged over cleartext loopback —
|
|
125
|
+
byte-identity). The ONE resolver every set/clear/read site threads through, so the name a response
|
|
126
|
+
writes and the name a later request reads always agree."""
|
|
127
|
+
if effective_https(conn.app.state, conn.url.scheme) and browser_hardening_enabled():
|
|
128
|
+
return HOST_COOKIE_NAME
|
|
129
|
+
return COOKIE_NAME
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def session_token(conn: Request | WebSocket) -> str | None:
|
|
133
|
+
"""Read the session token from whichever cookie name applies to this connection's scheme."""
|
|
134
|
+
return conn.cookies.get(session_cookie_name(conn))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Strict, self-only CSP for the /ui surface — no 'unsafe-eval'/'unsafe-inline' (ADR 0065 §5). The only
|
|
138
|
+
# script is the first-party /ui/static/app.js (no inline script, no on* handlers), so 'self' suffices.
|
|
139
|
+
# Everything (script/style/font/img) is served from /ui/static, same origin.
|
|
140
|
+
UI_CSP = (
|
|
141
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
|
142
|
+
"connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; "
|
|
143
|
+
"form-action 'self'; object-src 'none'"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Sec-Fetch-Site values that mean the request did NOT originate from our own origin — rejected on
|
|
147
|
+
# state-changing /ui POSTs (M2). "same-site" (a sibling subdomain) is rejected too: /ui is strictly
|
|
148
|
+
# same-origin. "same-origin" and "none" (a user-initiated navigation) are allowed.
|
|
149
|
+
_CROSS_ORIGIN_FETCH = frozenset({"cross-site", "same-site"})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
#: ASVS 14.3.1 — the header emitted on every response that ENDS a session's browser-visible life.
|
|
153
|
+
#: There are exactly THREE emitters, and between them they cover every shape a termination takes:
|
|
154
|
+
#: :func:`clear_session_cookie` (a route that deletes the cookie — sign-out, and the password change
|
|
155
|
+
#: that revokes every session), :func:`_login_redirect` / :func:`login_redirect_response` (every
|
|
156
|
+
#: SERVER-driven expiry, revoke or disable redirect), and the login RENDER in :mod:`.routes.core`
|
|
157
|
+
#: (a post-termination landing reached by bookmark or Back, recognised by its outcome code or by a
|
|
158
|
+
#: session cookie that no longer authenticates). Deletion and this header are deliberately fused in
|
|
159
|
+
#: one helper rather than left as two lines a new route must remember to write together.
|
|
160
|
+
#: ``"cache"`` ONLY: ``"cookies"`` is already covered by the explicit cookie deletion (and would clear
|
|
161
|
+
#: the in-flight OIDC flow cookie), and ``"storage"`` would wipe the deliberately-persistent, PHI-free
|
|
162
|
+
#: ``mfcols:v2`` column preferences (14.3.3), which are not session state. Defense-in-depth against
|
|
163
|
+
#: Back/bfcache resurrection of a terminated session's rendered PHI page.
|
|
164
|
+
CLEAR_SITE_DATA_HEADER = "Clear-Site-Data"
|
|
165
|
+
CLEAR_SITE_DATA_VALUE = '"cache"'
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _login_redirect(note: str = "") -> HTTPException:
|
|
169
|
+
"""A 303 redirect (as an exception, to short-circuit a dependency) to the /ui login page.
|
|
170
|
+
|
|
171
|
+
Carries :data:`CLEAR_SITE_DATA_HEADER` on the REDIRECT ITSELF (ASVS 14.3.1). Every SERVER-driven
|
|
172
|
+
session termination lands here — idle timeout, absolute timeout, revoke ("sign out everywhere
|
|
173
|
+
else"), admin disable, AD-reconcile revoke — and those are exactly the paths the residual names.
|
|
174
|
+
Attaching it to the 303 rather than only to the landing page means the header cannot depend on
|
|
175
|
+
which login URL the browser is sent to, or on the client watchdog having run at all (with
|
|
176
|
+
JavaScript blocked it never does).
|
|
177
|
+
"""
|
|
178
|
+
location = "/ui/login" + (f"?e={note}" if note else "")
|
|
179
|
+
return HTTPException(
|
|
180
|
+
status.HTTP_303_SEE_OTHER,
|
|
181
|
+
headers={"Location": location, CLEAR_SITE_DATA_HEADER: CLEAR_SITE_DATA_VALUE},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def login_redirect_response(note: str = "expired") -> RedirectResponse:
|
|
186
|
+
"""The RESPONSE form of :func:`_login_redirect`, for a ROUTE that discovers the session is gone
|
|
187
|
+
(rather than a dependency, which raises). Same 14.3.1 header, same default outcome code, so the
|
|
188
|
+
two paths cannot drift apart."""
|
|
189
|
+
resp = RedirectResponse("/ui/login" + (f"?e={note}" if note else ""), status_code=303)
|
|
190
|
+
resp.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
|
|
191
|
+
return resp
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _mfa_redirect() -> HTTPException:
|
|
195
|
+
"""A 303 to the /ui second-factor page (ASVS 6.3.3), the cookie-plane twin of the JSON gate's
|
|
196
|
+
403 + ``X-MFA-Required``.
|
|
197
|
+
|
|
198
|
+
Deliberately NOT :func:`_login_redirect`: the session is ALIVE and merely half-proven, so it must
|
|
199
|
+
not carry ``Clear-Site-Data`` — that header marks a TERMINATED session (14.3.1), and firing it
|
|
200
|
+
here would wipe the cache on an ordinary sign-in step and mislabel the state in the login page's
|
|
201
|
+
``?e=`` code."""
|
|
202
|
+
return HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/ui/mfa"})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def require_ui(
|
|
206
|
+
*permissions: Permission,
|
|
207
|
+
phi: bool = False,
|
|
208
|
+
allow_must_change: bool = False,
|
|
209
|
+
allow_mfa_pending: bool = False,
|
|
210
|
+
activity: bool = True,
|
|
211
|
+
) -> Callable[[Request], Awaitable[Identity]]:
|
|
212
|
+
"""Authenticate a /ui request from the session cookie and assert ``permissions``.
|
|
213
|
+
|
|
214
|
+
``phi=True`` also applies the same per-actor anti-automation throttle as ``require_phi_read`` (the
|
|
215
|
+
/ui PHI views call the JSON handlers directly, which skips their own ``Depends`` gate, so this
|
|
216
|
+
dependency must re-apply the equivalent permission + throttle — otherwise a cookie session could
|
|
217
|
+
read PHI it lacks the permission/quota for). Unauthenticated/expired → 303 to the login page;
|
|
218
|
+
forbidden → 403; throttled → 429.
|
|
219
|
+
|
|
220
|
+
A ``must_change_password`` account is 303'd to the browser change-password page (L4b) from every
|
|
221
|
+
/ui route — ``allow_must_change=True`` is set ONLY by that page's own GET/POST (so the rotation
|
|
222
|
+
can actually happen; anything else would loop).
|
|
223
|
+
|
|
224
|
+
``activity=False`` (ASVS 14.3.1) validates the session WITHOUT refreshing its idle clock — the
|
|
225
|
+
same contract the engine's /ws/stats keepalive uses. Set it on the console's **timer-driven
|
|
226
|
+
background refreshes**, which no human initiates: the nav status/health poll, the live connections
|
|
227
|
+
fragment and the live flow fragment, plus the session-status probe the client watchdog runs. With
|
|
228
|
+
those polling on the default nav — i.e. on every PHI page — an abandoned tab otherwise slid
|
|
229
|
+
``last_used_at`` forever and the idle deadline could never fire, so the automatic-logoff control
|
|
230
|
+
was defeated exactly where it matters most. Leave it True for anything an operator actually
|
|
231
|
+
clicks (navigation, the log tail/level controls, export): those ARE user activity.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
async def dependency(request: Request) -> Identity:
|
|
235
|
+
auth = get_auth(request)
|
|
236
|
+
if auth is None or not auth.enabled:
|
|
237
|
+
# The browser UI always needs a real session — no allow_no_auth shortcut here.
|
|
238
|
+
raise _login_redirect()
|
|
239
|
+
token = session_token(request)
|
|
240
|
+
identity = await auth.identity_for_token(token, activity=activity)
|
|
241
|
+
if identity is None:
|
|
242
|
+
# A cookie that no longer authenticates IS a post-termination landing (idle/absolute
|
|
243
|
+
# expiry, revoke, admin disable), so name it: the login page then explains itself
|
|
244
|
+
# instead of appearing as an unexplained form, and its render carries Clear-Site-Data
|
|
245
|
+
# too (14.3.1). A visitor with NO cookie never had a session — plain form, no code.
|
|
246
|
+
raise _login_redirect("expired" if token else "")
|
|
247
|
+
if identity.must_change_password and not allow_must_change:
|
|
248
|
+
# A flagged account can go nowhere but the change-password page (L4b) until it rotates.
|
|
249
|
+
raise HTTPException(
|
|
250
|
+
status.HTTP_303_SEE_OTHER, headers={"Location": "/ui/account/password"}
|
|
251
|
+
)
|
|
252
|
+
# ASVS 6.3.3, the cookie mirror of the JSON gate. Ordering matches require(): must_change
|
|
253
|
+
# above, permissions below — a pending session must not learn whether it holds a permission.
|
|
254
|
+
if not allow_mfa_pending and not await auth.mfa_satisfied(token):
|
|
255
|
+
raise _mfa_redirect()
|
|
256
|
+
for permission in permissions:
|
|
257
|
+
if not identity.has(permission):
|
|
258
|
+
await auth.audit_permission_denied(identity, permission, request.url.path)
|
|
259
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
|
|
260
|
+
if phi and not auth.allow_phi_read(identity.user_id):
|
|
261
|
+
raise HTTPException(
|
|
262
|
+
status.HTTP_429_TOO_MANY_REQUESTS,
|
|
263
|
+
"too many requests; please slow down",
|
|
264
|
+
headers={"Retry-After": "10"},
|
|
265
|
+
)
|
|
266
|
+
return identity
|
|
267
|
+
|
|
268
|
+
return dependency
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def allow_reauth_attempt(auth: AuthService, identity: Identity, client: str | None) -> bool:
|
|
272
|
+
"""Per-ACTOR budget for the POST-session credential ceremonies (re-auth, password change, MFA
|
|
273
|
+
enrolment confirm), replacing the shared unauthenticated sign-in budget those used to draw on —
|
|
274
|
+
which let anyone able to reach the login page lock every signed-in operator out of step-up.
|
|
275
|
+
|
|
276
|
+
Resolved via ``getattr`` because the console ships as a **separately-versioned wheel**: mounted on
|
|
277
|
+
an engine that predates ``allow_reauth_attempt`` it degrades to the previous behaviour rather than
|
|
278
|
+
failing, so no ENGINE_UI_SEAM bump is required."""
|
|
279
|
+
gate = getattr(auth, "allow_reauth_attempt", None)
|
|
280
|
+
if gate is None: # older engine — previous (shared sign-in budget) behaviour
|
|
281
|
+
return bool(auth.allow_login_attempt(client))
|
|
282
|
+
return bool(gate(identity.user_id))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _origin_matches(app_state: object, origin: str, host: str | None) -> bool:
|
|
286
|
+
"""Whether the browser ``Origin`` is our own origin (ADR 0065 off-loopback defaults).
|
|
287
|
+
|
|
288
|
+
When ``[api].public_origin`` is configured — the off-loopback case, behind a reverse proxy that may
|
|
289
|
+
not preserve ``Host`` — it is **authoritative** (exact, normalized match). Otherwise (loopback, or a
|
|
290
|
+
Host-preserving proxy) fall back to comparing the ``Origin`` host[:port] to the request ``Host``.
|
|
291
|
+
"""
|
|
292
|
+
public_origin: str | None = getattr(app_state, "public_origin", None)
|
|
293
|
+
if public_origin:
|
|
294
|
+
# Scheme + host are case-INSENSITIVE (RFC 6454 §4 / RFC 3986 §3.2.2). Canonicalize the incoming
|
|
295
|
+
# Origin the same way the validator canonicalizes public_origin (lowercased), so a case variant
|
|
296
|
+
# is treated as the same origin — fail-closed either way, but this avoids rejecting a legit
|
|
297
|
+
# browser (browsers lowercase the host) or a mixed-case configured public_origin.
|
|
298
|
+
parts = urlsplit(origin)
|
|
299
|
+
return f"{parts.scheme.lower()}://{parts.netloc.lower()}" == public_origin
|
|
300
|
+
return host is not None and urlsplit(origin).netloc.lower() == host.lower()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def assert_not_cross_site(request: Request) -> None:
|
|
304
|
+
"""Reject a request the BROWSER ITSELF labels cross-site — the ``Sec-Fetch-Site`` half of
|
|
305
|
+
:func:`assert_same_origin`, and nothing else (ASVS 3.5.1).
|
|
306
|
+
|
|
307
|
+
Deliberately NARROWER than :func:`assert_same_origin`: it consults only ``Sec-Fetch-Site`` and
|
|
308
|
+
never falls back to comparing ``Origin`` against our own origin. That fallback is right for a
|
|
309
|
+
document-initiated form POST, where an absent ``Sec-Fetch-Site`` means an OLD browser and the
|
|
310
|
+
``Origin`` is authoritative — but it is WRONG for a browser-agent delivery such as a CSP violation
|
|
311
|
+
report, which is sent out-of-band by the user agent's reporting agent rather than by the document:
|
|
312
|
+
those carry no ``Sec-Fetch-*`` at all and may carry ``Origin: null``, which the ``Origin``
|
|
313
|
+
comparison would reject, silently killing every violation report (including the 3.7.5 enforcement
|
|
314
|
+
canary's). So the check keeps exactly the property that matters for such a sink — a report a
|
|
315
|
+
FOREIGN site's CSP aimed at us is refused instead of amplifying our log — and lets conforming
|
|
316
|
+
same-origin and agent-initiated delivery through.
|
|
317
|
+
"""
|
|
318
|
+
if request.headers.get("sec-fetch-site") in _CROSS_ORIGIN_FETCH:
|
|
319
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, "cross-site request rejected")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def assert_same_origin(request: Request) -> None:
|
|
323
|
+
"""Reject a cross-site state-changing /ui request — CSRF defense-in-depth (M2, ADR 0065 §M2).
|
|
324
|
+
|
|
325
|
+
The **primary** CSRF defense is the SameSite=Strict session cookie: a cross-site POST carries no
|
|
326
|
+
``mf_session`` cookie, so ``require_ui`` already fails it (303 to login) before any action runs.
|
|
327
|
+
This adds an explicit origin check on top: modern browsers send ``Sec-Fetch-Site`` on every request
|
|
328
|
+
(reject ``cross-site``/``same-site``); for older clients that omit it, fall back to comparing the
|
|
329
|
+
``Origin`` to our own origin (``[api].public_origin`` when set, else the request ``Host``). A
|
|
330
|
+
same-origin form POST (the only way the /ui buttons submit) passes both. Token-free — so it needs no
|
|
331
|
+
crypto import (avoids the ASVS 11.1.3 inventory gate).
|
|
332
|
+
|
|
333
|
+
It needs **no session**, which is why it is also the first statement of the two UNAUTHENTICATED
|
|
334
|
+
state-changing POSTs (ASVS 3.5.1): ``POST /ui/login``, where SameSite=Strict supplies nothing
|
|
335
|
+
because no session cookie exists yet, and ``POST /ui/logout``, which has no ``Depends`` gate at all
|
|
336
|
+
(by design — a must-change-confined session must be able to revoke itself, ASVS 7.4.4) and so has
|
|
337
|
+
no other request-provenance control. The header-less fallthrough is safe by construction: a browser
|
|
338
|
+
attaches ``Sec-Fetch-Site`` or ``Origin`` to every cross-site POST, so only a non-browser client —
|
|
339
|
+
which cannot be CSRF-ridden — passes header-less.
|
|
340
|
+
"""
|
|
341
|
+
sec_fetch_site = request.headers.get("sec-fetch-site")
|
|
342
|
+
if sec_fetch_site is not None:
|
|
343
|
+
assert_not_cross_site(request)
|
|
344
|
+
return
|
|
345
|
+
origin = request.headers.get("origin")
|
|
346
|
+
if origin and not _origin_matches(request.app.state, origin, request.headers.get("host")):
|
|
347
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, "cross-origin request rejected")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True, slots=True)
|
|
351
|
+
class UiWriteAction:
|
|
352
|
+
"""One registered state-changing /ui action (ADR 0065 §multi-session-build): the anchored path
|
|
353
|
+
pattern it is served at, the RBAC permission it needs, whether it requires a fresh step-up, and how
|
|
354
|
+
the step-up re-auth flow hands control back to it after a successful re-verification.
|
|
355
|
+
|
|
356
|
+
Exactly one continuation style applies:
|
|
357
|
+
|
|
358
|
+
* ``auto_retry`` — a URL-complete, **body-less POST** the re-auth flow may **re-POST** (auto-submit)
|
|
359
|
+
once the window is fresh (replay, purge, config-reload). The re-POST carries no body, so every
|
|
360
|
+
parameter must live in the PATH.
|
|
361
|
+
* ``unlock`` — a **GET form page** (L4a admin forms) the re-auth flow may **303-GET-redirect** to
|
|
362
|
+
after step-up, so the form re-opens inside a fresh window and the operator submits the body-carrying
|
|
363
|
+
POST (incl. a create-user password) in a single request that never crosses ``/ui/reauth``. This is
|
|
364
|
+
the stateless confirm-after-step-up primitive: no body — and no password — is ever preserved across
|
|
365
|
+
the redirect.
|
|
366
|
+
|
|
367
|
+
The two are mutually exclusive (``__post_init__`` enforces it): a GET page must never be auto-POSTed,
|
|
368
|
+
and a POST action must never be GET-redirected to.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
path_re: re.Pattern[str]
|
|
372
|
+
# None = a self-scoped account action (L4b): any authenticated session may continue it after
|
|
373
|
+
# re-auth — there is no RBAC permission beyond holding a valid (re-verified) session. The field is
|
|
374
|
+
# descriptive either way: enforcement lives in each route's require_ui* dependency, and the
|
|
375
|
+
# continuation gates (is_safe_ui_action / is_unlock_action) match on pattern + flags only.
|
|
376
|
+
permission: Permission | None
|
|
377
|
+
step_up: bool = True
|
|
378
|
+
auto_retry: bool = True
|
|
379
|
+
unlock: bool = False
|
|
380
|
+
# 7.5.1 (ADR 0077): the single-use step-up ACTION grant string /ui/reauth mints for this
|
|
381
|
+
# continuation (None = mint nothing = a pure session-window refresh, today's default). Set on the
|
|
382
|
+
# browser factor-binding lanes so their /ui/reauth re-proof mints exactly that action's grant.
|
|
383
|
+
action: str | None = None
|
|
384
|
+
|
|
385
|
+
def __post_init__(self) -> None:
|
|
386
|
+
# Guard against a mis-registration that would let the re-auth flow POST-auto-submit a GET form
|
|
387
|
+
# page (or GET-redirect to a state-changing POST): the continuation branch keys off these flags.
|
|
388
|
+
if self.auto_retry and self.unlock:
|
|
389
|
+
raise ValueError("a /ui action cannot be both auto_retry (POST) and unlock (GET)")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# The write-action registry — the extensible replacement for the former single ``_SAFE_UI_ACTION_RE``
|
|
393
|
+
# literal. A write-page lane registers its action (co-located with its route, or here) instead of editing
|
|
394
|
+
# a central allow-list, so parallel lanes never collide. This is also the ONLY source of truth for which
|
|
395
|
+
# actions the step-up re-auth flow may hand control back to (see ``is_safe_ui_action``): the gate that
|
|
396
|
+
# stops the re-auth becoming an open POST/redirect gadget.
|
|
397
|
+
_UI_WRITE_ACTIONS: list[UiWriteAction] = []
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def register_ui_action(
|
|
401
|
+
pattern: str,
|
|
402
|
+
permission: Permission | None,
|
|
403
|
+
*,
|
|
404
|
+
step_up: bool = True,
|
|
405
|
+
auto_retry: bool = True,
|
|
406
|
+
unlock: bool = False,
|
|
407
|
+
action: str | None = None,
|
|
408
|
+
) -> UiWriteAction:
|
|
409
|
+
"""Register a state-changing /ui action into the write-action registry. Idempotent by ``pattern``.
|
|
410
|
+
|
|
411
|
+
``pattern`` MUST be a fully-anchored regex for the exact path (e.g. ``r"^/ui/alerts/[^/?#]+/ack$"``).
|
|
412
|
+
``auto_retry`` entries are the only paths the step-up re-auth may **re-POST** — keep it ``True`` only
|
|
413
|
+
for body-less actions whose params all live in the PATH. ``unlock`` entries are **GET form pages** the
|
|
414
|
+
re-auth may **303-GET-redirect** to after step-up (the confirm-after-step-up primitive for
|
|
415
|
+
body-carrying admin forms); register those as ``auto_retry=False, unlock=True`` (the two are mutually
|
|
416
|
+
exclusive — :class:`UiWriteAction` rejects both).
|
|
417
|
+
"""
|
|
418
|
+
entry = UiWriteAction(re.compile(pattern), permission, step_up, auto_retry, unlock, action)
|
|
419
|
+
if not any(a.path_re.pattern == entry.path_re.pattern for a in _UI_WRITE_ACTIONS):
|
|
420
|
+
_UI_WRITE_ACTIONS.append(entry)
|
|
421
|
+
return entry
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# Phase-0 replay actions (migrated verbatim from the former _SAFE_UI_ACTION_RE): a single message, all
|
|
425
|
+
# dead deliveries for one channel, or the dead deliveries for one (channel, destination). All params are
|
|
426
|
+
# in the PATH (opaque ids / channel + destination names, each a single slash/query/fragment-free
|
|
427
|
+
# segment), so the body-less auto-retry re-POST carries everything it needs.
|
|
428
|
+
register_ui_action(r"^/ui/messages/[^/?#]+/replay$", Permission.MESSAGES_REPLAY)
|
|
429
|
+
register_ui_action(r"^/ui/dead-letters/[^/?#]+(/[^/?#]+)?/replay$", Permission.MESSAGES_REPLAY)
|
|
430
|
+
# L6b (#75 parity): replay ALL dead deliveries across EVERY channel — a body-less step-up POST, so
|
|
431
|
+
# the /ui/reauth flow may auto-retry it. The literal `replay-all` segment is NOT matched by the
|
|
432
|
+
# per-channel pattern above (it has no `/replay` suffix), so it needs its own allow-list entry.
|
|
433
|
+
register_ui_action(r"^/ui/dead-letters/replay-all$", Permission.MESSAGES_REPLAY)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def is_safe_ui_action(next_path: str | None) -> bool:
|
|
437
|
+
"""Whether ``next_path`` is a same-origin /ui action the re-auth flow may auto-retry.
|
|
438
|
+
|
|
439
|
+
Rejects any ``..`` outright: a segment like ``CH/..`` is normalized by the browser before the POST, so
|
|
440
|
+
it must never be treated as a valid retry target (defense-in-depth over the anchored patterns). Then
|
|
441
|
+
scans the write-action registry for an ``auto_retry`` entry whose anchored pattern fully matches — so a
|
|
442
|
+
write-page lane extends the allow-list by *registering* its action, never by editing this function.
|
|
443
|
+
"""
|
|
444
|
+
if not next_path or ".." in next_path:
|
|
445
|
+
return False
|
|
446
|
+
return any(
|
|
447
|
+
action.auto_retry and action.path_re.fullmatch(next_path) for action in _UI_WRITE_ACTIONS
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def lookup_ui_action(next_path: str | None) -> UiWriteAction | None:
|
|
452
|
+
"""The registered continuation action ``next_path`` resolves to (auto_retry OR unlock), or None.
|
|
453
|
+
|
|
454
|
+
Used by the /ui/reauth flow to read an action's metadata — notably ``step_up``: a ``step_up=False``
|
|
455
|
+
continuation is password-only (require_ui_reauth_only, e.g. MFA enrollment), so a required-but-
|
|
456
|
+
unenrolled session can complete it; a ``step_up=True`` continuation needs the full step-up (MFA
|
|
457
|
+
leg), which such a session can NEVER satisfy — the flow sends it to enroll instead of looping. Same
|
|
458
|
+
``..`` guard as the boolean gates.
|
|
459
|
+
"""
|
|
460
|
+
if not next_path or ".." in next_path:
|
|
461
|
+
return None
|
|
462
|
+
for action in _UI_WRITE_ACTIONS:
|
|
463
|
+
if (action.auto_retry or action.unlock) and action.path_re.fullmatch(next_path):
|
|
464
|
+
return action
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def is_unlock_action(next_path: str | None) -> bool:
|
|
469
|
+
"""Whether ``next_path`` is a registered /ui **GET form page** the re-auth may 303-GET-redirect to.
|
|
470
|
+
|
|
471
|
+
The GET analogue of :func:`is_safe_ui_action`: it gates the confirm-after-step-up primitive so the
|
|
472
|
+
re-auth flow can only redirect back to a form page a lane explicitly registered (``unlock=True``),
|
|
473
|
+
never to an arbitrary URL. Same ``..`` rejection (a normalized ``/..`` segment is never a valid
|
|
474
|
+
target) and same append-only registry as the only source of truth — a lane opts a form page in by
|
|
475
|
+
*registering* it, not by editing this function.
|
|
476
|
+
"""
|
|
477
|
+
if not next_path or ".." in next_path:
|
|
478
|
+
return False
|
|
479
|
+
return any(
|
|
480
|
+
action.unlock and action.path_re.fullmatch(next_path) for action in _UI_WRITE_ACTIONS
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _reauth_redirect(request: Request, next_path: str | None = None) -> HTTPException:
|
|
485
|
+
"""303 a browser to the /ui re-auth page, remembering the action to continue with.
|
|
486
|
+
|
|
487
|
+
``next_path`` (when given) overrides the default of this request's own path — used by body-carrying
|
|
488
|
+
POST actions to point the re-auth at their **unlock form page** instead of the POST path (which is
|
|
489
|
+
deliberately not a registered continuation). Either way the value is only ever *acted on* after
|
|
490
|
+
``/ui/reauth`` re-validates it against the write-action registry, so a bad override fails closed.
|
|
491
|
+
"""
|
|
492
|
+
nxt = quote(next_path if next_path is not None else request.url.path, safe="/")
|
|
493
|
+
return HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": f"/ui/reauth?next={nxt}"})
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def require_ui_step_up(
|
|
497
|
+
*permissions: Permission,
|
|
498
|
+
reauth_next: Callable[[Request], str] | None = None,
|
|
499
|
+
) -> Callable[[Request], Awaitable[Identity]]:
|
|
500
|
+
"""Authenticate a /ui request, assert ``permissions``, AND require a fresh step-up — the cookie-world
|
|
501
|
+
analogue of ``api.security.require_step_up`` for sensitive /ui actions (replay).
|
|
502
|
+
|
|
503
|
+
The /ui action routes call the JSON handlers directly, which SKIPS the handler's own
|
|
504
|
+
``require_step_up`` gate — so this dependency re-applies the exact same checks (MFA satisfied +
|
|
505
|
+
recent password step-up + new-client-IP contextual risk) that ``require_step_up`` does. The only
|
|
506
|
+
difference is the *response*: instead of a 403 with ``X-MFA-Required``/``X-Step-Up-Required`` (which
|
|
507
|
+
a browser can't act on), it **redirects** to the /ui re-auth page carrying ``next=<this action>`` so
|
|
508
|
+
the browser can re-authenticate and auto-retry.
|
|
509
|
+
|
|
510
|
+
``reauth_next`` overrides *which* continuation the re-auth page is pointed at. A **body-carrying**
|
|
511
|
+
POST (create-user, set-roles) cannot be auto-retried (the re-POST would carry no body) and its POST
|
|
512
|
+
path is deliberately not a registered continuation — so it maps the redirect to its **unlock form
|
|
513
|
+
page** (e.g. ``POST /ui/users`` → ``/ui/users/new``): after re-verification the browser is GET-
|
|
514
|
+
redirected to the form, which re-opens inside a fresh window for a clean re-submit. The mapped value
|
|
515
|
+
gets no trust: ``/ui/reauth`` still validates it against the write-action registry (fail-closed).
|
|
516
|
+
"""
|
|
517
|
+
# allow_mfa_pending: the base must NOT fire the 6.3.3 redirect for a step-up route. This
|
|
518
|
+
# factory runs its OWN mfa_satisfied check below, which 303s to /ui/reauth *carrying the
|
|
519
|
+
# next= continuation*; letting the base pre-empt it would drop that continuation and land the
|
|
520
|
+
# operator on /ui instead of the action they clicked. Same refusal, better destination.
|
|
521
|
+
base = require_ui(*permissions, allow_mfa_pending=True)
|
|
522
|
+
|
|
523
|
+
async def dependency(request: Request) -> Identity:
|
|
524
|
+
identity = await base(request) # cookie auth + permission (+ must-change gate)
|
|
525
|
+
auth = get_auth(request)
|
|
526
|
+
if auth is None or not auth.enabled: # pragma: no cover - base already handled this
|
|
527
|
+
raise _login_redirect()
|
|
528
|
+
token = session_token(request)
|
|
529
|
+
nxt = reauth_next(request) if reauth_next is not None else None
|
|
530
|
+
# Second factor first (mirrors require_step_up): an MFA-required session must have verified TOTP.
|
|
531
|
+
if not await auth.mfa_satisfied(token):
|
|
532
|
+
raise _reauth_redirect(request, nxt)
|
|
533
|
+
# Contextual risk + password step-up window: a new client IP or a stale window forces re-auth.
|
|
534
|
+
client = request.client.host if request.client else None
|
|
535
|
+
new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
|
|
536
|
+
if new_ip or not await auth.has_recent_step_up(token):
|
|
537
|
+
raise _reauth_redirect(request, nxt)
|
|
538
|
+
return identity
|
|
539
|
+
|
|
540
|
+
return dependency
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def require_ui_reauth_only(
|
|
544
|
+
*permissions: Permission,
|
|
545
|
+
reauth_next: Callable[[Request], str] | None = None,
|
|
546
|
+
) -> Callable[[Request], Awaitable[Identity]]:
|
|
547
|
+
"""Like :func:`require_ui_step_up` but with **only** the password step-up — **not** the MFA gate;
|
|
548
|
+
the cookie-world analogue of ``api.security.require_reauth_only``.
|
|
549
|
+
|
|
550
|
+
Used by the browser MFA *enrollment* routes (L4b): a user enrolling their first second factor (or
|
|
551
|
+
a ``require_mfa`` account that has not enrolled yet) cannot satisfy an MFA gate, so
|
|
552
|
+
``require_ui_step_up`` there would deadlock. Re-proving the password still defends a stolen cookie
|
|
553
|
+
session from silently enrolling an attacker-controlled authenticator (WP-14). Same contextual
|
|
554
|
+
new-client-IP layer; a stale window 303s to /ui/reauth (which only asks for a TOTP code when one
|
|
555
|
+
is actually enrolled).
|
|
556
|
+
"""
|
|
557
|
+
# allow_mfa_pending: a genuine exemption, not a re-route. These gate the ENROLLMENT path, and
|
|
558
|
+
# an un-enrolled user can never satisfy a gate standing in front of the route that enrolls them.
|
|
559
|
+
base = require_ui(*permissions, allow_mfa_pending=True)
|
|
560
|
+
|
|
561
|
+
async def dependency(request: Request) -> Identity:
|
|
562
|
+
identity = await base(request) # cookie auth + permission (+ must-change gate)
|
|
563
|
+
auth = get_auth(request)
|
|
564
|
+
if auth is None or not auth.enabled: # pragma: no cover - base already handled this
|
|
565
|
+
raise _login_redirect()
|
|
566
|
+
token = session_token(request)
|
|
567
|
+
client = request.client.host if request.client else None
|
|
568
|
+
new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
|
|
569
|
+
if new_ip or not await auth.has_recent_step_up(token):
|
|
570
|
+
raise _reauth_redirect(
|
|
571
|
+
request, reauth_next(request) if reauth_next is not None else None
|
|
572
|
+
)
|
|
573
|
+
return identity
|
|
574
|
+
|
|
575
|
+
return dependency
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
async def _ui_action_step_up_ok(auth: AuthService, token: str | None, action: str) -> bool:
|
|
579
|
+
"""The /ui step-up decision for a per-action lane (ADR 0077), mirroring
|
|
580
|
+
``api.security._action_step_up_ok``: when action-binding is enforced (default) a fresh single-use
|
|
581
|
+
grant BOUND to ``action`` (consumed here); when the org opted out
|
|
582
|
+
(``[auth].require_action_step_up = false``) the legacy session-window recency. Uses only PUBLIC
|
|
583
|
+
``AuthService`` members, so no cross-package private import is needed."""
|
|
584
|
+
if auth.action_step_up_required:
|
|
585
|
+
return await auth.has_action_step_up(token, action)
|
|
586
|
+
return await auth.has_recent_step_up(token)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def require_ui_step_up_action(
|
|
590
|
+
action: str,
|
|
591
|
+
*permissions: Permission,
|
|
592
|
+
reauth_next: Callable[[Request], str] | None = None,
|
|
593
|
+
) -> Callable[[Request], Awaitable[Identity]]:
|
|
594
|
+
"""Like :func:`require_ui_step_up`, but the step-up must be a fresh proof **bound to** ``action``
|
|
595
|
+
(single-use, ADR 0077 / ASVS 7.5.1), not the shared session window. Keeps the MFA gate — used for
|
|
596
|
+
the durable-takeover browser factor ops (**disable-MFA**, **webauthn-delete**). Falls back to the
|
|
597
|
+
session window under ``[auth].require_action_step_up = false``. ``new_ip`` is checked FIRST so a
|
|
598
|
+
forced new-IP step-up short-circuits and leaves the single-use grant UNCONSUMED (mirrors
|
|
599
|
+
``api.security.require_step_up_action``)."""
|
|
600
|
+
# allow_mfa_pending: the base must NOT fire the 6.3.3 redirect for a step-up route. This
|
|
601
|
+
# factory runs its OWN mfa_satisfied check below, which 303s to /ui/reauth *carrying the
|
|
602
|
+
# next= continuation*; letting the base pre-empt it would drop that continuation and land the
|
|
603
|
+
# operator on /ui instead of the action they clicked. Same refusal, better destination.
|
|
604
|
+
base = require_ui(*permissions, allow_mfa_pending=True)
|
|
605
|
+
|
|
606
|
+
async def dependency(request: Request) -> Identity:
|
|
607
|
+
identity = await base(request) # cookie auth + permission (+ must-change gate)
|
|
608
|
+
auth = get_auth(request)
|
|
609
|
+
if auth is None or not auth.enabled: # pragma: no cover - base already handled this
|
|
610
|
+
raise _login_redirect()
|
|
611
|
+
token = session_token(request)
|
|
612
|
+
nxt = reauth_next(request) if reauth_next is not None else None
|
|
613
|
+
if not await auth.mfa_satisfied(token):
|
|
614
|
+
raise _reauth_redirect(request, nxt)
|
|
615
|
+
client = request.client.host if request.client else None
|
|
616
|
+
new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
|
|
617
|
+
if new_ip or not await _ui_action_step_up_ok(auth, token, action):
|
|
618
|
+
raise _reauth_redirect(request, nxt)
|
|
619
|
+
return identity
|
|
620
|
+
|
|
621
|
+
return dependency
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def require_ui_reauth_only_action(
|
|
625
|
+
action: str,
|
|
626
|
+
*permissions: Permission,
|
|
627
|
+
reauth_next: Callable[[Request], str] | None = None,
|
|
628
|
+
) -> Callable[[Request], Awaitable[Identity]]:
|
|
629
|
+
"""Like :func:`require_ui_reauth_only` (password-only, **no MFA gate** so a required-but-unenrolled
|
|
630
|
+
session can still enroll its first factor), but the step-up must be a fresh proof **bound to**
|
|
631
|
+
``action`` (single-use, ADR 0077 / ASVS 7.5.1). Used by the browser factor-binding enroll lanes.
|
|
632
|
+
Falls back to the session window under ``[auth].require_action_step_up = false``. Same ``new_ip``-
|
|
633
|
+
first short-circuit so a forced new-IP step-up leaves the single-use grant UNCONSUMED."""
|
|
634
|
+
# allow_mfa_pending: a genuine exemption, not a re-route. These gate the ENROLLMENT path, and
|
|
635
|
+
# an un-enrolled user can never satisfy a gate standing in front of the route that enrolls them.
|
|
636
|
+
base = require_ui(*permissions, allow_mfa_pending=True)
|
|
637
|
+
|
|
638
|
+
async def dependency(request: Request) -> Identity:
|
|
639
|
+
identity = await base(request) # cookie auth + permission (+ must-change gate)
|
|
640
|
+
auth = get_auth(request)
|
|
641
|
+
if auth is None or not auth.enabled: # pragma: no cover - base already handled this
|
|
642
|
+
raise _login_redirect()
|
|
643
|
+
token = session_token(request)
|
|
644
|
+
client = request.client.host if request.client else None
|
|
645
|
+
new_ip = await auth.flag_new_client_ip(token, client, path=request.url.path)
|
|
646
|
+
if new_ip or not await _ui_action_step_up_ok(auth, token, action):
|
|
647
|
+
raise _reauth_redirect(
|
|
648
|
+
request, reauth_next(request) if reauth_next is not None else None
|
|
649
|
+
)
|
|
650
|
+
return identity
|
|
651
|
+
|
|
652
|
+
return dependency
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
async def authorize_ui_ws(
|
|
656
|
+
websocket: WebSocket, *permissions: Permission
|
|
657
|
+
) -> tuple[Identity | None, str | None]:
|
|
658
|
+
"""Authorize a **same-origin browser** WebSocket handshake via the ``mf_session`` cookie.
|
|
659
|
+
|
|
660
|
+
Browsers cannot set the ``Authorization`` header on a WebSocket handshake (and the ``?token=`` query
|
|
661
|
+
fallback was removed for ASVS), so the browser's only header-free credential is the cookie the
|
|
662
|
+
handshake carries. Returns ``(identity, token)`` for an authorized same-origin browser, else
|
|
663
|
+
``(None, None)`` — the caller then falls back to the native (header) ``authorize_ws`` path.
|
|
664
|
+
|
|
665
|
+
**CSWSH defense (two independent layers):** (1) the handshake ``Origin`` must be same-origin as the
|
|
666
|
+
``Host`` — a cross-site page's WS is rejected here; (2) ``mf_session`` is ``SameSite=Strict``, so a
|
|
667
|
+
cross-site-initiated handshake carries **no** cookie at all. A native client sends no ``Origin``, so
|
|
668
|
+
this returns ``(None, None)`` and does not interfere with the header path.
|
|
669
|
+
"""
|
|
670
|
+
origin = websocket.headers.get("origin")
|
|
671
|
+
if not origin:
|
|
672
|
+
return None, None # native client (no Origin) — the header path handles it
|
|
673
|
+
if not _origin_matches(websocket.app.state, origin, websocket.headers.get("host")):
|
|
674
|
+
return None, None # cross-origin browser handshake (CSWSH) — reject
|
|
675
|
+
token = session_token(websocket)
|
|
676
|
+
if not token:
|
|
677
|
+
return None, None
|
|
678
|
+
auth = getattr(websocket.app.state, "auth", None)
|
|
679
|
+
if auth is None or not auth.enabled:
|
|
680
|
+
return None, None
|
|
681
|
+
identity = await auth.identity_for_token(token)
|
|
682
|
+
if identity is None or identity.must_change_password:
|
|
683
|
+
return None, None
|
|
684
|
+
# ASVS 6.3.3, mirroring authorize_ws on the header path: an MFA-pending session does not stream.
|
|
685
|
+
# No exempt set — every /ui socket is a data feed, none is part of the enroll/verify escape path.
|
|
686
|
+
if not await auth.mfa_satisfied(token):
|
|
687
|
+
return None, None
|
|
688
|
+
for permission in permissions:
|
|
689
|
+
if not identity.has(permission):
|
|
690
|
+
return None, None
|
|
691
|
+
return identity, token
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def set_session_cookie(response: Response, token: str, *, request: Request) -> None:
|
|
695
|
+
"""Set the confined session cookie: HttpOnly + SameSite=Strict, Path=/, and — in an effective-https
|
|
696
|
+
context (and unless the org opt-out is set) — the ``__Host-`` prefixed name (ADR 0065 §hardening /
|
|
697
|
+
#192, ASVS 3.4.3). Secure is ALWAYS set when the effective scheme is https, even under the opt-out
|
|
698
|
+
(transport security is never downgraded). Over cleartext loopback this is byte-identical to the
|
|
699
|
+
pre-#192 cookie (``mf_session``, no Secure). Path=/ (not /ui) so a future same-origin WebSocket
|
|
700
|
+
handshake at the root can carry it (M2); the cookie is only ever *read* by ``require_ui`` on /ui
|
|
701
|
+
routes, never by the JSON API deps.
|
|
702
|
+
"""
|
|
703
|
+
secure = effective_https(request.app.state, request.url.scheme)
|
|
704
|
+
name = HOST_COOKIE_NAME if (secure and browser_hardening_enabled()) else COOKIE_NAME
|
|
705
|
+
response.set_cookie(
|
|
706
|
+
name,
|
|
707
|
+
token,
|
|
708
|
+
httponly=True,
|
|
709
|
+
samesite="strict",
|
|
710
|
+
secure=secure,
|
|
711
|
+
path="/",
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def clear_session_cookie(response: Response, request: Request) -> None:
|
|
716
|
+
"""End this browser's session: delete the session cookie AND stamp
|
|
717
|
+
:data:`CLEAR_SITE_DATA_HEADER` (ASVS 14.3.1). Pairs with a server-side revoke.
|
|
718
|
+
|
|
719
|
+
Deletes whichever name this scheme uses (:func:`session_cookie_name`). Over cleartext loopback the
|
|
720
|
+
DELETION stays byte-identical to the pre-#192 clear (``delete_cookie(COOKIE_NAME, path="/")``); the
|
|
721
|
+
``__Host-`` deletion additionally carries Secure so the browser accepts the expiry (a ``__Host-``
|
|
722
|
+
cookie is only writable — expiry included — over a Secure connection).
|
|
723
|
+
|
|
724
|
+
The header is set HERE rather than beside each call site because deleting the session cookie IS
|
|
725
|
+
the browser-visible end of a session: fusing them makes the 14.3.1 control structurally
|
|
726
|
+
un-forgettable. It was previously two lines a route had to remember to write together, and
|
|
727
|
+
``POST /ui/account/password`` — which revokes EVERY session for the user, the termination an
|
|
728
|
+
operator reaches for after a suspected compromise — wrote only one of them, so Back/bfcache could
|
|
729
|
+
resurrect the rendered PHI page across a full revoke. Both current callers (sign-out, password
|
|
730
|
+
change) are terminations; a future caller that deletes the cookie without ending the session would
|
|
731
|
+
be the anomaly and must justify itself, not the other way round.
|
|
732
|
+
"""
|
|
733
|
+
name = session_cookie_name(request)
|
|
734
|
+
if name == COOKIE_NAME:
|
|
735
|
+
response.delete_cookie(COOKIE_NAME, path="/")
|
|
736
|
+
else:
|
|
737
|
+
response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="strict")
|
|
738
|
+
response.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# --- ADR 0142: the OIDC flow cookie (browser binding for the federated login) ------
|
|
742
|
+
|
|
743
|
+
#: The federated-flow cookie. Carries only an opaque flow id — never a token, never the PKCE verifier
|
|
744
|
+
#: (those stay server-side in the service's ``FlowCache``, keyed on ``sha256(flow_id)``).
|
|
745
|
+
FLOW_COOKIE_NAME = "mf_oidc_flow"
|
|
746
|
+
|
|
747
|
+
#: The ``__Host-`` twin, used on the same effective-https terms as :data:`HOST_COOKIE_NAME`.
|
|
748
|
+
HOST_FLOW_COOKIE_NAME = "__Host-mf_oidc_flow"
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def oidc_flow_cookie_name(conn: Request | WebSocket) -> str:
|
|
752
|
+
"""The flow-cookie name for this connection — the ONE resolver the set/read/clear sites share, so
|
|
753
|
+
the name the start leg writes and the name the callback reads can never disagree.
|
|
754
|
+
|
|
755
|
+
Mirrors :func:`session_cookie_name`: a browser silently DROPS a ``__Host-`` cookie that is not
|
|
756
|
+
Secure, and ``[api].public_origin`` legitimately permits an http:// origin, so on cleartext
|
|
757
|
+
loopback the plain name is used. Without that split, the callback would find no cookie and audit
|
|
758
|
+
``flow_binding_missing`` forever on every dev deployment, with nothing pointing at the cause.
|
|
759
|
+
"""
|
|
760
|
+
if effective_https(conn.app.state, conn.url.scheme) and browser_hardening_enabled():
|
|
761
|
+
return HOST_FLOW_COOKIE_NAME
|
|
762
|
+
return FLOW_COOKIE_NAME
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def set_oidc_flow_cookie(
|
|
766
|
+
response: Response, flow_id: str, *, request: Request, max_age: int
|
|
767
|
+
) -> None:
|
|
768
|
+
"""Stage the federated flow id in the browser (ADR 0142 browser-binding, AC-7).
|
|
769
|
+
|
|
770
|
+
``SameSite=Lax``, deliberately NOT the session cookie's ``Strict``: Lax IS sent on a top-level
|
|
771
|
+
cross-site GET, which is exactly what the IdP's redirect back to ``/ui/oidc/callback`` is. A Strict
|
|
772
|
+
flow cookie would be withheld on that return and every federated login would fail
|
|
773
|
+
``flow_binding_missing``. The session cookie's Strict is untouched — this is a separate, short-lived
|
|
774
|
+
cookie carrying no authority of its own.
|
|
775
|
+
|
|
776
|
+
``max_age`` bounds it to the server-side flow TTL so an abandoned login does not leave a cookie
|
|
777
|
+
behind indefinitely.
|
|
778
|
+
"""
|
|
779
|
+
secure = effective_https(request.app.state, request.url.scheme)
|
|
780
|
+
name = HOST_FLOW_COOKIE_NAME if (secure and browser_hardening_enabled()) else FLOW_COOKIE_NAME
|
|
781
|
+
response.set_cookie(
|
|
782
|
+
name,
|
|
783
|
+
flow_id,
|
|
784
|
+
max_age=max_age,
|
|
785
|
+
httponly=True,
|
|
786
|
+
samesite="lax",
|
|
787
|
+
secure=secure,
|
|
788
|
+
path="/",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def clear_oidc_flow_cookie(response: Response, request: Request) -> None:
|
|
793
|
+
"""Delete the flow cookie. Called on EVERY terminal callback response, success or failure: the
|
|
794
|
+
server-side flow is single-use, so a surviving cookie would make the next callback present a flow
|
|
795
|
+
id that no longer resolves — a confusing ``flow_binding_missing`` on an otherwise clean retry."""
|
|
796
|
+
name = oidc_flow_cookie_name(request)
|
|
797
|
+
if name == FLOW_COOKIE_NAME:
|
|
798
|
+
response.delete_cookie(FLOW_COOKIE_NAME, path="/")
|
|
799
|
+
else:
|
|
800
|
+
response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="lax")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# --- L5a: WebAuthn RP identity (ADR 0068 §7) --------------------------------------
|
|
804
|
+
|
|
805
|
+
#: Legible fail-closed copy, shared by every affected surface (account page, enroll flow, reauth
|
|
806
|
+
#: page) so the operator sees ONE consistent message + recovery path — never a redirect loop.
|
|
807
|
+
WEBAUTHN_RP_MISSING_NOTICE = (
|
|
808
|
+
"Passkeys are unavailable: [api].public_origin is not set — contact your administrator."
|
|
809
|
+
)
|
|
810
|
+
WEBAUTHN_EXTRA_MISSING_NOTICE = (
|
|
811
|
+
"Passkeys are unavailable on this install (the [webauthn] extra is not installed) — "
|
|
812
|
+
"contact your administrator."
|
|
813
|
+
)
|
|
814
|
+
WEBAUTHN_RP_CHANGED_NOTICE = (
|
|
815
|
+
"Your passkeys were enrolled under a different origin and are unusable here — contact your "
|
|
816
|
+
"administrator to reset your MFA so you can re-enroll."
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def webauthn_rp(request: Request) -> tuple[str, str] | None:
|
|
821
|
+
"""The WebAuthn RP identity for this deployment: ``(rp_id, expected_origin)`` or ``None``.
|
|
822
|
+
|
|
823
|
+
``[api].public_origin`` is AUTHORITATIVE when set (it is already the validated, normalized
|
|
824
|
+
origin the /ui CSRF + CSWSH checks match against — never a second origin knob). Unset, the
|
|
825
|
+
request URL is used ONLY when ``create_app`` marked request-derivation safe (loopback bind
|
|
826
|
+
with no reverse proxy declared — the browser connected directly, so the request Host is what
|
|
827
|
+
it actually used, not proxy-rewritable). Anywhere else this returns ``None`` and ceremonies
|
|
828
|
+
FAIL CLOSED: behind a declared proxy the Host header is client-forwardable, and anchoring the
|
|
829
|
+
rp_id to it would defeat exactly the phishing resistance WebAuthn exists to add (the red-team
|
|
830
|
+
CRITICAL repair — keyed on the proxy declaration, never the bind host alone).
|
|
831
|
+
"""
|
|
832
|
+
public_origin = getattr(request.app.state, "public_origin", None)
|
|
833
|
+
if public_origin:
|
|
834
|
+
host = urlsplit(public_origin).hostname
|
|
835
|
+
return (host, public_origin) if host else None
|
|
836
|
+
if getattr(request.app.state, "webauthn_rp_from_request", False):
|
|
837
|
+
host = request.url.hostname
|
|
838
|
+
if not host:
|
|
839
|
+
return None
|
|
840
|
+
return (host, f"{request.url.scheme}://{request.url.netloc}")
|
|
841
|
+
return None
|