Coverage for apps / core / auth.py: 84%
57 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
1"""Session-based authentication for Django Ninja endpoints."""
3import logging
4from typing import Any, Optional
6from django.conf import settings
7from django.http import HttpRequest
8from ninja.errors import HttpError
9from ninja.security import APIKeyCookie
11from apps.profiles.models import Profile
13__all__ = ["SessionAuth", "HomeOnlyAuth", "HomeOnlyAnonAuth"]
15security_logger = logging.getLogger("security")
18class SessionAuth(APIKeyCookie):
19 """Mode-aware authenticator.
21 Home mode: checks session["profile_id"] → Profile (no user accounts).
22 Passkey mode: checks request.user.is_authenticated → request.user.profile.
23 """
25 param_name: str = settings.SESSION_COOKIE_NAME
27 def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]:
28 if settings.AUTH_MODE == "passkey":
29 return self._authenticate_passkey(request)
30 return self._authenticate_home(request)
32 def _authenticate_home(self, request: HttpRequest) -> Optional[Profile]:
33 profile_id = request.session.get("profile_id")
34 if not profile_id:
35 security_logger.warning(
36 "Auth failure: no profile_id in session for %s from %s",
37 request.path,
38 request.META.get("REMOTE_ADDR"),
39 )
40 return None
41 try:
42 return Profile.objects.get(id=profile_id)
43 except Profile.DoesNotExist:
44 security_logger.warning(
45 "Auth failure: invalid profile_id %s for %s from %s",
46 profile_id,
47 request.path,
48 request.META.get("REMOTE_ADDR"),
49 )
50 return None
52 def _authenticate_passkey(self, request: HttpRequest) -> Optional[Profile]:
53 user = getattr(request, "user", None)
54 if not user or not getattr(user, "is_authenticated", False):
55 # Fallback: check session profile_id (set during passkey login)
56 profile_id = request.session.get("profile_id")
57 if profile_id:
58 try:
59 profile = Profile.objects.select_related("user").get(id=profile_id)
60 if profile.user and profile.user.is_active:
61 request.user = profile.user
62 return profile
63 except Profile.DoesNotExist:
64 pass
65 security_logger.warning(
66 "Auth failure: unauthenticated request to %s from %s",
67 request.path,
68 request.META.get("REMOTE_ADDR"),
69 )
70 return None
71 if not user.is_active:
72 return None
73 try:
74 return user.profile
75 except Profile.DoesNotExist:
76 security_logger.warning(
77 "Auth failure: no profile for user %s at %s",
78 user.pk,
79 request.path,
80 )
81 return None
84class HomeOnlyAuth(SessionAuth):
85 """SessionAuth gated by AUTH_MODE=home.
87 Raises 404 before any cookie extraction or session lookup when
88 AUTH_MODE != "home". Probes from passkey deployments are indistinguishable
89 from hits on a never-existed path: same status, same body, no security-log
90 auth-failure line.
92 Applied to every endpoint whose functional scope is home-mode only
93 (the admin endpoints + the authenticated profile endpoints).
94 """
96 def __call__(self, request: HttpRequest) -> Any:
97 if settings.AUTH_MODE != "home":
98 raise HttpError(404, "Not found")
99 return super().__call__(request)
102class HomeOnlyAnonAuth(HomeOnlyAuth):
103 """Pre-session variant of HomeOnlyAuth for profile-selector endpoints.
105 Used on the three endpoints that MUST be reachable before any session
106 exists (list/create/select profile). Home mode: allows anonymous access
107 but still enforces CSRF on unsafe methods via APIKeyCookie._get_key.
108 Non-home modes: raises 404, same as HomeOnlyAuth.
110 Inheriting from HomeOnlyAuth means HomeOnlyRouteGateMiddleware includes
111 these paths in the passkey-mode route gate, short-circuiting probes to
112 404 above Django's URL dispatcher — so Pydantic body-schema validation
113 cannot leak route existence via 422 on a POST with an invalid payload
114 (pentest round 5 / F2).
115 """
117 def __call__(self, request: HttpRequest) -> Any:
118 if settings.AUTH_MODE != "home":
119 raise HttpError(404, "Not found")
120 # CSRF enforced here on unsafe methods; safe methods are no-ops.
121 # Raises HttpError(403) on CSRF failure. No cookie/session lookup.
122 self._get_key(request)
123 return True