Coverage for src/lexigram/admin/middleware/auth.py: 88%
102 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
1"""Authentication middleware for Lexigram Admin.
3This middleware integrates with Lexigram's DI container to provide
4request-scoped user authentication and authorization.
5"""
7from __future__ import annotations
9from datetime import datetime
10from typing import TYPE_CHECKING
12from starlette.requests import Request
13from starlette.requests import Request as StarletteRequest
14from starlette.types import ASGIApp, Receive, Scope, Send
16from lexigram.admin.auth.models import GUEST_USER
17from lexigram.admin.auth.store.base import AbstractAdminUserStore
18from lexigram.contracts import AuthenticatedUserProtocol
19from lexigram.di.decorators import inject
20from lexigram.logging import get_logger
21from lexigram.primitives import clock
23if TYPE_CHECKING:
24 from lexigram.admin.auth.protocols import AdminSessionServiceProtocol
26logger = get_logger(__name__)
29@inject
30class AdminAuthMiddleware:
31 """Pure ASGI middleware for admin authentication and authorization.
33 10x faster than BaseHTTPMiddleware - no task creation overhead.
35 This middleware:
36 1. Checks if user is authenticated (via session/JWT)
37 2. Loads AdminUser from session or creates guest user
38 3. Injects AdminUser into DI request scope
39 4. Optionally redirects unauthenticated users to login
41 The injected AdminUser is then available to all controllers
42 via dependency injection.
43 """
45 def __init__(
46 self,
47 app: ASGIApp,
48 user_store: AbstractAdminUserStore | None = None,
49 session_service: AdminSessionServiceProtocol | None = None,
50 require_auth: bool = False,
51 excluded_paths: list[str] | None = None,
52 ):
53 """Initialize auth middleware.
55 Args:
56 app: ASGI application
57 user_store: AbstractAdminUserStore for loading users
58 session_service: AdminSessionServiceProtocol for TTL enforcement
59 require_auth: If True, redirect unauthenticated users
60 excluded_paths: Paths that don't require authentication
61 """
62 self.app = app
63 self.user_store = user_store
64 self._session_service = session_service
65 self.require_auth = require_auth
66 self.excluded_paths = excluded_paths or []
68 def _is_path_excluded(self, path: str) -> bool:
69 """Check if path is excluded from auth requirements.
71 Args:
72 path: Request path to check
74 Returns:
75 True if path is excluded, False otherwise
76 """
77 for pattern in self.excluded_paths:
78 if pattern.endswith("*"):
79 # Wildcard matching
80 prefix = pattern[:-1]
81 if path.startswith(prefix):
82 return True
83 elif path == pattern:
84 return True
85 return False
87 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
88 """Pure ASGI middleware implementation."""
90 if scope["type"] != "http":
91 # Pass through non-HTTP requests
92 await self.app(scope, receive, send)
93 return
95 path = scope.get("path", "")
97 # Check if path is excluded
98 if self._is_path_excluded(path):
99 await self.app(scope, receive, send)
100 return
102 # Build request for store access
103 request = StarletteRequest(scope, receive)
105 # Resolve tenant
106 if hasattr(request.state, "tenant_id"):
107 pass
109 # Load user from session
110 user = await self._load_user(request)
112 # Check if authentication is required
113 if self.require_auth and (
114 user is None or getattr(user, "user_id", "guest") == "guest"
115 ):
116 from starlette.exceptions import HTTPException
118 raise HTTPException(status_code=401, detail="Unauthorized")
120 # Store user in scope for access by other middleware/controllers
121 scope["user"] = user
123 # Also store in scope state (becomes request.state)
124 if "state" not in scope:
125 scope["state"] = {}
126 scope["state"]["user"] = user
128 # Continue with request
129 try:
130 await self.app(scope, receive, send)
131 finally:
132 pass
134 async def _load_user(self, request: Request) -> AuthenticatedUserProtocol | None:
135 """Load user from request session or return guest user.
137 Validates via AdminSessionService (which enforces idle + absolute TTL)
138 when both a session_id and session_service are available. Falls back
139 to direct user_store lookup for backward compatibility with sessions
140 created before the session_id was stored.
142 Args:
143 request: The incoming request
145 Returns:
146 AdminUser instance (or GUEST_USER if not authenticated)
147 """
148 # Canonical admin auth path: signed Starlette session cookie managed by
149 # SessionMiddleware + AuthController.
150 try:
151 if "session" not in request.scope:
152 return GUEST_USER
154 # ── Session-service path (enforces TTL) ────────────────────
155 session_id = request.session.get("session_id")
156 if self._session_service is not None:
157 if not session_id:
158 # Service-bound deployment without a service-managed
159 # session: never consult the legacy fallback.
160 return GUEST_USER
161 session_data = await self._session_service.get_session(session_id)
162 if session_data is None:
163 # Session expired or revoked — clear cookie
164 request.session.clear()
165 logger.debug("session.expired_or_revoked", session_id=session_id)
166 return GUEST_USER
168 admin_id = session_data.get("admin_id")
169 if admin_id is None:
170 request.session.clear()
171 return GUEST_USER
173 user = (
174 await self.user_store.get_by_id(admin_id)
175 if self.user_store
176 else None
177 )
178 if user is None or not user.is_active:
179 await self._session_service.revoke_session(session_id)
180 request.session.clear()
181 return GUEST_USER
183 logger.debug(
184 "Successfully loaded user %s from session (TTL-validated)",
185 user.user_id,
186 )
187 return user
189 # ── Legacy fallback (no session_service / no session_id) ───
190 user_id = request.session.get("admin_user_id")
191 if user_id:
192 expires_at_raw = request.session.get("admin_session_expires_at")
193 if expires_at_raw is None:
194 request.session.clear()
195 return GUEST_USER
196 try:
197 expires_at = datetime.fromisoformat(expires_at_raw)
198 except ValueError:
199 request.session.clear()
200 return GUEST_USER
201 if expires_at.tzinfo is None or clock.now() >= expires_at:
202 request.session.clear()
203 return GUEST_USER
204 user_store = self.user_store
205 user = await user_store.get_by_id(user_id) if user_store else None
206 if user and user.is_active:
207 logger.debug(
208 "Successfully loaded user %s from request.session",
209 user.user_id,
210 )
211 return user
212 except (RuntimeError, ValueError, OSError, AssertionError) as e:
213 logger.debug("Failed to load user from request.session: %s", e)
215 return GUEST_USER
218def current_user(request: Request | None = None) -> AuthenticatedUserProtocol | None:
219 """Get the current authenticated user from request scope.
221 This is a helper function that can be used in controllers
222 when DI injection is not available.
224 Args:
225 request: Request object (optional, will try to get from DI scope)
227 Returns:
228 The current User or GUEST_USER
230 Example:
231 ```python
232 @get("/admin/profile")
233 async def profile(request: Request):
234 user = current_user(request)
235 return {"username": user.name}
236 ```
237 """
238 # Try to get from request state first
239 if request and hasattr(request.state, "user"):
240 return request.state.user
242 return GUEST_USER