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

1"""Session-based auth guard middleware for the Lexigram Admin panel. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from urllib.parse import quote 

11 

12from starlette.responses import RedirectResponse, Response 

13from starlette.types import ASGIApp, Receive, Scope, Send 

14 

15from lexigram.logging import get_logger 

16 

17logger = get_logger(__name__) 

18 

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) 

32 

33_BYPASS_PREFIXES: tuple[str, ...] = ( 

34 "/static/", 

35 "/admin/static/", 

36) 

37 

38 

39class AdminAuthGuardMiddleware: 

40 """Pure ASGI middleware that enforces session-based authentication. 

41 

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``. 

45 

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 """ 

50 

51 def __init__(self, app: ASGIApp) -> None: 

52 """Initialise the middleware. 

53 

54 Args: 

55 app: The next ASGI application in the stack. 

56 """ 

57 self._app = app 

58 

59 # ------------------------------------------------------------------ 

60 # ASGI callable 

61 # ------------------------------------------------------------------ 

62 

63 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 

64 """Process an incoming HTTP request. 

65 

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 

74 

75 path: str = scope.get("path", "") 

76 

77 if self._is_bypass_path(path): 

78 await self._app(scope, receive, send) 

79 return 

80 

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 

87 

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) 

101 

102 # ------------------------------------------------------------------ 

103 # Helpers 

104 # ------------------------------------------------------------------ 

105 

106 @staticmethod 

107 def _is_htmx(scope: Scope) -> bool: 

108 """Return True when the request carries the htmx HX-Request header. 

109 

110 Args: 

111 scope: ASGI connection scope. 

112 

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" 

118 

119 def _is_bypass_path(self, path: str) -> bool: 

120 """Return True if the path should bypass auth enforcement. 

121 

122 Args: 

123 path: The request path. 

124 

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 

131 

132 return any(path.startswith(prefix) for prefix in _BYPASS_PREFIXES)