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

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 

13__all__ = ["SessionAuth", "HomeOnlyAuth", "HomeOnlyAnonAuth"] 

14 

15security_logger = logging.getLogger("security") 

16 

17 

18class SessionAuth(APIKeyCookie): 

19 """Mode-aware authenticator. 

20 

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

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

23 """ 

24 

25 param_name: str = settings.SESSION_COOKIE_NAME 

26 

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) 

31 

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 

51 

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 

82 

83 

84class HomeOnlyAuth(SessionAuth): 

85 """SessionAuth gated by AUTH_MODE=home. 

86 

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. 

91 

92 Applied to every endpoint whose functional scope is home-mode only 

93 (the admin endpoints + the authenticated profile endpoints). 

94 """ 

95 

96 def __call__(self, request: HttpRequest) -> Any: 

97 if settings.AUTH_MODE != "home": 

98 raise HttpError(404, "Not found") 

99 return super().__call__(request) 

100 

101 

102class HomeOnlyAnonAuth(HomeOnlyAuth): 

103 """Pre-session variant of HomeOnlyAuth for profile-selector endpoints. 

104 

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. 

109 

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

116 

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 

← Back to Dashboard