Coverage for src / lexigram / admin / middleware / auth_guard.py: 34%
41 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Session-based auth guard middleware for the Lexigram Admin panel.
3Redirects unauthenticated requests to the login page. Bypass paths
4(login, setup, static assets, health) are always passed through so
5authentication pages remain accessible before a session exists.
6"""
8from __future__ import annotations
10from urllib.parse import quote
12from starlette.responses import RedirectResponse, Response
13from starlette.types import ASGIApp, Receive, Scope, Send
15from lexigram.logging import get_logger
17logger = get_logger(__name__)
19# Paths (or path suffixes) that are always accessible without a session.
20_BYPASS_SUFFIXES: frozenset[str] = frozenset(
21 {
22 "/login",
23 "/login/",
24 "/logout",
25 "/logout/",
26 "/setup",
27 "/setup/",
28 "/health",
29 "/health/",
30 }
31)
33_BYPASS_PREFIXES: tuple[str, ...] = (
34 "/static/",
35 "/admin/static/",
36)
39class AdminAuthGuardMiddleware:
40 """Pure ASGI middleware that enforces session-based authentication.
42 Any request whose path is not in the bypass list must carry a
43 Starlette session with ``admin_user_id`` set. If the session is
44 missing or empty the client is redirected to ``/admin/login``.
46 This middleware is intentionally lightweight — it does not touch the
47 database. It relies solely on the signed session cookie that
48 ``AuthController`` writes on successful login.
49 """
51 def __init__(self, app: ASGIApp) -> None:
52 """Initialise the middleware.
54 Args:
55 app: The next ASGI application in the stack.
56 """
57 self._app = app
59 # ------------------------------------------------------------------
60 # ASGI callable
61 # ------------------------------------------------------------------
63 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
64 """Process an incoming HTTP request.
66 Args:
67 scope: ASGI connection scope.
68 receive: ASGI receive callable.
69 send: ASGI send callable.
70 """
71 if scope["type"] != "http":
72 await self._app(scope, receive, send)
73 return
75 path: str = scope.get("path", "")
77 if self._is_bypass_path(path):
78 await self._app(scope, receive, send)
79 return
81 # Inspect the Starlette session (populated by SessionMiddleware).
82 if "session" in scope:
83 user_id = scope["session"].get("admin_user_id")
84 if user_id:
85 await self._app(scope, receive, send)
86 return
88 # No valid session — redirect to login preserving the original URL.
89 # HTMX requests get HX-Redirect so the browser performs a full page
90 # navigation; a plain 307 would make htmx swap the login page into
91 # the current component (e.g. a widget container).
92 logger.debug("auth_guard.unauthenticated path=%s", path)
93 next_url = quote(path, safe="/")
94 login_url = f"/admin/login?next={next_url}"
95 if self._is_htmx(scope):
96 response = Response(status_code=200)
97 response.headers["HX-Redirect"] = login_url
98 else:
99 response = RedirectResponse(url=login_url, status_code=307)
100 await response(scope, receive, send)
102 # ------------------------------------------------------------------
103 # Helpers
104 # ------------------------------------------------------------------
106 @staticmethod
107 def _is_htmx(scope: Scope) -> bool:
108 """Return True when the request carries the htmx HX-Request header.
110 Args:
111 scope: ASGI connection scope.
113 Returns:
114 True for htmx fragment requests.
115 """
116 headers = dict(scope.get("headers") or ())
117 return headers.get(b"hx-request") == b"true"
119 def _is_bypass_path(self, path: str) -> bool:
120 """Return True if the path should bypass auth enforcement.
122 Args:
123 path: The request path.
125 Returns:
126 True when the path maps to a public endpoint or static asset.
127 """
128 for suffix in _BYPASS_SUFFIXES:
129 if path == suffix or path.endswith(suffix):
130 return True
132 return any(path.startswith(prefix) for prefix in _BYPASS_PREFIXES)