Coverage for src/lexigram/admin/middleware/auth_guard.py: 87%
45 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +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 "/login/2fa",
31 "/login/2fa/",
32 "/verify-email",
33 "/verify-email/",
34 "/password-reset",
35 "/password-reset/",
36 "/register",
37 "/register/",
38 }
39)
41_BYPASS_PREFIXES: tuple[str, ...] = (
42 "/static/",
43 "/admin/static/",
44)
46# Token-bearing sub-paths that must remain reachable without a session
47# (e.g. the email verification and password-reset links emailed to admins).
48_BYPASS_TOKEN_PREFIXES: tuple[str, ...] = (
49 "/admin/verify-email/",
50 "/admin/password-reset/",
51)
53# Exact public routes derived from _BYPASS_SUFFIXES — full-path membership
54# only, never suffix matching, so resource names like "login", "register",
55# "health", or "setup" cannot shadow protected admin routes.
56_BYPASS_ROUTES: frozenset[str] = frozenset(
57 f"/admin{s.rstrip('/')}" for s in _BYPASS_SUFFIXES
58)
61class AdminAuthGuardMiddleware:
62 """Pure ASGI middleware that enforces session-based authentication.
64 Any request whose path is not in the bypass list must carry a
65 Starlette session with ``admin_user_id`` set. If the session is
66 missing or empty the client is redirected to ``/admin/login``.
68 This middleware is intentionally lightweight — it does not touch the
69 database. It relies solely on the signed session cookie that
70 ``AuthController`` writes on successful login.
71 """
73 def __init__(self, app: ASGIApp) -> None:
74 """Initialise the middleware.
76 Args:
77 app: The next ASGI application in the stack.
78 """
79 self._app = app
81 # ------------------------------------------------------------------
82 # ASGI callable
83 # ------------------------------------------------------------------
85 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
86 """Process an incoming HTTP request.
88 Args:
89 scope: ASGI connection scope.
90 receive: ASGI receive callable.
91 send: ASGI send callable.
92 """
93 if scope["type"] != "http":
94 await self._app(scope, receive, send)
95 return
97 path: str = scope.get("path", "")
99 if self._is_bypass_path(path):
100 await self._app(scope, receive, send)
101 return
103 # Inspect the Starlette session (populated by SessionMiddleware).
104 if "session" in scope:
105 user_id = scope["session"].get("admin_user_id")
106 if user_id:
107 await self._app(scope, receive, send)
108 return
110 # No valid session — redirect to login preserving the original URL.
111 # HTMX requests get HX-Redirect so the browser performs a full page
112 # navigation; a plain 307 would make htmx swap the login page into
113 # the current component (e.g. a widget container).
114 logger.debug("auth_guard.unauthenticated path=%s", path)
115 next_url = quote(path, safe="/")
116 login_url = f"/admin/login?next={next_url}"
117 if self._is_htmx(scope):
118 response = Response(status_code=200)
119 response.headers["HX-Redirect"] = login_url
120 else:
121 response = RedirectResponse(url=login_url, status_code=307)
122 await response(scope, receive, send)
124 # ------------------------------------------------------------------
125 # Helpers
126 # ------------------------------------------------------------------
128 @staticmethod
129 def _is_htmx(scope: Scope) -> bool:
130 """Return True when the request carries the htmx HX-Request header.
132 Args:
133 scope: ASGI connection scope.
135 Returns:
136 True for htmx fragment requests.
137 """
138 headers = dict(scope.get("headers") or ())
139 return headers.get(b"hx-request") == b"true"
141 def _is_bypass_path(self, path: str) -> bool:
142 """Return True if the path should bypass auth enforcement.
144 Args:
145 path: The request path.
147 Returns:
148 True when the path maps to a public endpoint or static asset.
149 """
150 stripped = path.rstrip("/") or path
151 if stripped in _BYPASS_ROUTES:
152 return True
154 if any(path.startswith(prefix) for prefix in _BYPASS_TOKEN_PREFIXES):
155 return True
157 return any(path.startswith(prefix) for prefix in _BYPASS_PREFIXES)