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,451 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""L4b/L5a/L6b self-service account surface: change-password, TOTP MFA lifecycle, WebAuthn passkeys, and session management."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
|
|
10
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from messagefoundry.api._ui_seam import UiDeps
|
|
14
|
+
from messagefoundry.api.auth_models import (
|
|
15
|
+
PasswordChangeRequest,
|
|
16
|
+
)
|
|
17
|
+
from messagefoundry.auth import Identity
|
|
18
|
+
from messagefoundry.auth import webauthn as webauthn_mod
|
|
19
|
+
from messagefoundry.auth.service import (
|
|
20
|
+
STEP_UP_ACTION_MFA_CONFIRM,
|
|
21
|
+
STEP_UP_ACTION_MFA_DISABLE,
|
|
22
|
+
STEP_UP_ACTION_MFA_ENROLL,
|
|
23
|
+
STEP_UP_ACTION_WEBAUTHN_DELETE,
|
|
24
|
+
STEP_UP_ACTION_WEBAUTHN_ENROLL,
|
|
25
|
+
AuthService,
|
|
26
|
+
)
|
|
27
|
+
from messagefoundry.auth.tokens import hash_token
|
|
28
|
+
|
|
29
|
+
from .. import pages
|
|
30
|
+
from .._auth import (
|
|
31
|
+
WEBAUTHN_EXTRA_MISSING_NOTICE,
|
|
32
|
+
WEBAUTHN_RP_MISSING_NOTICE,
|
|
33
|
+
allow_reauth_attempt,
|
|
34
|
+
assert_same_origin,
|
|
35
|
+
clear_session_cookie,
|
|
36
|
+
register_ui_action,
|
|
37
|
+
require_ui,
|
|
38
|
+
require_ui_reauth_only,
|
|
39
|
+
require_ui_reauth_only_action,
|
|
40
|
+
require_ui_step_up_action,
|
|
41
|
+
session_token,
|
|
42
|
+
webauthn_rp,
|
|
43
|
+
)
|
|
44
|
+
from .._service import _service
|
|
45
|
+
from ._common import _client, _form_pairs, _rate_limited
|
|
46
|
+
|
|
47
|
+
# --- L4b: self-service account (change password + TOTP MFA lifecycle) -------
|
|
48
|
+
# Self-scoped actions: any authenticated session acts on ITS OWN credential, so the registry
|
|
49
|
+
# entries carry permission=None (enforcement is the require_ui* dependency on each route).
|
|
50
|
+
# MFA enroll/disable are body-less POSTs → auto_retry; the confirm-code POST is body-carrying,
|
|
51
|
+
# so its standalone GET form registers as the unlock re-entry point. The change-password POST
|
|
52
|
+
# is in NEITHER registry: it has no step-up gate (the current password in the body IS the
|
|
53
|
+
# proof), so no re-auth continuation ever needs to reach it.
|
|
54
|
+
# enroll + confirm are password-only (require_ui_reauth_only_action) — step_up=False so the /ui/reauth
|
|
55
|
+
# flow knows a required-but-unenrolled session CAN complete them (it is the enrollment path);
|
|
56
|
+
# disable needs the full step-up (require_ui_step_up_action), so it keeps step_up=True (default).
|
|
57
|
+
# 7.5.1 (ADR 0077): each factor lane now carries an action= tag, so /ui/reauth mints the matching
|
|
58
|
+
# single-use grant and each bind consumes exactly one fresh proof (closing the /ui window residual).
|
|
59
|
+
register_ui_action(
|
|
60
|
+
r"^/ui/account/mfa/enroll$", None, step_up=False, action=STEP_UP_ACTION_MFA_ENROLL
|
|
61
|
+
)
|
|
62
|
+
register_ui_action(r"^/ui/account/mfa/disable$", None, action=STEP_UP_ACTION_MFA_DISABLE)
|
|
63
|
+
register_ui_action(
|
|
64
|
+
r"^/ui/account/mfa/confirm$",
|
|
65
|
+
None,
|
|
66
|
+
step_up=False,
|
|
67
|
+
auto_retry=False,
|
|
68
|
+
unlock=True,
|
|
69
|
+
action=STEP_UP_ACTION_MFA_CONFIRM,
|
|
70
|
+
)
|
|
71
|
+
# --- L5a: WebAuthn passkeys (ADR 0068, WP-14b) -------------------------------
|
|
72
|
+
# Self-scoped actions (permission=None — enforcement is each route's require_ui* dep).
|
|
73
|
+
# enroll is the TOTP-enroll twin: a body-less POST returning HTML, gated on the password-
|
|
74
|
+
# only re-proof (require_ui_reauth_only — WP-14: a stolen pre-MFA cookie must never bind
|
|
75
|
+
# an attacker's passkey) and registered step_up=False so the enroll_first anti-loop knows
|
|
76
|
+
# a required-but-unenrolled session CAN complete it. delete is the body-less path-param
|
|
77
|
+
# auto-retry shape behind the FULL step-up. The verify route is a body-carrying JSON POST
|
|
78
|
+
# — NEVER registered as a continuation (hard invariant); its require_ui_reauth_only maps a
|
|
79
|
+
# stale-window 303 to the REGISTERED enroll action (the #745 ui_mfa_verify precedent).
|
|
80
|
+
register_ui_action(
|
|
81
|
+
r"^/ui/account/webauthn/enroll$", None, step_up=False, action=STEP_UP_ACTION_WEBAUTHN_ENROLL
|
|
82
|
+
)
|
|
83
|
+
register_ui_action(
|
|
84
|
+
r"^/ui/account/webauthn/[^/?#]+/delete$", None, action=STEP_UP_ACTION_WEBAUTHN_DELETE
|
|
85
|
+
)
|
|
86
|
+
# --- L6b: self-service session TERMINATE (ASVS 7.5.2) — password re-proof before revoke ---
|
|
87
|
+
# Both terminate POSTs are body-less (all params in the PATH), so they register as auto_retry
|
|
88
|
+
# continuations /ui/reauth may re-POST after a stale-window re-auth. step_up=False (password-
|
|
89
|
+
# only, mirrors require_ui_reauth_only): a required-but-unenrolled MFA session must still be
|
|
90
|
+
# able to revoke — require_ui_step_up would deadlock it (WP-14). permission=None (self-scoped).
|
|
91
|
+
# The two patterns do not overlap: the per-session pattern requires two more segments after
|
|
92
|
+
# sessions/ (<id>/revoke), so /ui/account/sessions/revoke-others (one segment) matches only itself.
|
|
93
|
+
register_ui_action(r"^/ui/account/sessions/[^/?#]+/revoke$", None, step_up=False)
|
|
94
|
+
register_ui_action(r"^/ui/account/sessions/revoke-others$", None, step_up=False)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def register(app: FastAPI, deps: UiDeps) -> None:
|
|
98
|
+
"""L4b/L5a/L6b self-service account surface: change-password, TOTP MFA lifecycle, WebAuthn passkeys, and session management."""
|
|
99
|
+
admin = deps.admin
|
|
100
|
+
|
|
101
|
+
# Post-redirect notices (allow-listed codes only — never reflected text).
|
|
102
|
+
_ACCOUNT_NOTICES = {
|
|
103
|
+
"mfa_off": "MFA disabled.",
|
|
104
|
+
"enroll_first": (
|
|
105
|
+
"That action requires MFA — enroll an authenticator (TOTP app or passkey) to continue."
|
|
106
|
+
),
|
|
107
|
+
"passkey_added": "Passkey added.",
|
|
108
|
+
"passkey_removed": "Passkey removed.",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async def _account_response(
|
|
112
|
+
service: AuthService,
|
|
113
|
+
identity: Identity,
|
|
114
|
+
request: Request,
|
|
115
|
+
*,
|
|
116
|
+
notice: str | None = None,
|
|
117
|
+
error: str | None = None,
|
|
118
|
+
status_code: int = 200,
|
|
119
|
+
) -> HTMLResponse:
|
|
120
|
+
mfa = await admin.my_mfa(service=service, identity=identity)
|
|
121
|
+
# L5a passkey rows (plain mappings — the pages module never touches the store); the
|
|
122
|
+
# "usable" flag compares each credential's mint-time rp_id to the CURRENT RP identity
|
|
123
|
+
# (an origin migration renders old credentials visibly unusable, ADR 0068 §7).
|
|
124
|
+
rp = webauthn_rp(request)
|
|
125
|
+
creds = await service.store.list_webauthn_credentials(identity.user_id)
|
|
126
|
+
passkeys = [
|
|
127
|
+
{
|
|
128
|
+
"label": c.label,
|
|
129
|
+
"created_at": c.created_at,
|
|
130
|
+
"last_used_at": c.last_used_at,
|
|
131
|
+
"backed_up": c.backed_up,
|
|
132
|
+
"usable": rp is not None and c.rp_id == rp[0],
|
|
133
|
+
"credential_id_hash": c.credential_id_hash,
|
|
134
|
+
}
|
|
135
|
+
for c in creds
|
|
136
|
+
]
|
|
137
|
+
wa_notice: str | None = None
|
|
138
|
+
if not service.webauthn_available():
|
|
139
|
+
wa_notice = WEBAUTHN_EXTRA_MISSING_NOTICE
|
|
140
|
+
elif rp is None:
|
|
141
|
+
wa_notice = WEBAUTHN_RP_MISSING_NOTICE
|
|
142
|
+
return HTMLResponse(
|
|
143
|
+
pages.account_page(
|
|
144
|
+
admin.current_user(identity),
|
|
145
|
+
mfa,
|
|
146
|
+
notice=notice,
|
|
147
|
+
error=error,
|
|
148
|
+
passkeys=passkeys,
|
|
149
|
+
webauthn_notice=wa_notice,
|
|
150
|
+
),
|
|
151
|
+
status_code=status_code,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@app.get("/ui/account", response_class=HTMLResponse)
|
|
155
|
+
async def ui_account(
|
|
156
|
+
request: Request,
|
|
157
|
+
service: AuthService = Depends(_service),
|
|
158
|
+
identity: Identity = Depends(require_ui(allow_mfa_pending=True)),
|
|
159
|
+
m: str | None = Query(None, max_length=32),
|
|
160
|
+
) -> HTMLResponse:
|
|
161
|
+
return await _account_response(
|
|
162
|
+
service, identity, request, notice=_ACCOUNT_NOTICES.get(m or "")
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@app.get("/ui/account/password", response_class=HTMLResponse)
|
|
166
|
+
async def ui_account_password_form(
|
|
167
|
+
identity: Identity = Depends(require_ui(allow_must_change=True, allow_mfa_pending=True)),
|
|
168
|
+
) -> HTMLResponse:
|
|
169
|
+
# `forced` comes from the SERVER-side flag, never a query param (unspoofable).
|
|
170
|
+
return HTMLResponse(pages.password_page(forced=identity.must_change_password))
|
|
171
|
+
|
|
172
|
+
@app.post("/ui/account/password")
|
|
173
|
+
async def ui_account_password(
|
|
174
|
+
request: Request,
|
|
175
|
+
service: AuthService = Depends(_service),
|
|
176
|
+
identity: Identity = Depends(require_ui(allow_must_change=True, allow_mfa_pending=True)),
|
|
177
|
+
) -> Response:
|
|
178
|
+
assert_same_origin(request)
|
|
179
|
+
# No throttle call here on purpose: this route DELEGATES to the JSON handler
|
|
180
|
+
# (admin.change_password) below, which applies the per-actor ceremony budget and whose 429 is
|
|
181
|
+
# re-raised intact. Charging here too would spend two tokens per submission.
|
|
182
|
+
form = dict(await _form_pairs(request))
|
|
183
|
+
forced = identity.must_change_password
|
|
184
|
+
|
|
185
|
+
def _retry(message: str, code: int = 400) -> HTMLResponse:
|
|
186
|
+
# Passwords are NEVER echoed back — the re-rendered form is always empty.
|
|
187
|
+
return HTMLResponse(pages.password_page(forced=forced, error=message), status_code=code)
|
|
188
|
+
|
|
189
|
+
if form.get("new_password", "") != form.get("new_password2", ""):
|
|
190
|
+
return _retry("the new passwords do not match")
|
|
191
|
+
try:
|
|
192
|
+
body = PasswordChangeRequest(
|
|
193
|
+
current_password=form.get("current_password", ""),
|
|
194
|
+
new_password=form.get("new_password", ""),
|
|
195
|
+
)
|
|
196
|
+
await admin.change_password(
|
|
197
|
+
body=body, request=request, service=service, identity=identity
|
|
198
|
+
)
|
|
199
|
+
except ValidationError:
|
|
200
|
+
return _retry("invalid input")
|
|
201
|
+
except HTTPException as exc:
|
|
202
|
+
if exc.status_code == status.HTTP_429_TOO_MANY_REQUESTS:
|
|
203
|
+
raise # rate-limited — keep the Retry-After semantics
|
|
204
|
+
return _retry(str(exc.detail), exc.status_code)
|
|
205
|
+
# Changed: the service revoked every session (incl. this cookie) — sign in again. This is the
|
|
206
|
+
# WIDEST termination the console offers (every session for this user, the one an operator
|
|
207
|
+
# reaches for after a suspected compromise), so it must drop the browser's cached PHI pages
|
|
208
|
+
# like any other: ``clear_session_cookie`` emits Clear-Site-Data with the deletion (ASVS
|
|
209
|
+
# 14.3.1), and ``pwchanged`` is an allow-listed Clear-Site-Data login code for the landing.
|
|
210
|
+
resp = RedirectResponse("/ui/login?e=pwchanged", status_code=303)
|
|
211
|
+
clear_session_cookie(resp, request)
|
|
212
|
+
return resp
|
|
213
|
+
|
|
214
|
+
@app.post("/ui/account/mfa/enroll")
|
|
215
|
+
async def ui_mfa_enroll(
|
|
216
|
+
request: Request,
|
|
217
|
+
service: AuthService = Depends(_service),
|
|
218
|
+
identity: Identity = Depends(require_ui_reauth_only_action(STEP_UP_ACTION_MFA_ENROLL)),
|
|
219
|
+
) -> Response:
|
|
220
|
+
assert_same_origin(request)
|
|
221
|
+
try:
|
|
222
|
+
enroll = await admin.enroll_mfa(service=service, identity=identity)
|
|
223
|
+
except HTTPException as exc:
|
|
224
|
+
# AD account / already enrolled — surface on the account page.
|
|
225
|
+
return await _account_response(
|
|
226
|
+
service, identity, request, error=str(exc.detail), status_code=400
|
|
227
|
+
)
|
|
228
|
+
# The staged secret renders ONCE (with the confirm form); it is inert until confirmed.
|
|
229
|
+
return HTMLResponse(pages.mfa_enroll_page(enroll.secret, enroll.otpauth_uri))
|
|
230
|
+
|
|
231
|
+
@app.get("/ui/account/mfa/confirm", response_class=HTMLResponse)
|
|
232
|
+
async def ui_mfa_confirm_form(
|
|
233
|
+
_identity: Identity = Depends(require_ui_reauth_only()),
|
|
234
|
+
) -> HTMLResponse:
|
|
235
|
+
# The unlock re-entry point: the secret is already staged server-side (and in the user's
|
|
236
|
+
# authenticator), so this form only collects the code — the secret is never re-shown.
|
|
237
|
+
return HTMLResponse(pages.mfa_confirm_page())
|
|
238
|
+
|
|
239
|
+
@app.post("/ui/account/mfa/verify")
|
|
240
|
+
async def ui_mfa_verify(
|
|
241
|
+
request: Request,
|
|
242
|
+
service: AuthService = Depends(_service),
|
|
243
|
+
identity: Identity = Depends(
|
|
244
|
+
require_ui_reauth_only_action(
|
|
245
|
+
STEP_UP_ACTION_MFA_CONFIRM, reauth_next=lambda _r: "/ui/account/mfa/confirm"
|
|
246
|
+
)
|
|
247
|
+
),
|
|
248
|
+
) -> Response:
|
|
249
|
+
assert_same_origin(request)
|
|
250
|
+
# The JSON confirm_mfa handler reads the HEADER bearer token (absent on a cookie request),
|
|
251
|
+
# so this route drives the SERVICE directly with the cookie session token — the same
|
|
252
|
+
# cookie-vs-header split ui_login/ui_reauth already handle. Semantics match the JSON
|
|
253
|
+
# handler: rate-limited like login; wrong code changes nothing.
|
|
254
|
+
client = _client(request)
|
|
255
|
+
if not allow_reauth_attempt(service, identity, client): # per-ACTOR, not the sign-in budget
|
|
256
|
+
raise _rate_limited(request, "mfa-confirm")
|
|
257
|
+
token = session_token(request)
|
|
258
|
+
if not token: # pragma: no cover - require_ui already authenticated this cookie
|
|
259
|
+
return RedirectResponse("/ui/login", status_code=303)
|
|
260
|
+
form = dict(await _form_pairs(request))
|
|
261
|
+
code = form.get("code", "").strip()
|
|
262
|
+
try:
|
|
263
|
+
codes = await service.confirm_mfa_enrollment(identity, code, token=token, client=client)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
# No enrollment staged / not a local account — back to the account page.
|
|
266
|
+
return await _account_response(
|
|
267
|
+
service, identity, request, error=str(exc), status_code=400
|
|
268
|
+
)
|
|
269
|
+
if codes is None:
|
|
270
|
+
return HTMLResponse(pages.mfa_confirm_page(error="Invalid code."), status_code=400)
|
|
271
|
+
# Activated: the recovery codes render ONCE — never re-fetchable.
|
|
272
|
+
return HTMLResponse(pages.mfa_recovery_page(codes))
|
|
273
|
+
|
|
274
|
+
@app.post("/ui/account/mfa/disable")
|
|
275
|
+
async def ui_mfa_disable(
|
|
276
|
+
request: Request,
|
|
277
|
+
service: AuthService = Depends(_service),
|
|
278
|
+
identity: Identity = Depends(require_ui_step_up_action(STEP_UP_ACTION_MFA_DISABLE)),
|
|
279
|
+
) -> Response:
|
|
280
|
+
assert_same_origin(request)
|
|
281
|
+
await admin.disable_my_mfa(request=request, service=service, identity=identity)
|
|
282
|
+
return RedirectResponse("/ui/account?m=mfa_off", status_code=303)
|
|
283
|
+
|
|
284
|
+
# --- L6b: self-service session management (#75 parity — desktop sessions.py twin) ---
|
|
285
|
+
# LISTING one's OWN sessions is plain cookie-authenticated self-service (require_ui). TERMINATING
|
|
286
|
+
# one (revoke one / revoke-others) additionally requires a fresh PASSWORD re-proof
|
|
287
|
+
# (require_ui_reauth_only — ASVS 7.5.2), NOT the full MFA step-up: a no-factor user must still be
|
|
288
|
+
# able to revoke, and require_ui_step_up would deadlock them (WP-14). A fresh login seeds the
|
|
289
|
+
# step-up window, so an immediate post-login revoke is unaffected; a stale window 303s to
|
|
290
|
+
# /ui/reauth?next=<this action> and auto-retries after re-verification (both terminate POSTs are
|
|
291
|
+
# registered auto_retry, step_up=False, above). The handlers read the HEADER/cookie session token
|
|
292
|
+
# to identify the current session and drive the SERVICE directly (the ui_mfa_verify pattern).
|
|
293
|
+
_SESSION_NOTICES = {
|
|
294
|
+
"revoked": "Session revoked.",
|
|
295
|
+
"signed_out_others": "Signed out of your other sessions.",
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@app.get("/ui/account/sessions", response_class=HTMLResponse)
|
|
299
|
+
async def ui_account_sessions(
|
|
300
|
+
request: Request,
|
|
301
|
+
service: AuthService = Depends(_service),
|
|
302
|
+
identity: Identity = Depends(require_ui()),
|
|
303
|
+
m: str | None = Query(None, max_length=32),
|
|
304
|
+
) -> HTMLResponse:
|
|
305
|
+
current = hash_token(session_token(request) or "")
|
|
306
|
+
sessions = await service.list_sessions(identity.user_id)
|
|
307
|
+
rows = [
|
|
308
|
+
{
|
|
309
|
+
"id": s.token_hash,
|
|
310
|
+
"created_at": s.created_at,
|
|
311
|
+
"last_used_at": s.last_used_at,
|
|
312
|
+
"expires_at": s.expires_at,
|
|
313
|
+
"client": s.client,
|
|
314
|
+
"current": s.token_hash == current,
|
|
315
|
+
}
|
|
316
|
+
for s in sessions
|
|
317
|
+
]
|
|
318
|
+
return HTMLResponse(pages.sessions_page(rows, notice=_SESSION_NOTICES.get(m or "")))
|
|
319
|
+
|
|
320
|
+
@app.post("/ui/account/sessions/{session_id}/revoke")
|
|
321
|
+
async def ui_revoke_session(
|
|
322
|
+
session_id: str,
|
|
323
|
+
request: Request,
|
|
324
|
+
service: AuthService = Depends(_service),
|
|
325
|
+
identity: Identity = Depends(require_ui_reauth_only()),
|
|
326
|
+
) -> Response:
|
|
327
|
+
assert_same_origin(request)
|
|
328
|
+
# Ownership-checked in the service; an unknown/foreign id is a silent no-op (never
|
|
329
|
+
# confirms another user's session id). Revoking the CURRENT session logs the caller
|
|
330
|
+
# out — the next request finds no session and 303s to login.
|
|
331
|
+
await service.revoke_own_session(identity, session_id, actor=identity.username)
|
|
332
|
+
return RedirectResponse("/ui/account/sessions?m=revoked", status_code=303)
|
|
333
|
+
|
|
334
|
+
@app.post("/ui/account/sessions/revoke-others")
|
|
335
|
+
async def ui_revoke_other_sessions(
|
|
336
|
+
request: Request,
|
|
337
|
+
service: AuthService = Depends(_service),
|
|
338
|
+
identity: Identity = Depends(require_ui_reauth_only()),
|
|
339
|
+
) -> Response:
|
|
340
|
+
assert_same_origin(request)
|
|
341
|
+
current = hash_token(session_token(request) or "")
|
|
342
|
+
await service.revoke_other_sessions(identity, current, actor=identity.username)
|
|
343
|
+
return RedirectResponse("/ui/account/sessions?m=signed_out_others", status_code=303)
|
|
344
|
+
|
|
345
|
+
_RP_NAME = "MessageFoundry"
|
|
346
|
+
|
|
347
|
+
@app.post("/ui/account/webauthn/enroll")
|
|
348
|
+
async def ui_webauthn_enroll(
|
|
349
|
+
request: Request,
|
|
350
|
+
service: AuthService = Depends(_service),
|
|
351
|
+
identity: Identity = Depends(require_ui_reauth_only_action(STEP_UP_ACTION_WEBAUTHN_ENROLL)),
|
|
352
|
+
) -> Response:
|
|
353
|
+
assert_same_origin(request)
|
|
354
|
+
if not service.webauthn_available():
|
|
355
|
+
return await _account_response(
|
|
356
|
+
service,
|
|
357
|
+
identity,
|
|
358
|
+
request,
|
|
359
|
+
error=WEBAUTHN_EXTRA_MISSING_NOTICE,
|
|
360
|
+
status_code=400,
|
|
361
|
+
)
|
|
362
|
+
rp = webauthn_rp(request)
|
|
363
|
+
if rp is None:
|
|
364
|
+
# Fail closed, legibly (ADR 0068 §7): 409 + the shared notice on the account page.
|
|
365
|
+
return await _account_response(
|
|
366
|
+
service,
|
|
367
|
+
identity,
|
|
368
|
+
request,
|
|
369
|
+
error=WEBAUTHN_RP_MISSING_NOTICE,
|
|
370
|
+
status_code=409,
|
|
371
|
+
)
|
|
372
|
+
token = session_token(request)
|
|
373
|
+
if not token: # pragma: no cover - require_ui_reauth_only authenticated this cookie
|
|
374
|
+
return RedirectResponse("/ui/login", status_code=303)
|
|
375
|
+
try:
|
|
376
|
+
options = await service.begin_webauthn_registration(
|
|
377
|
+
identity, token=token, rp_id=rp[0], rp_name=_RP_NAME
|
|
378
|
+
)
|
|
379
|
+
except ValueError as exc: # AD account
|
|
380
|
+
return await _account_response(
|
|
381
|
+
service, identity, request, error=str(exc), status_code=400
|
|
382
|
+
)
|
|
383
|
+
except webauthn_mod.ChallengeCacheFullError as exc:
|
|
384
|
+
return await _account_response(
|
|
385
|
+
service, identity, request, error=str(exc), status_code=503
|
|
386
|
+
)
|
|
387
|
+
# The creation options render ONCE in a data-* hook (CSP: no inline script).
|
|
388
|
+
return HTMLResponse(pages.webauthn_enroll_page(options))
|
|
389
|
+
|
|
390
|
+
@app.post("/ui/account/webauthn/verify")
|
|
391
|
+
async def ui_webauthn_verify(
|
|
392
|
+
request: Request,
|
|
393
|
+
service: AuthService = Depends(_service),
|
|
394
|
+
identity: Identity = Depends(
|
|
395
|
+
require_ui_reauth_only(reauth_next=lambda _r: "/ui/account/webauthn/enroll")
|
|
396
|
+
),
|
|
397
|
+
) -> Response:
|
|
398
|
+
assert_same_origin(request)
|
|
399
|
+
rp = webauthn_rp(request)
|
|
400
|
+
if rp is None:
|
|
401
|
+
return JSONResponse({"ok": False, "error": "rp_unavailable"}, status_code=409)
|
|
402
|
+
token = session_token(request)
|
|
403
|
+
if not token: # pragma: no cover - require_ui_reauth_only authenticated this cookie
|
|
404
|
+
return JSONResponse({"ok": False, "error": "session expired"}, status_code=401)
|
|
405
|
+
try:
|
|
406
|
+
body = await request.json()
|
|
407
|
+
response_json = json.dumps(body["response"])
|
|
408
|
+
label = str(body.get("label", ""))
|
|
409
|
+
except (ValueError, KeyError, TypeError):
|
|
410
|
+
return JSONResponse({"ok": False, "error": "malformed request"}, status_code=400)
|
|
411
|
+
try:
|
|
412
|
+
ok = await service.finish_webauthn_registration(
|
|
413
|
+
identity,
|
|
414
|
+
response_json,
|
|
415
|
+
label=label,
|
|
416
|
+
token=token,
|
|
417
|
+
client=_client(request),
|
|
418
|
+
rp_id=rp[0],
|
|
419
|
+
origin=rp[1],
|
|
420
|
+
)
|
|
421
|
+
except ValueError as exc:
|
|
422
|
+
# Service-authored, safe messages only (expired ceremony / duplicate label or
|
|
423
|
+
# credential / bad label / AD) — never reflected input.
|
|
424
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
425
|
+
if not ok:
|
|
426
|
+
return JSONResponse(
|
|
427
|
+
{"ok": False, "error": "passkey verification failed"}, status_code=400
|
|
428
|
+
)
|
|
429
|
+
return JSONResponse({"ok": True, "redirect": "/ui/account?m=passkey_added"})
|
|
430
|
+
|
|
431
|
+
@app.post("/ui/account/webauthn/{credential_id_hash}/delete")
|
|
432
|
+
async def ui_webauthn_delete(
|
|
433
|
+
credential_id_hash: str,
|
|
434
|
+
request: Request,
|
|
435
|
+
service: AuthService = Depends(_service),
|
|
436
|
+
identity: Identity = Depends(require_ui_step_up_action(STEP_UP_ACTION_WEBAUTHN_DELETE)),
|
|
437
|
+
) -> Response:
|
|
438
|
+
assert_same_origin(request)
|
|
439
|
+
try:
|
|
440
|
+
removed = await service.delete_webauthn_credential(
|
|
441
|
+
identity, credential_id_hash, client=_client(request)
|
|
442
|
+
)
|
|
443
|
+
except ValueError as exc: # last-required-factor refusal
|
|
444
|
+
return await _account_response(
|
|
445
|
+
service, identity, request, error=str(exc), status_code=400
|
|
446
|
+
)
|
|
447
|
+
if not removed:
|
|
448
|
+
return await _account_response(
|
|
449
|
+
service, identity, request, error="No such passkey.", status_code=404
|
|
450
|
+
)
|
|
451
|
+
return RedirectResponse("/ui/account?m=passkey_removed", status_code=303)
|