Coverage for apps / core / auth.py: 82%
57 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +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
13security_logger = logging.getLogger("security")
16class SessionAuth(APIKeyCookie):
17 """Mode-aware authenticator.
19 Home mode: checks session["profile_id"] → Profile (no user accounts).
20 Passkey mode: checks request.user.is_authenticated → request.user.profile.
21 """
23 param_name: str = settings.SESSION_COOKIE_NAME
25 def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]:
26 if settings.AUTH_MODE == "passkey":
27 return self._authenticate_passkey(request)
28 return self._authenticate_home(request)
30 def _authenticate_home(self, request: HttpRequest) -> Optional[Profile]:
31 profile_id = request.session.get("profile_id")
32 if not profile_id:
33 security_logger.warning(
34 "Auth failure: no profile_id in session for %s from %s",
35 request.path,
36 request.META.get("REMOTE_ADDR"),
37 )
38 return None
39 try:
40 return Profile.objects.get(id=profile_id)
41 except Profile.DoesNotExist:
42 security_logger.warning(
43 "Auth failure: invalid profile_id %s for %s from %s",
44 profile_id,
45 request.path,
46 request.META.get("REMOTE_ADDR"),
47 )
48 return None
50 def _authenticate_passkey(self, request: HttpRequest) -> Optional[Profile]:
51 user = getattr(request, "user", None)
52 if not user or not getattr(user, "is_authenticated", False):
53 # Fallback: check session profile_id (set during passkey login)
54 profile_id = request.session.get("profile_id")
55 if profile_id:
56 try:
57 profile = Profile.objects.select_related("user").get(id=profile_id)
58 if profile.user and profile.user.is_active:
59 request.user = profile.user
60 return profile
61 except Profile.DoesNotExist:
62 pass
63 security_logger.warning(
64 "Auth failure: unauthenticated request to %s from %s",
65 request.path,
66 request.META.get("REMOTE_ADDR"),
67 )
68 return None
69 if not user.is_active:
70 return None
71 try:
72 return user.profile
73 except Profile.DoesNotExist:
74 security_logger.warning(
75 "Auth failure: no profile for user %s at %s",
76 user.pk,
77 request.path,
78 )
79 return None
82class AdminAuth(SessionAuth):
83 """Admin-only authenticator.
85 Home mode: identical to SessionAuth (no admin distinction). This is an
86 accepted design decision — home mode is intended for single-household use
87 where all profiles are trusted. Any profile holder can access admin
88 endpoints (e.g., /api/system/reset/, /api/ai/save-api-key). This matches
89 the constitution's Principle III: no authentication friction in home mode.
91 Passkey mode: resolves user first, then checks is_staff. Only users
92 promoted to admin via the cookie_admin CLI can access admin endpoints.
93 """
95 def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]:
96 if settings.AUTH_MODE == "passkey":
97 profile = self._authenticate_passkey(request)
98 if profile is None:
99 return None # 401 — not authenticated
100 user = getattr(request, "user", None)
101 if not user or not user.is_staff:
102 security_logger.warning(
103 "Admin auth failure: %s from %s",
104 request.path,
105 request.META.get("REMOTE_ADDR"),
106 )
107 raise HttpError(403, "Admin access required")
108 return profile
109 return self._authenticate_home(request)