keepup-http 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {keepup_http-0.5.0 → keepup_http-0.6.0}/PKG-INFO +1 -1
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/__init__.py +1 -1
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/factory.py +14 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/routes.py +219 -89
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/index_nebula.html +2 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/index_new.html +2 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/main_new.js +64 -24
- keepup_http-0.6.0/keepup_http/static/js/mfa_panel.js +342 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/PKG-INFO +1 -1
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/SOURCES.txt +1 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/pyproject.toml +1 -1
- {keepup_http-0.5.0 → keepup_http-0.6.0}/README.md +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/api_docs.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/body_limit.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/events_api.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/lock_routes.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/plugin.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/aos.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/main_nebula.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/main_new.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/css/tailwind.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/aos.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/feather-icons.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/layout_bootstrap.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/js/tailwind.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/background_tasks.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/cluster.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/event_manager.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/integration_logs.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/metrics.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/modules.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/themes.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/css/users.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/background_tasks.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/chart.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/cluster.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/event_manager.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/integration_logs.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/metrics.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/modules.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/themes.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/static/modules/js/users.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http/web.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/dependency_links.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/entry_points.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/requires.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/keepup_http.egg-info/top_level.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.6.0}/setup.cfg +0 -0
|
@@ -10,7 +10,7 @@ route wrapper; they move in one slice at a time, and the base answers their old
|
|
|
10
10
|
names through the compatibility layer for one release (keepup-122).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
__version__ = "0.
|
|
13
|
+
__version__ = "0.6.0"
|
|
14
14
|
|
|
15
15
|
__all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
|
|
16
16
|
"register_event_api_routes"]
|
|
@@ -43,6 +43,7 @@ from keepup_audit.events import init_event_manager
|
|
|
43
43
|
from keepup_http.events_api import register_event_api_routes
|
|
44
44
|
from keepup.instance import get_instance_id
|
|
45
45
|
from keepup_http.lock_routes import register_lock_routes
|
|
46
|
+
from keepup_http.routes import register_declared_routes, register_declared_sockets
|
|
46
47
|
from keepup_metrics.api import register_metrics_routes
|
|
47
48
|
from keepup_modules.catalogue import register_module_routes
|
|
48
49
|
from keepup.plugins.registry import (
|
|
@@ -314,6 +315,19 @@ def _build_lifespan(settings: KeepupSettings):
|
|
|
314
315
|
if manager is not None:
|
|
315
316
|
await initialize_plugins(app, manager, config_path=settings.plugins_config_path)
|
|
316
317
|
|
|
318
|
+
# What the kernel's plugins declared is bound here (keepup-121): an
|
|
319
|
+
# application that named only `plugins_dir` used to get no route of its
|
|
320
|
+
# own, because only a PluginManager of the 0.3.0 path ever bound them.
|
|
321
|
+
# The manager above registered its declarations a moment ago; a route the
|
|
322
|
+
# application already answers is skipped rather than answered twice.
|
|
323
|
+
if kernel is not None:
|
|
324
|
+
mounted = kernel.mount({
|
|
325
|
+
"routes": lambda items, runtime=None: register_declared_routes(app, items),
|
|
326
|
+
"sockets": lambda items, runtime=None: register_declared_sockets(app, items),
|
|
327
|
+
})
|
|
328
|
+
if mounted:
|
|
329
|
+
logger.info("Mounted what the plugins declared: %s", mounted)
|
|
330
|
+
|
|
317
331
|
# The log handlers the running capabilities contribute -- the shipping
|
|
318
332
|
# one, when a collector is named (keepup-118). A deployment that
|
|
319
333
|
# installs none attaches nothing.
|
|
@@ -40,6 +40,8 @@ from keepup.plugins import route_mask
|
|
|
40
40
|
__all__ = [
|
|
41
41
|
"accepted_params",
|
|
42
42
|
"raw_request_wrapper",
|
|
43
|
+
"register_declared_routes",
|
|
44
|
+
"register_declared_sockets",
|
|
43
45
|
"register_plugin_routes",
|
|
44
46
|
]
|
|
45
47
|
|
|
@@ -335,10 +337,9 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
|
|
|
335
337
|
|
|
336
338
|
async def call(request: Request, current_user):
|
|
337
339
|
"""One call of a plugin route, from the audit's first row to its last."""
|
|
338
|
-
request_data
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
340
|
+
# The actor is not part of request_data: that is redacted whole, and a
|
|
341
|
+
# row that cannot name who called is not an audit (keepup-172).
|
|
342
|
+
request_data = {}
|
|
342
343
|
if has_path_params:
|
|
343
344
|
request_data['path_params'] = dict(request.path_params)
|
|
344
345
|
request_data['query_params'] = dict(request.query_params)
|
|
@@ -353,7 +354,8 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
|
|
|
353
354
|
method=method_name,
|
|
354
355
|
endpoint=path,
|
|
355
356
|
host='plugin_api',
|
|
356
|
-
request_data=request_data
|
|
357
|
+
request_data=request_data,
|
|
358
|
+
actor=current_user
|
|
357
359
|
) as request_id:
|
|
358
360
|
try:
|
|
359
361
|
response = await invoke(request, current_user)
|
|
@@ -413,95 +415,223 @@ async def register_plugin_routes(app, manager):
|
|
|
413
415
|
logger.info(f"Found {len(routes)} plugin routes to register")
|
|
414
416
|
|
|
415
417
|
for route in routes:
|
|
416
|
-
|
|
417
|
-
path = route['path']
|
|
418
|
-
methods = route['methods']
|
|
419
|
-
require_auth = route.get('require_auth', True)
|
|
420
|
-
is_upload = route.get('is_upload', False)
|
|
421
|
-
permission = route.get('permission')
|
|
422
|
-
audit = route.get('audit', True)
|
|
423
|
-
response_media_type = route.get('response_media_type')
|
|
424
|
-
if response_media_type is not None and (
|
|
425
|
-
not isinstance(response_media_type, str) or not response_media_type.strip()):
|
|
426
|
-
raise ValueError(
|
|
427
|
-
f"{path}: response_media_type is a media type, like 'text/plain'")
|
|
428
|
-
if response_media_type is not None and route.get('raw_request'):
|
|
429
|
-
raise ValueError(
|
|
430
|
-
f"{path}: a raw_request route answers with a Response of its own; "
|
|
431
|
-
"response_media_type would be ignored. Drop one of the two.")
|
|
432
|
-
if permission is not None:
|
|
433
|
-
# A right needs somebody to hold it: on a route nobody signs in to,
|
|
434
|
-
# or one that signs itself in, it would be a check that never runs.
|
|
435
|
-
if not isinstance(permission, str) or not permission.strip():
|
|
436
|
-
raise ValueError(f"{path}: permission must be a non-empty string")
|
|
437
|
-
if route.get('raw_request') or not require_auth:
|
|
438
|
-
raise ValueError(
|
|
439
|
-
f"{path}: a route that declares a permission must be signed in by "
|
|
440
|
-
"the framework -- drop require_auth: false or raw_request, or the "
|
|
441
|
-
"permission")
|
|
442
|
-
|
|
443
|
-
logger.info(
|
|
444
|
-
f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
|
|
445
|
-
|
|
446
|
-
# The kernel parsed and checked the mask when it collected the route,
|
|
447
|
-
# for every transport there will ever be (keepup-102); a route that
|
|
448
|
-
# reaches here without one was declared by something else, and is
|
|
449
|
-
# checked the same way rather than trusted.
|
|
450
|
-
mask = route.get('_mask')
|
|
451
|
-
if mask is None:
|
|
452
|
-
try:
|
|
453
|
-
mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
|
|
454
|
-
route_mask.check_signature(handler, mask)
|
|
455
|
-
except route_mask.MaskError as error:
|
|
456
|
-
# The same loudness as the raw_request check below: a mask naming
|
|
457
|
-
# a parameter the handler does not take is a typo, and finding it
|
|
458
|
-
# on a request would mean hearing about it from a user.
|
|
459
|
-
raise ValueError(f"{path}: {error}")
|
|
460
|
-
|
|
461
|
-
if route.get('raw_request'):
|
|
462
|
-
# A raw route authenticates inside the handler -- it is handed the
|
|
463
|
-
# Request and nothing else. Declaring require_auth on it therefore
|
|
464
|
-
# promises a check the framework does not make, and the start-up
|
|
465
|
-
# line used to print auth=True over an open route (keepup-12).
|
|
466
|
-
if route.get('require_auth') is True:
|
|
467
|
-
raise ValueError(
|
|
468
|
-
f"{path}: a raw_request route cannot also declare "
|
|
469
|
-
"require_auth -- the handler receives the Request and "
|
|
470
|
-
"authenticates itself. Drop one of the two.")
|
|
471
|
-
require_auth = False
|
|
472
|
-
wrapper = raw_request_wrapper(handler)
|
|
473
|
-
else:
|
|
474
|
-
wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
|
|
475
|
-
permission, audit=audit,
|
|
476
|
-
response_media_type=response_media_type)
|
|
477
|
-
|
|
478
|
-
# What the route says about its body: its own limit, or that it reads
|
|
479
|
-
# the body itself (keepup/body_limit.py).
|
|
480
|
-
declare_limit(wrapper, route)
|
|
481
|
-
|
|
482
|
-
app.add_api_route(
|
|
483
|
-
path,
|
|
484
|
-
wrapper,
|
|
485
|
-
methods=methods,
|
|
486
|
-
include_in_schema=route.get('include_in_schema', True)
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
logger.info(
|
|
490
|
-
f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
|
|
418
|
+
_mount_route(app, route)
|
|
491
419
|
|
|
492
420
|
websocket_routes = manager.get_all_websocket_routes()
|
|
493
421
|
logger.info(f"Found {len(websocket_routes)} WebSocket plugin routes to register")
|
|
494
422
|
|
|
495
423
|
for ws_route in websocket_routes:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
424
|
+
_mount_socket(app, ws_route)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def register_declared_routes(app, declarations) -> int:
|
|
428
|
+
"""Mount the routes the kernel collected from the plugins (keepup-121).
|
|
429
|
+
|
|
430
|
+
The kernel resolves, loads and initialises the plugins, and what they
|
|
431
|
+
declared is the runtime's, transport-neutral: this is the HTTP half of that
|
|
432
|
+
seam, the call ``runtime.mount`` hands to a registrar. Until it existed an
|
|
433
|
+
application that named only ``plugins_dir`` got no route of its own, because
|
|
434
|
+
only a PluginManager of the 0.3.0 path ever bound them.
|
|
435
|
+
|
|
436
|
+
What the application already answers is left alone: the framework's own
|
|
437
|
+
routes are registered before the kernel starts, and an application that
|
|
438
|
+
passes a manager has its declarations bound by `register_plugin_routes` --
|
|
439
|
+
so one declaration never becomes two endpoints.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
app: the FastAPI application.
|
|
443
|
+
declarations: the route declarations the kernel collected, each tagged
|
|
444
|
+
with ``_plugin_id`` and, because the collector does it once for every
|
|
445
|
+
transport, a parsed ``_mask``.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
How many declarations were mounted.
|
|
449
|
+
"""
|
|
450
|
+
mounted = 0
|
|
451
|
+
for declaration in declarations:
|
|
452
|
+
path = declaration["path"]
|
|
453
|
+
methods = tuple(declaration.get("methods") or ())
|
|
454
|
+
if _already_mounted(app, path, methods):
|
|
455
|
+
logger.info("Route %s %s already answers: the kernel's declaration is "
|
|
456
|
+
"not mounted twice", path, list(methods))
|
|
457
|
+
continue
|
|
458
|
+
_mount_route(app, declaration)
|
|
459
|
+
mounted += 1
|
|
460
|
+
return mounted
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def register_declared_sockets(app, declarations) -> int:
|
|
464
|
+
"""Mount the sockets the kernel collected from the plugins (keepup-121).
|
|
465
|
+
|
|
466
|
+
The same seam as `register_declared_routes`, for the other thing a plugin
|
|
467
|
+
declares. A socket the application already answers is left alone.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
app: the FastAPI application.
|
|
471
|
+
declarations: the socket declarations the kernel collected.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
How many declarations were mounted.
|
|
475
|
+
"""
|
|
476
|
+
mounted = 0
|
|
477
|
+
for declaration in declarations:
|
|
478
|
+
path = declaration["path"]
|
|
479
|
+
if _already_mounted(app, path):
|
|
480
|
+
logger.info("Socket %s already answers: the kernel's declaration is not "
|
|
481
|
+
"mounted twice", path)
|
|
482
|
+
continue
|
|
483
|
+
_mount_socket(app, declaration)
|
|
484
|
+
mounted += 1
|
|
485
|
+
return mounted
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _mount_route(app, declaration):
|
|
489
|
+
"""Turn one declared route into an endpoint, having checked it first.
|
|
490
|
+
|
|
491
|
+
One implementation for every declaration, whatever collected it -- the
|
|
492
|
+
manager of the 0.3.0 path or the kernel's catalogue (keepup-21, keepup-121).
|
|
493
|
+
A declaration the route runtime refuses -- a permission on a route nobody
|
|
494
|
+
signs in to, a mask naming a parameter the handler does not take -- is a
|
|
495
|
+
mistake found at the start, rather than a route that quietly goes missing
|
|
496
|
+
(keepup-102).
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
app: the FastAPI application.
|
|
500
|
+
declaration: the route as the plugin declared it. The kernel's collector
|
|
501
|
+
leaves a parsed ``_mask`` on it; a declaration from anywhere else is
|
|
502
|
+
checked here instead of trusted.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ValueError: when the declaration is one the runtime refuses.
|
|
506
|
+
"""
|
|
507
|
+
route = declaration
|
|
508
|
+
handler = route['handler']
|
|
509
|
+
path = route['path']
|
|
510
|
+
methods = route['methods']
|
|
511
|
+
require_auth = route.get('require_auth', True)
|
|
512
|
+
is_upload = route.get('is_upload', False)
|
|
513
|
+
permission = route.get('permission')
|
|
514
|
+
audit = route.get('audit', True)
|
|
515
|
+
response_media_type = route.get('response_media_type')
|
|
516
|
+
if response_media_type is not None and (
|
|
517
|
+
not isinstance(response_media_type, str) or not response_media_type.strip()):
|
|
518
|
+
raise ValueError(
|
|
519
|
+
f"{path}: response_media_type is a media type, like 'text/plain'")
|
|
520
|
+
if response_media_type is not None and route.get('raw_request'):
|
|
521
|
+
raise ValueError(
|
|
522
|
+
f"{path}: a raw_request route answers with a Response of its own; "
|
|
523
|
+
"response_media_type would be ignored. Drop one of the two.")
|
|
524
|
+
if permission is not None:
|
|
525
|
+
# A right needs somebody to hold it: on a route nobody signs in to,
|
|
526
|
+
# or one that signs itself in, it would be a check that never runs.
|
|
527
|
+
if not isinstance(permission, str) or not permission.strip():
|
|
528
|
+
raise ValueError(f"{path}: permission must be a non-empty string")
|
|
529
|
+
if route.get('raw_request') or not require_auth:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"{path}: a route that declares a permission must be signed in by "
|
|
532
|
+
"the framework -- drop require_auth: false or raw_request, or the "
|
|
533
|
+
"permission")
|
|
534
|
+
|
|
535
|
+
logger.info(
|
|
536
|
+
f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
|
|
537
|
+
|
|
538
|
+
# The kernel parsed and checked the mask when it collected the route,
|
|
539
|
+
# for every transport there will ever be (keepup-102); a route that
|
|
540
|
+
# reaches here without one was declared by something else, and is
|
|
541
|
+
# checked the same way rather than trusted.
|
|
542
|
+
mask = route.get('_mask')
|
|
543
|
+
if mask is None:
|
|
544
|
+
try:
|
|
545
|
+
mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
|
|
546
|
+
route_mask.check_signature(handler, mask)
|
|
547
|
+
except route_mask.MaskError as error:
|
|
548
|
+
# The same loudness as the raw_request check below: a mask naming
|
|
549
|
+
# a parameter the handler does not take is a typo, and finding it
|
|
550
|
+
# on a request would mean hearing about it from a user.
|
|
551
|
+
raise ValueError(f"{path}: {error}")
|
|
552
|
+
|
|
553
|
+
if route.get('raw_request'):
|
|
554
|
+
# A raw route authenticates inside the handler -- it is handed the
|
|
555
|
+
# Request and nothing else. Declaring require_auth on it therefore
|
|
556
|
+
# promises a check the framework does not make, and the start-up
|
|
557
|
+
# line used to print auth=True over an open route (keepup-12).
|
|
558
|
+
if route.get('require_auth') is True:
|
|
559
|
+
raise ValueError(
|
|
560
|
+
f"{path}: a raw_request route cannot also declare "
|
|
561
|
+
"require_auth -- the handler receives the Request and "
|
|
562
|
+
"authenticates itself. Drop one of the two.")
|
|
563
|
+
require_auth = False
|
|
564
|
+
wrapper = raw_request_wrapper(handler)
|
|
565
|
+
else:
|
|
566
|
+
wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
|
|
567
|
+
permission, audit=audit,
|
|
568
|
+
response_media_type=response_media_type)
|
|
569
|
+
|
|
570
|
+
# What the route says about its body: its own limit, or that it reads
|
|
571
|
+
# the body itself (keepup/body_limit.py).
|
|
572
|
+
declare_limit(wrapper, route)
|
|
573
|
+
|
|
574
|
+
app.add_api_route(
|
|
575
|
+
path,
|
|
576
|
+
wrapper,
|
|
577
|
+
methods=methods,
|
|
578
|
+
include_in_schema=route.get('include_in_schema', True)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
logger.info(
|
|
582
|
+
f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _mount_socket(app, declaration):
|
|
586
|
+
"""Turn one declared socket into a route on the application.
|
|
587
|
+
|
|
588
|
+
The other half of the same seam: the kernel collects what a plugin declared,
|
|
589
|
+
and this is where HTTP binds it. Kept beside `_mount_route` because one
|
|
590
|
+
declaration should have one way in, whatever collected it.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
app: the FastAPI application.
|
|
594
|
+
declaration: the socket as the plugin declared it.
|
|
595
|
+
"""
|
|
596
|
+
ws_route = declaration
|
|
597
|
+
path = ws_route['path']
|
|
598
|
+
handler = ws_route['handler']
|
|
599
|
+
# Off by default, unlike HTTP routes: sockets of agents and guests carry
|
|
600
|
+
# tokens of their own and would be refused by the panel's sign-in rule.
|
|
601
|
+
if ws_route.get('require_auth', False):
|
|
602
|
+
handler = signed_in_websocket(handler)
|
|
603
|
+
_register_websocket(app, path, handler)
|
|
604
|
+
logger.info(f"Registered WebSocket plugin route: {path} "
|
|
605
|
+
f"(auth={bool(ws_route.get('require_auth', False))})")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _already_mounted(app, path, methods=None) -> bool:
|
|
609
|
+
"""Whether the application already answers this address, and these verbs.
|
|
610
|
+
|
|
611
|
+
Asked before a kernel-collected declaration is mounted: the framework's own
|
|
612
|
+
routes and the ones a PluginManager bound are registered first, and a path
|
|
613
|
+
registered twice is an application that answers twice. A socket is asked
|
|
614
|
+
about by path alone -- it has no methods of its own.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
app: the FastAPI application.
|
|
618
|
+
path: the address.
|
|
619
|
+
methods: the verbs, or None for a socket.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
True when the application already has it.
|
|
623
|
+
"""
|
|
624
|
+
for route in getattr(app, "routes", ()):
|
|
625
|
+
if getattr(route, "path", None) != path:
|
|
626
|
+
continue
|
|
627
|
+
existing = getattr(route, "methods", None)
|
|
628
|
+
if methods is None:
|
|
629
|
+
if not existing:
|
|
630
|
+
return True
|
|
631
|
+
continue
|
|
632
|
+
if tuple(sorted(existing or ())) == tuple(sorted(methods)):
|
|
633
|
+
return True
|
|
634
|
+
return False
|
|
505
635
|
|
|
506
636
|
|
|
507
637
|
def _register_websocket(app, path, handler):
|
|
@@ -77,13 +77,11 @@ const ROLE_CLIENT = 'CLIENT';
|
|
|
77
77
|
//
|
|
78
78
|
// A user holds a set of roles, and the panel decides by the set: somebody who is
|
|
79
79
|
// both an administrator and a client holds two, and comparing one field with
|
|
80
|
-
// ROLE_ADMIN would have made them choose.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// under node -- see tests/user_roles_tests.py.
|
|
80
|
+
// ROLE_ADMIN would have made them choose. The set is the only answer, in the
|
|
81
|
+
// answers and in the table (keepup-51, keepup-168). Cut out by these markers and
|
|
82
|
+
// executed under node -- see tests/user_roles_tests.py.
|
|
84
83
|
function userRolesOf(user) {
|
|
85
|
-
|
|
86
|
-
return user && user.role ? [user.role] : [];
|
|
84
|
+
return user && Array.isArray(user.roles) ? user.roles : [];
|
|
87
85
|
}
|
|
88
86
|
|
|
89
87
|
function userHasRole(user, role) {
|
|
@@ -1189,6 +1187,21 @@ function setupAuthForms() {
|
|
|
1189
1187
|
// Updated sign-in form handler
|
|
1190
1188
|
document.getElementById('loginForm').addEventListener('submit', function (e) {
|
|
1191
1189
|
e.preventDefault();
|
|
1190
|
+
// The password earned a challenge, not a session: the same form now
|
|
1191
|
+
// carries the code (keepup-176).
|
|
1192
|
+
if (window.MfaPanel && MfaPanel.challengeToken()) {
|
|
1193
|
+
const code = document.getElementById('loginCode');
|
|
1194
|
+
MfaPanel.verify(code ? code.value : '').then(function (result) {
|
|
1195
|
+
if (result.ok) {
|
|
1196
|
+
finishSignIn(result.data);
|
|
1197
|
+
} else {
|
|
1198
|
+
showAuthMessage(result.data.detail || 'That code is not right', 'error');
|
|
1199
|
+
}
|
|
1200
|
+
}).catch(function (error) {
|
|
1201
|
+
showAuthMessage('Network error: ' + error, 'error');
|
|
1202
|
+
});
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1192
1205
|
const username = document.getElementById('loginUsername').value;
|
|
1193
1206
|
const password = document.getElementById('loginPassword').value;
|
|
1194
1207
|
login(username, password);
|
|
@@ -1295,26 +1308,20 @@ async function login(username, password) {
|
|
|
1295
1308
|
const data = await response.json();
|
|
1296
1309
|
|
|
1297
1310
|
if (response.ok) {
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
if (
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
return;
|
|
1311
|
+
// A confirmed second factor answers here with a challenge, not a
|
|
1312
|
+
// session: no marker, no refresh timer, no /me without a cookie
|
|
1313
|
+
// (keepup-176). Before this, the panel called that a success and
|
|
1314
|
+
// then showed "Failed to get user data".
|
|
1315
|
+
if (data.mfa_required) {
|
|
1316
|
+
if (window.MfaPanel) {
|
|
1317
|
+
MfaPanel.showChallenge(data.mfa_token);
|
|
1318
|
+
showAuthMessage('Enter the code from your authenticator app.', 'info');
|
|
1319
|
+
} else {
|
|
1320
|
+
showAuthMessage('This panel cannot ask for the second factor.', 'error');
|
|
1309
1321
|
}
|
|
1310
|
-
|
|
1311
|
-
refreshEntireUI();
|
|
1312
|
-
|
|
1313
|
-
closeAuthModal();
|
|
1314
|
-
showAuthMessage('Signed in successfully!', 'success');
|
|
1315
|
-
} else {
|
|
1316
|
-
showAuthMessage('Failed to get user data', 'error');
|
|
1322
|
+
return;
|
|
1317
1323
|
}
|
|
1324
|
+
await finishSignIn(data);
|
|
1318
1325
|
} else {
|
|
1319
1326
|
showAuthMessage(data.detail || 'Sign-in failed', 'error');
|
|
1320
1327
|
}
|
|
@@ -1323,6 +1330,35 @@ async function login(username, password) {
|
|
|
1323
1330
|
}
|
|
1324
1331
|
}
|
|
1325
1332
|
|
|
1333
|
+
async function finishSignIn(data) {
|
|
1334
|
+
// The one place a sign-in becomes a session in this browser, whichever
|
|
1335
|
+
// door it came through: password, or password and a code (keepup-176).
|
|
1336
|
+
if (!data || !data.access_token) {
|
|
1337
|
+
showAuthMessage('Sign-in failed: the server answered without a session.', 'error');
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
localStorage.setItem('authToken', SESSION_MARKER);
|
|
1341
|
+
scheduleSessionRefresh(data.expires_in);
|
|
1342
|
+
const userResponse = await fetch('/api/auth/me');
|
|
1343
|
+
|
|
1344
|
+
if (userResponse.ok) {
|
|
1345
|
+
const userData = await userResponse.json();
|
|
1346
|
+
currentUser = userData;
|
|
1347
|
+
updateUIAfterAuth();
|
|
1348
|
+
closeAuthModal();
|
|
1349
|
+
if (!(await ensureAgreements())) {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
await loadModules();
|
|
1353
|
+
refreshEntireUI();
|
|
1354
|
+
|
|
1355
|
+
closeAuthModal();
|
|
1356
|
+
showAuthMessage('Signed in successfully!', 'success');
|
|
1357
|
+
} else {
|
|
1358
|
+
showAuthMessage('Failed to get user data', 'error');
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1326
1362
|
function togglePasswordVisibility(inputId) {
|
|
1327
1363
|
const passwordInput = document.getElementById(inputId);
|
|
1328
1364
|
const toggleButton = document.getElementById(inputId + 'Toggle');
|
|
@@ -1540,6 +1576,7 @@ async function logout() {
|
|
|
1540
1576
|
}
|
|
1541
1577
|
localStorage.removeItem('authToken');
|
|
1542
1578
|
localStorage.removeItem('auth_token');
|
|
1579
|
+
if (window.MfaPanel) MfaPanel.clearChallenge();
|
|
1543
1580
|
currentUser = null;
|
|
1544
1581
|
headerBadge = null;
|
|
1545
1582
|
updateUIAfterAuth();
|
|
@@ -1812,6 +1849,9 @@ document.head.appendChild(styleSheet);
|
|
|
1812
1849
|
|
|
1813
1850
|
document.addEventListener('DOMContentLoaded', function() {
|
|
1814
1851
|
initializeApp();
|
|
1852
|
+
if (window.MfaPanel) {
|
|
1853
|
+
MfaPanel.install();
|
|
1854
|
+
}
|
|
1815
1855
|
checkAuth();
|
|
1816
1856
|
});
|
|
1817
1857
|
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// The second factor, seen by a person (keepup-176).
|
|
2
|
+
//
|
|
3
|
+
// The server has answered the API since keepup-174: a confirmed account gets
|
|
4
|
+
// `mfa_required` and a five-minute challenge instead of a session, and the MFA
|
|
5
|
+
// endpoints enrol, confirm and disable the factor. This file is the other half:
|
|
6
|
+
// the code field the sign-in form grows after the password, and the screen that
|
|
7
|
+
// puts a secret into an authenticator app and reads it back.
|
|
8
|
+
//
|
|
9
|
+
// It builds its elements rather than shipping markup in a page: the same script
|
|
10
|
+
// is loaded by every theme, and a theme that replaces the sign-in form still
|
|
11
|
+
// gets the second factor. Text is written with textContent, never spliced into
|
|
12
|
+
// markup.
|
|
13
|
+
(function () {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const state = {challenge: null, busy: false};
|
|
17
|
+
|
|
18
|
+
function node(tag, className, text) {
|
|
19
|
+
const element = document.createElement(tag);
|
|
20
|
+
if (className) element.className = className;
|
|
21
|
+
if (text !== undefined) element.textContent = text;
|
|
22
|
+
return element;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function payloadFor(value) {
|
|
26
|
+
const code = String(value || '').trim();
|
|
27
|
+
if (!code) return null;
|
|
28
|
+
// A recovery code is the only thing people type with a dash. The
|
|
29
|
+
// server accepts both fields and checks the value; this only names the
|
|
30
|
+
// one the person meant.
|
|
31
|
+
return /^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/.test(code)
|
|
32
|
+
? {recovery_code: code}
|
|
33
|
+
: {code: code};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function request(path, body) {
|
|
37
|
+
const answer = await fetch(path, {
|
|
38
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
39
|
+
headers: {'Content-Type': 'application/json'},
|
|
40
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
41
|
+
});
|
|
42
|
+
let data = {};
|
|
43
|
+
try {
|
|
44
|
+
data = await answer.json();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
data = {detail: answer.statusText || 'The server did not answer'};
|
|
47
|
+
}
|
|
48
|
+
return {ok: answer.ok, status: answer.status, data: data};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- the code field on the sign-in form ---------------------------------
|
|
52
|
+
|
|
53
|
+
function challengeElements() {
|
|
54
|
+
return {
|
|
55
|
+
form: document.getElementById('loginForm'),
|
|
56
|
+
block: document.getElementById('mfaStep'),
|
|
57
|
+
input: document.getElementById('loginCode'),
|
|
58
|
+
username: document.getElementById('loginUsername'),
|
|
59
|
+
password: document.getElementById('loginPassword')
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function installChallenge() {
|
|
64
|
+
const found = challengeElements();
|
|
65
|
+
if (!found.form || found.block || !found.password) return null;
|
|
66
|
+
|
|
67
|
+
const block = node('div', 'mb-6 hidden');
|
|
68
|
+
block.id = 'mfaStep';
|
|
69
|
+
block.appendChild(node('p', 'text-sm text-gray-600 mb-2',
|
|
70
|
+
'Enter the six-digit code from your authenticator app.'));
|
|
71
|
+
const input = node('input', 'w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500');
|
|
72
|
+
input.id = 'loginCode';
|
|
73
|
+
input.type = 'text';
|
|
74
|
+
input.inputMode = 'numeric';
|
|
75
|
+
input.autocomplete = 'one-time-code';
|
|
76
|
+
input.placeholder = '000000';
|
|
77
|
+
block.appendChild(input);
|
|
78
|
+
block.appendChild(node('p', 'mt-2 text-xs text-gray-500',
|
|
79
|
+
'Lost the device? Enter one of your recovery codes instead.'));
|
|
80
|
+
const back = node('button', 'mt-3 text-sm text-blue-600 hover:underline', 'Back to the password');
|
|
81
|
+
back.type = 'button';
|
|
82
|
+
back.addEventListener('click', function () {
|
|
83
|
+
clearChallenge();
|
|
84
|
+
if (found.username) found.username.focus();
|
|
85
|
+
});
|
|
86
|
+
block.appendChild(back);
|
|
87
|
+
|
|
88
|
+
(found.password.closest('.mb-6') || found.password.parentElement)
|
|
89
|
+
.insertAdjacentElement('afterend', block);
|
|
90
|
+
return block;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setSubmitLabel(text) {
|
|
94
|
+
const {form} = challengeElements();
|
|
95
|
+
const submit = form && form.querySelector('button[type="submit"]');
|
|
96
|
+
if (submit) submit.textContent = text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function showChallenge(token) {
|
|
100
|
+
state.challenge = token || null;
|
|
101
|
+
installChallenge();
|
|
102
|
+
const {block, input, username, password} = challengeElements();
|
|
103
|
+
if (username) (username.closest('.mb-4') || username.parentElement).classList.add('hidden');
|
|
104
|
+
if (password) (password.closest('.mb-6') || password.parentElement).classList.add('hidden');
|
|
105
|
+
if (block) block.classList.remove('hidden');
|
|
106
|
+
setSubmitLabel('Verify');
|
|
107
|
+
if (input) {
|
|
108
|
+
input.value = '';
|
|
109
|
+
input.focus();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function clearChallenge() {
|
|
114
|
+
state.challenge = null;
|
|
115
|
+
const {block, input, username, password} = challengeElements();
|
|
116
|
+
if (username) (username.closest('.mb-4') || username.parentElement).classList.remove('hidden');
|
|
117
|
+
if (password) (password.closest('.mb-6') || password.parentElement).classList.remove('hidden');
|
|
118
|
+
if (block) block.classList.add('hidden');
|
|
119
|
+
if (input) input.value = '';
|
|
120
|
+
setSubmitLabel('Sign in');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function verify(value) {
|
|
124
|
+
const payload = payloadFor(value);
|
|
125
|
+
if (!state.challenge || !payload) {
|
|
126
|
+
return {ok: false, status: 400, data: {detail: 'Enter a code first'}};
|
|
127
|
+
}
|
|
128
|
+
return request('/api/auth/mfa/verify', {mfa_token: state.challenge, ...payload});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- the account screen -------------------------------------------------
|
|
132
|
+
|
|
133
|
+
let modal = null;
|
|
134
|
+
|
|
135
|
+
function buildModal() {
|
|
136
|
+
if (modal) return modal;
|
|
137
|
+
|
|
138
|
+
const overlay = node('div', 'modal-overlay hidden');
|
|
139
|
+
overlay.id = 'mfaModal';
|
|
140
|
+
const content = node('div', 'modal-content');
|
|
141
|
+
const header = node('div', 'modal-header');
|
|
142
|
+
header.appendChild(node('h2', 'modal-title', 'Two-factor authentication'));
|
|
143
|
+
const close = node('button', 'close-btn');
|
|
144
|
+
close.type = 'button';
|
|
145
|
+
close.setAttribute('aria-label', 'Close');
|
|
146
|
+
const icon = node('i');
|
|
147
|
+
icon.setAttribute('data-feather', 'x');
|
|
148
|
+
close.appendChild(icon);
|
|
149
|
+
close.addEventListener('click', closeModal);
|
|
150
|
+
header.appendChild(close);
|
|
151
|
+
content.appendChild(header);
|
|
152
|
+
|
|
153
|
+
const body = node('div', 'modal-body');
|
|
154
|
+
|
|
155
|
+
const status = node('p', 'text-sm text-gray-600 mb-4');
|
|
156
|
+
status.id = 'mfaStatus';
|
|
157
|
+
body.appendChild(status);
|
|
158
|
+
|
|
159
|
+
const enabled = node('div', 'hidden');
|
|
160
|
+
enabled.id = 'mfaEnabled';
|
|
161
|
+
enabled.appendChild(node('p', 'text-sm text-gray-600 mb-3',
|
|
162
|
+
'A code from your authenticator is required when you sign in.'));
|
|
163
|
+
const disableCode = codeInput('mfaDisableCode', 'Code from the app');
|
|
164
|
+
enabled.appendChild(disableCode);
|
|
165
|
+
const disable = node('button', 'mt-3 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700', 'Disable');
|
|
166
|
+
disable.type = 'button';
|
|
167
|
+
disable.addEventListener('click', disableFactor);
|
|
168
|
+
enabled.appendChild(disable);
|
|
169
|
+
body.appendChild(enabled);
|
|
170
|
+
|
|
171
|
+
const disabled = node('div', 'hidden');
|
|
172
|
+
disabled.id = 'mfaDisabled';
|
|
173
|
+
disabled.appendChild(node('p', 'text-sm text-gray-600 mb-3',
|
|
174
|
+
'Add a second step to your sign-in. You will need an authenticator app.'));
|
|
175
|
+
const start = node('button', 'px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700', 'Set up');
|
|
176
|
+
start.type = 'button';
|
|
177
|
+
start.addEventListener('click', startFactor);
|
|
178
|
+
disabled.appendChild(start);
|
|
179
|
+
body.appendChild(disabled);
|
|
180
|
+
|
|
181
|
+
const setup = node('div', 'hidden');
|
|
182
|
+
setup.id = 'mfaSetup';
|
|
183
|
+
setup.appendChild(node('p', 'text-sm text-gray-600 mb-2',
|
|
184
|
+
'Type this secret into your authenticator app, or open the address in it.'));
|
|
185
|
+
const secret = node('code', 'block break-all bg-gray-100 rounded p-3 mb-2');
|
|
186
|
+
secret.id = 'mfaSecret';
|
|
187
|
+
setup.appendChild(secret);
|
|
188
|
+
const uri = node('code', 'block break-all text-xs bg-gray-100 rounded p-3 mb-3');
|
|
189
|
+
uri.id = 'mfaUri';
|
|
190
|
+
setup.appendChild(uri);
|
|
191
|
+
setup.appendChild(codeInput('mfaConfirmCode', 'Code from the app'));
|
|
192
|
+
const confirm = node('button', 'mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700', 'Confirm');
|
|
193
|
+
confirm.type = 'button';
|
|
194
|
+
confirm.addEventListener('click', confirmFactor);
|
|
195
|
+
setup.appendChild(confirm);
|
|
196
|
+
body.appendChild(setup);
|
|
197
|
+
|
|
198
|
+
const recovery = node('div', 'hidden mt-4');
|
|
199
|
+
recovery.id = 'mfaRecovery';
|
|
200
|
+
recovery.appendChild(node('h3', 'font-medium text-gray-800 mb-2', 'Recovery codes'));
|
|
201
|
+
recovery.appendChild(node('p', 'text-sm text-gray-600 mb-2',
|
|
202
|
+
'Keep these somewhere safe. Each works once, and they will not be shown again.'));
|
|
203
|
+
const list = node('ul', 'font-mono text-sm bg-gray-100 rounded p-3 space-y-1');
|
|
204
|
+
list.id = 'mfaRecoveryList';
|
|
205
|
+
recovery.appendChild(list);
|
|
206
|
+
body.appendChild(recovery);
|
|
207
|
+
|
|
208
|
+
const message = node('p', 'mt-4 text-sm');
|
|
209
|
+
message.id = 'mfaMessage';
|
|
210
|
+
body.appendChild(message);
|
|
211
|
+
content.appendChild(body);
|
|
212
|
+
overlay.appendChild(content);
|
|
213
|
+
overlay.addEventListener('click', function (event) {
|
|
214
|
+
if (event.target === overlay) closeModal();
|
|
215
|
+
});
|
|
216
|
+
document.body.appendChild(overlay);
|
|
217
|
+
modal = overlay;
|
|
218
|
+
return overlay;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function codeInput(id, placeholder) {
|
|
222
|
+
const input = node('input', 'w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500');
|
|
223
|
+
input.id = id;
|
|
224
|
+
input.type = 'text';
|
|
225
|
+
input.autocomplete = 'one-time-code';
|
|
226
|
+
input.placeholder = placeholder || '000000';
|
|
227
|
+
return input;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function say(text, tone) {
|
|
231
|
+
const message = document.getElementById('mfaMessage');
|
|
232
|
+
if (!message) return;
|
|
233
|
+
message.textContent = text || '';
|
|
234
|
+
message.className = 'mt-4 text-sm ' + (tone === 'error' ? 'text-red-600' : 'text-green-700');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function show(id, visible) {
|
|
238
|
+
const element = document.getElementById(id);
|
|
239
|
+
if (element) element.classList.toggle('hidden', !visible);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function openModal() {
|
|
243
|
+
buildModal();
|
|
244
|
+
modal.classList.remove('hidden');
|
|
245
|
+
say('', 'ok');
|
|
246
|
+
refreshStatus();
|
|
247
|
+
if (window.feather && window.feather.replace) window.feather.replace();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function closeModal() {
|
|
251
|
+
if (modal) modal.classList.add('hidden');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function refreshStatus() {
|
|
255
|
+
const answer = await request('/api/auth/mfa');
|
|
256
|
+
const status = document.getElementById('mfaStatus');
|
|
257
|
+
const enabled = answer.ok && answer.data.enabled;
|
|
258
|
+
if (status) status.textContent = enabled
|
|
259
|
+
? 'Two-factor authentication is on.'
|
|
260
|
+
: 'Two-factor authentication is off.';
|
|
261
|
+
show('mfaEnabled', enabled);
|
|
262
|
+
show('mfaDisabled', !enabled);
|
|
263
|
+
if (!enabled) show('mfaSetup', false);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function startFactor() {
|
|
267
|
+
const answer = await request('/api/auth/mfa/totp/start', {});
|
|
268
|
+
if (!answer.ok) return say(answer.data.detail || 'Could not start setup', 'error');
|
|
269
|
+
document.getElementById('mfaSecret').textContent = answer.data.secret || '';
|
|
270
|
+
document.getElementById('mfaUri').textContent = answer.data.otpauth_uri || '';
|
|
271
|
+
show('mfaSetup', true);
|
|
272
|
+
show('mfaDisabled', false);
|
|
273
|
+
show('mfaRecovery', false);
|
|
274
|
+
say('Scan the address, then enter the code it shows.', 'ok');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function confirmFactor() {
|
|
278
|
+
const code = document.getElementById('mfaConfirmCode');
|
|
279
|
+
const answer = await request('/api/auth/mfa/totp/confirm', {code: code ? code.value : ''});
|
|
280
|
+
if (!answer.ok) return say(answer.data.detail || 'That code is not right', 'error');
|
|
281
|
+
const list = document.getElementById('mfaRecoveryList');
|
|
282
|
+
list.replaceChildren(...(answer.data.recovery_codes || []).map(value => {
|
|
283
|
+
const item = node('li', '', value);
|
|
284
|
+
return item;
|
|
285
|
+
}));
|
|
286
|
+
show('mfaSetup', false);
|
|
287
|
+
show('mfaRecovery', true);
|
|
288
|
+
show('mfaEnabled', true);
|
|
289
|
+
show('mfaDisabled', false);
|
|
290
|
+
const status = document.getElementById('mfaStatus');
|
|
291
|
+
if (status) status.textContent = 'Two-factor authentication is on.';
|
|
292
|
+
say('Two-factor authentication is on. Save the recovery codes now.', 'ok');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function disableFactor() {
|
|
296
|
+
const code = document.getElementById('mfaDisableCode');
|
|
297
|
+
const answer = await request('/api/auth/mfa/totp/disable', {code: code ? code.value : ''});
|
|
298
|
+
if (!answer.ok) return say(answer.data.detail || 'That code is not right', 'error');
|
|
299
|
+
show('mfaRecovery', false);
|
|
300
|
+
say('Two-factor authentication is off.', 'ok');
|
|
301
|
+
refreshStatus();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function installAccountButton() {
|
|
305
|
+
const logout = document.querySelector('[data-action="shell.logout"]');
|
|
306
|
+
if (!logout || document.getElementById('mfaOpen')) return;
|
|
307
|
+
const button = node('button', 'ml-2 p-2 text-gray-600 rounded-full hover:bg-gray-100 hover:text-blue-600 transition-colors duration-150');
|
|
308
|
+
button.id = 'mfaOpen';
|
|
309
|
+
button.type = 'button';
|
|
310
|
+
button.title = 'Two-factor authentication';
|
|
311
|
+
button.setAttribute('aria-label', 'Two-factor authentication');
|
|
312
|
+
const icon = node('i');
|
|
313
|
+
icon.setAttribute('data-feather', 'shield');
|
|
314
|
+
button.appendChild(icon);
|
|
315
|
+
button.addEventListener('click', openModal);
|
|
316
|
+
logout.parentElement.insertBefore(button, logout);
|
|
317
|
+
if (window.feather && window.feather.replace) window.feather.replace();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function install() {
|
|
321
|
+
installChallenge();
|
|
322
|
+
installAccountButton();
|
|
323
|
+
document.addEventListener('keydown', function (event) {
|
|
324
|
+
if (event.key === 'Escape' && modal && !modal.classList.contains('hidden')) closeModal();
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
window.MfaPanel = {
|
|
329
|
+
install: install,
|
|
330
|
+
payloadFor: payloadFor,
|
|
331
|
+
verify: verify,
|
|
332
|
+
showChallenge: showChallenge,
|
|
333
|
+
clearChallenge: clearChallenge,
|
|
334
|
+
challengeToken: () => state.challenge,
|
|
335
|
+
open: openModal,
|
|
336
|
+
close: closeModal
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// The shell calls install() once the page is ready: both pages that load
|
|
340
|
+
// this file also load main_new.js, and a second listener would install the
|
|
341
|
+
// second factor twice (keepup-176).
|
|
342
|
+
})();
|
|
@@ -25,6 +25,7 @@ keepup_http/static/js/aos.js
|
|
|
25
25
|
keepup_http/static/js/feather-icons.js
|
|
26
26
|
keepup_http/static/js/layout_bootstrap.js
|
|
27
27
|
keepup_http/static/js/main_new.js
|
|
28
|
+
keepup_http/static/js/mfa_panel.js
|
|
28
29
|
keepup_http/static/js/tailwind.js
|
|
29
30
|
keepup_http/static/modules/css/background_tasks.css
|
|
30
31
|
keepup_http/static/modules/css/cluster.css
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "keepup-http"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "The way into the application over HTTP: the application, its routes and its middleware."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|