Coverage for src/lexigram/admin/auth/guards.py: 0%
219 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Authentication and authorization guards for lexigram-admin.
3Provides middleware and guard utilities for protecting routes.
4Integrates with lexigram-auth session management.
5"""
7from __future__ import annotations
9import base64
10from dataclasses import dataclass
11from functools import wraps
12import hmac
13from typing import TYPE_CHECKING, Any
15from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
16from starlette.requests import Request
17from starlette.responses import RedirectResponse, Response
19from lexigram.admin.auth.permissions import PermissionSet, get_user_permissions
20from lexigram.admin.exceptions import ErrorCode, PermissionDeniedError
21from lexigram.contracts import (
22 AuthorizerProtocol,
23 AuthProviderProtocol,
24)
25from lexigram.contracts.web import RequestProtocol, ResponseProtocol
26from lexigram.di.decorators import inject
27from lexigram.logging import get_logger
28from lexigram.result import Err, Ok, Result
29from lexigram.serialization.backends import json as json_backend
31if TYPE_CHECKING:
32 from collections.abc import Awaitable, Callable
34logger = get_logger(__name__)
37@dataclass
38class GuardConfig:
39 """Configuration for authentication guards."""
41 login_url: str = "/admin/login"
42 logout_url: str = "/admin/logout"
43 exempt_paths: tuple[str, ...] = (
44 "/admin/login",
45 "/admin/static",
46 "/admin/health",
47 # Standalone pre-session flows (own CSRF + guest handling):
48 "/admin/setup",
49 "/admin/verify-email",
50 "/admin/password-reset",
51 )
52 # Whether to accept Authorization: Bearer <token> for admin APIs.
53 # Default: False to enforce strict cookie-based admin sessions.
54 allow_bearer_tokens: bool = False
55 htmx_redirect_header: str = "HX-Redirect"
58@inject
59class AuthGuardMiddleware(BaseHTTPMiddleware):
60 """Middleware that enforces authentication on admin routes.
62 Checks for valid session and loads user into request.state.
63 Redirects unauthenticated requests to login page.
65 For HTMX requests, returns HX-Redirect header instead of 302.
66 """
68 def __init__(
69 self,
70 app: Any,
71 auth_provider: AuthProviderProtocol | None = None,
72 config: GuardConfig | None = None,
73 authorizer: AuthorizerProtocol | None = None,
74 ) -> None:
75 super().__init__(app)
76 self.auth_provider = auth_provider
77 self.config = config or GuardConfig()
78 self.authorizer = authorizer
80 async def dispatch( # type: ignore[override]
81 self,
82 request: RequestProtocol, # type: ignore[override]
83 call_next: RequestResponseEndpoint,
84 ) -> ResponseProtocol:
85 # Skip auth for exempt paths
86 if self._is_exempt(request.url.path):
87 return await call_next(request) # type: ignore[return-value, arg-type]
89 # Check if user is already loaded by AdminAuthMiddleware
90 user = getattr(request.state, "user", None)
92 # If not, try to load it (fallback/standalone usage)
93 if user is None:
94 user = await self._get_authenticated_user(request)
96 if not self._is_authenticated(user):
97 # If request has Authorization: Bearer, return 401 instead of redirect
98 auth_header = request.headers.get("Authorization", "")
99 if auth_header.startswith("Bearer "):
100 from starlette.responses import JSONResponse
102 return JSONResponse( # type: ignore[return-value]
103 {"authenticated": False, "detail": "Invalid or missing token"},
104 status_code=401,
105 )
106 return self._redirect_to_login(request)
108 # Ensure user and permissions are in state
109 request.state.user = user
110 if not hasattr(request.state, "permissions"):
111 # Use injected authorizer if available
112 if self.authorizer:
113 try:
114 request.state.permissions = get_user_permissions(
115 user,
116 self.authorizer,
117 )
118 except (
119 ConnectionError,
120 RuntimeError,
121 ValueError,
122 TypeError,
123 AttributeError,
124 ):
125 # Authorization failed, skip permissions
126 logger.debug(
127 "Could not compute user permissions",
128 exc_info=True,
129 )
130 request.state.permissions = None
131 else:
132 # No authorizer available, skip permissions
133 request.state.permissions = None
135 return await call_next(request) # type: ignore[return-value, arg-type]
137 def _is_authenticated(self, user: Any) -> bool:
138 """Check if user is traditionally authenticated (not guest)."""
139 if user is None:
140 return False
142 # Check common identity fields
143 user_id = getattr(user, "user_id", None) or getattr(user, "id", None)
144 if not user_id or user_id == "guest":
145 return False
147 # Check activity
148 return getattr(user, "is_active", True)
150 def _is_exempt(self, path: str) -> bool:
151 """Check if path is exempt from auth."""
152 return any(path.startswith(exempt) for exempt in self.config.exempt_paths)
154 async def _get_authenticated_user(self, request: RequestProtocol) -> Any | None:
155 """Get authenticated user from signed request session."""
156 if "session" in request.scope: # type: ignore[attr-defined]
157 user_id = request.session.get("admin_user_id") # type: ignore[attr-defined]
158 if user_id:
159 try:
160 if hasattr(self.auth_provider, "user_store"):
161 return await self.auth_provider.user_store.get_user_by_id( # type: ignore[union-attr]
162 user_id,
163 )
164 return None
165 except (
166 ConnectionError,
167 RuntimeError,
168 ValueError,
169 TypeError,
170 AttributeError,
171 ):
172 logger.debug("Session user resolution failed", exc_info=True)
174 # Try Authorization header (for API calls)
175 # By default, admin routes do NOT accept bearer tokens to keep a strict
176 # separation between admin sessions (cookie-based) and application
177 # JWTs. This can be enabled explicitly via GuardConfig.allow_bearer_tokens.
178 if self.config.allow_bearer_tokens:
179 auth_header = request.headers.get("Authorization", "")
180 if auth_header.startswith("Bearer "):
181 token = auth_header[7:]
182 try:
183 # lexigram-auth uses authenticate_user or verify_token but AuthGuard usually checks tokens too
184 if hasattr(self.auth_provider, "verify_token"):
185 token_result = self.auth_provider.verify_token(token) # type: ignore[union-attr]
186 if hasattr(token_result, "__await__"):
187 token_result = await token_result
188 # Handle Result[VerifiedToken, ...] (new API)
189 if hasattr(token_result, "is_ok"):
190 if token_result.is_ok():
191 verified = token_result.unwrap() # type: ignore[union-attr]
192 return (
193 await self.auth_provider.user_store.get_user_by_id( # type: ignore[union-attr]
194 verified.user_id,
195 )
196 )
197 # Fallback: legacy dict payload (older providers)
198 elif token_result and "sub" in token_result: # type: ignore[operator]
199 return await self.auth_provider.user_store.get_user_by_id( # type: ignore[union-attr]
200 token_result["sub"], # type: ignore[index]
201 )
202 elif self.auth_provider is not None and hasattr(
203 self.auth_provider, "validate_token"
204 ):
205 payload = self.auth_provider.validate_token(token)
206 if hasattr(payload, "__await__"):
207 payload = await payload
208 if (
209 payload
210 and "sub" in payload
211 and self.auth_provider is not None
212 ):
213 user_store = getattr(self.auth_provider, "user_store", None)
214 if user_store is not None:
215 return await user_store.get_user_by_id(
216 payload["sub"],
217 )
218 return payload
219 except (
220 ConnectionError,
221 RuntimeError,
222 ValueError,
223 TypeError,
224 AttributeError,
225 ) as e:
226 # Provide richer diagnostic logging so we can see token header issues (e.g., unexpected 'alg')
227 try:
228 header = None
229 try:
230 segment = token.split(".", 1)[0]
231 padded = segment + "=" * (-len(segment) % 4)
232 header = json_backend.loads(
233 base64.urlsafe_b64decode(padded)
234 )
235 except (ValueError, TypeError):
236 header = None
237 logger.warning(
238 "Token validation failed: %s - header=%s",
239 str(e),
240 header,
241 exc_info=True,
242 )
243 except (OSError, ValueError, TypeError) as e:
244 logger.warning("Token validation failed", exc_info=True)
245 return None
247 def _redirect_to_login(self, request: RequestProtocol) -> ResponseProtocol:
248 """Create redirect response to login page."""
249 # Build redirect URL with return path
250 return_to = request.url.path
251 if request.url.query:
252 return_to = f"{return_to}?{request.url.query}"
254 login_url = f"{self.config.login_url}?next={return_to}"
256 # For HTMX requests, use HX-Redirect header
257 if request.headers.get("HX-Request"):
258 response = Response(status_code=200)
259 response.headers[self.config.htmx_redirect_header] = login_url
260 return response # type: ignore[return-value]
262 return RedirectResponse(url=login_url, status_code=302) # type: ignore[return-value]
265class PermissionGuard:
266 """GuardProtocol that checks permissions on specific routes.
268 Usage with @use_guards decorator:
269 class UserController(Controller):
270 @get("/admin/users")
271 @use_guards(PermissionGuard("users.list"))
272 async def list_users(self, request: Request) -> ...: ...
274 Usage as standalone callable:
275 guard = PermissionGuard("users.delete")
276 result = await guard(request)
277 if result.is_err():
278 raise result.unwrap_err()
279 """
281 def __init__(
282 self,
283 *permissions: str,
284 require_all: bool = False,
285 message: str | None = None,
286 authorizer: AuthorizerProtocol | None = None,
287 ):
288 self.permissions = permissions
289 self.require_all = require_all
290 self.message = message
291 self._authorizer = authorizer
293 async def __call__(
294 self, request: RequestProtocol
295 ) -> Result[None, PermissionDeniedError]:
296 """Check permissions. Returns Ok(None) on success, Err(PermissionDeniedError) on denial."""
297 user = getattr(request.state, "user", None)
299 if user is None:
300 return Err(PermissionDeniedError(message="Authentication required"))
302 user_perms: PermissionSet = getattr(request.state, "permissions", None) # type: ignore[assignment]
303 if user_perms is None:
304 # Use injected authorizer or fallback to request.state.permissions if already set
305 authorizer = self._authorizer
306 if authorizer is None:
307 # Permissions should have been set by middleware
308 return Err(
309 PermissionDeniedError(
310 message="Authorization service unavailable",
311 )
312 )
313 user_perms = get_user_permissions(user, authorizer)
315 if self.require_all:
316 if not user_perms.has_all(*self.permissions):
317 missing = list(
318 filter(lambda p: not user_perms.has(p), self.permissions),
319 )
320 return Err(
321 PermissionDeniedError(
322 message=self.message
323 or f"Missing permissions: {', '.join(missing)}",
324 required_permission=str(self.permissions),
325 )
326 )
327 elif not user_perms.has_any(*self.permissions):
328 return Err(
329 PermissionDeniedError(
330 message=self.message
331 or f"Requires permission: {' or '.join(self.permissions)}",
332 required_permission=str(self.permissions),
333 )
334 )
335 return Ok(None)
337 def __matmul__(self, func: Callable) -> Callable:
338 """Allow usage as @guard decorator via @ operator."""
339 return self.wrap(func)
341 def wrap(
342 self,
343 func: Callable[..., Awaitable[Any]],
344 ) -> Callable[..., Awaitable[Any]]:
345 """Wrap a function with permission check.
347 Raises PermissionDeniedError if the guard check fails.
348 """
350 @wraps(func)
351 async def wrapper(request: Request, *args, **kwargs) -> Any:
352 result = await self(request) # type: ignore[arg-type]
353 if result.is_err():
354 raise result.unwrap_err()
355 return await func(request, *args, **kwargs)
357 return wrapper
360class RoleGuard:
361 """GuardProtocol that checks roles on specific routes."""
363 def __init__(
364 self,
365 *roles: str,
366 require_all: bool = False,
367 message: str | None = None,
368 ):
369 self.roles = roles
370 self.require_all = require_all
371 self.message = message
373 async def __call__(
374 self, request: RequestProtocol
375 ) -> Result[None, PermissionDeniedError]:
376 """Check roles. Returns Ok(None) on success, Err(PermissionDeniedError) on denial."""
377 user = getattr(request.state, "user", None)
379 if user is None:
380 return Err(PermissionDeniedError(message="Authentication required"))
382 user_roles = set(getattr(user, "roles", []) or [])
384 if self.require_all:
385 if not all(r in user_roles for r in self.roles):
386 missing = list(filter(lambda r: r not in user_roles, self.roles))
387 return Err(
388 PermissionDeniedError(
389 message=self.message
390 or f"Requires all roles: {', '.join(missing)}",
391 )
392 )
393 elif not user_roles.intersection(self.roles):
394 return Err(
395 PermissionDeniedError(
396 message=self.message or f"Requires role: {' or '.join(self.roles)}",
397 )
398 )
399 return Ok(None)
402def require_auth(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
403 """Simple decorator to require authentication.
405 Just checks that user exists in request.state.
406 """
408 @wraps(func)
409 async def wrapper(request: Request, *args, **kwargs) -> Any:
410 user = getattr(request.state, "user", None)
411 if user is None:
412 raise PermissionDeniedError(message="Authentication required")
413 return await func(request, *args, **kwargs)
415 return wrapper
418def csrf_protect(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
419 """Decorator to require valid CSRF token for state-changing operations.
421 Checks for CSRF token in:
422 1. X-CSRF-Token header
423 2. csrf_token form field
425 HTMX requests automatically include the token via hx-headers.
426 """
428 @wraps(func)
429 async def wrapper(request: Request, *args, **kwargs) -> Any:
430 # Skip for safe methods
431 if request.method in ("GET", "HEAD", "OPTIONS"):
432 return await func(request, *args, **kwargs)
434 # Get expected token from session
435 session = getattr(request.state, "session", None)
436 expected_token = getattr(session, "csrf_token", None) if session else None
438 if not expected_token:
439 # Fail closed: a session without CSRF state cannot authorize
440 # state-changing requests.
441 logger.warning("csrf_rejected_no_session_token")
442 raise PermissionDeniedError(
443 message="Missing CSRF session state",
444 code=ErrorCode.AUTH_INVALID_TOKEN,
445 )
447 # Get submitted token
448 submitted_token = request.headers.get("X-CSRF-Token")
450 if not submitted_token:
451 # Try form data
452 try:
453 form = request.scope.get("admin_form_data")
454 if form is None:
455 form = await request.form()
456 submitted_token = form.get("csrf_token") # type: ignore[assignment]
457 except (
458 ConnectionError,
459 RuntimeError,
460 ValueError,
461 TypeError,
462 AttributeError,
463 ):
464 pass
466 if not submitted_token or not hmac.compare_digest(
467 submitted_token, expected_token
468 ):
469 raise PermissionDeniedError(
470 message="Invalid or missing CSRF token",
471 code=ErrorCode.AUTH_INVALID_TOKEN,
472 )
474 return await func(request, *args, **kwargs)
476 return wrapper
479class CompositeGuard:
480 """Combine multiple guards with AND/OR logic.
482 Usage:
483 guard = CompositeGuard(
484 PermissionGuard("users.list"),
485 RoleGuard("admin"),
486 logic="or" # User needs permission OR role
487 )
488 """
490 def __init__(
491 self,
492 *guards: PermissionGuard | RoleGuard,
493 logic: str = "and", # "and" or "or"
494 ):
495 self.guards = guards
496 self.logic = logic
498 async def __call__(
499 self, request: RequestProtocol
500 ) -> Result[None, PermissionDeniedError]:
501 """Execute guards based on logic.
503 Returns Ok(None) when guard(s) pass. Returns Err(PermissionDeniedError)
504 on denial. For "and" logic, the first failure short-circuits. For "or"
505 logic, the last failure is returned if all guards deny.
506 """
507 if self.logic == "and":
508 # All guards must pass — short-circuit on first failure
509 for guard in self.guards:
510 result = await guard(request)
511 if result.is_err():
512 return result
513 return Ok(None)
514 # At least one guard must pass
515 last_failure: Result[None, PermissionDeniedError] = Err(
516 PermissionDeniedError(message="All guards denied access")
517 )
518 for guard in self.guards:
519 result = await guard(request)
520 if result.is_ok():
521 return Ok(None)
522 last_failure = result
523 return last_failure