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

1"""Session-based authentication for Django Ninja endpoints.""" 

2 

3import logging 

4from typing import Any, Optional 

5 

6from django.conf import settings 

7from django.http import HttpRequest 

8from ninja.errors import HttpError 

9from ninja.security import APIKeyCookie 

10 

11from apps.profiles.models import Profile 

12 

13security_logger = logging.getLogger("security") 

14 

15 

16class SessionAuth(APIKeyCookie): 

17 """Mode-aware authenticator. 

18 

19 Home mode: checks session["profile_id"] → Profile (no user accounts). 

20 Passkey mode: checks request.user.is_authenticated → request.user.profile. 

21 """ 

22 

23 param_name: str = settings.SESSION_COOKIE_NAME 

24 

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) 

29 

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 

49 

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 

80 

81 

82class AdminAuth(SessionAuth): 

83 """Admin-only authenticator. 

84 

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. 

90 

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

94 

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) 

← Back to Dashboard