Coverage for netbox/api/authentication.py: 64%
104 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
3from django.conf import settings
4from django.utils import timezone
5from drf_spectacular.extensions import OpenApiAuthenticationExtension
6from rest_framework import exceptions
7from rest_framework.authentication import BaseAuthentication, get_authorization_header
8from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoObjectPermissions
10from netbox.config import get_config
11from users.constants import TOKEN_PREFIX
12from users.models import Token
13from utilities.request import get_client_ip
15V1_KEYWORD = 'Token'
16V2_KEYWORD = 'Bearer'
19class TokenAuthentication(BaseAuthentication):
20 """
21 A custom authentication scheme which enforces Token expiration times and source IP restrictions.
22 """
23 model = Token
25 def authenticate(self, request):
26 # Authorization header is not present; ignore
27 if not (auth := get_authorization_header(request).split()):
28 return None
29 # Unrecognized header; ignore
30 if auth[0].lower() not in (V1_KEYWORD.lower().encode(), V2_KEYWORD.lower().encode()):
31 return None
32 # Check for extraneous token content
33 if len(auth) != 2: 33 ↛ 34line 33 didn't jump to line 34 because the condition on line 33 was never true
34 raise exceptions.AuthenticationFailed(
35 'Invalid authorization header: Must be in the form "Bearer <key>.<token>" or "Token <token>"'
36 )
37 # Extract the key (if v2) & token plaintext from the auth header
38 try:
39 auth_value = auth[1].decode()
40 except UnicodeError:
41 raise exceptions.AuthenticationFailed('Invalid authorization header: Token contains invalid characters')
43 # Infer token version from presence or absence of prefix
44 version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1
46 if version == 1: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true
47 key, plaintext = None, auth_value
48 else:
49 auth_value = auth_value.removeprefix(TOKEN_PREFIX)
50 try:
51 key, plaintext = auth_value.split('.', 1)
52 except ValueError:
53 raise exceptions.AuthenticationFailed(
54 "Invalid authorization header: Could not parse key from v2 token. Did you mean to use 'Token' "
55 "instead of 'Bearer'?"
56 )
58 # Look for a matching token in the database
59 try:
60 qs = Token.objects.prefetch_related('user')
61 if version == 1: 61 ↛ 63line 61 didn't jump to line 63 because the condition on line 61 was never true
62 # Fetch v1 token by querying plaintext value directly
63 token = qs.get(version=version, plaintext=plaintext)
64 else:
65 # Fetch v2 token by key, then validate the plaintext
66 token = qs.get(version=version, key=key)
67 if not token.validate(plaintext): 67 ↛ 69line 67 didn't jump to line 69 because the condition on line 67 was never true
68 # Key is valid but plaintext is not. Raise DoesNotExist to guard against key enumeration.
69 raise Token.DoesNotExist()
70 except Token.DoesNotExist:
71 raise exceptions.AuthenticationFailed(f"Invalid v{version} token")
73 # Enforce source IP restrictions (if any) set on the token
74 if token.allowed_ips: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 client_ip = get_client_ip(request)
76 if client_ip is None:
77 raise exceptions.AuthenticationFailed(
78 'Client IP address could not be determined for validation. Check that the HTTP server is '
79 'correctly configured to pass the required header(s).'
80 )
81 if not token.validate_client_ip(client_ip):
82 raise exceptions.AuthenticationFailed(
83 f"Source IP {client_ip} is not permitted to authenticate using this token."
84 )
86 # Enforce the Token is enabled
87 if not token.enabled: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 raise exceptions.AuthenticationFailed('Token disabled')
90 # Enforce the Token's expiration time, if one has been set.
91 if token.is_expired: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true
92 raise exceptions.AuthenticationFailed('Token expired')
94 # Update last used, but only once per minute at most. This reduces write load on the database
95 if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
96 # If maintenance mode is enabled, assume the database is read-only, and disable updating the token's
97 # last_used time upon authentication.
98 if get_config().MAINTENANCE_MODE: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 logger = logging.getLogger('netbox.auth.login')
100 logger.debug("Maintenance mode enabled: Disabling update of token's last used timestamp")
101 else:
102 Token.objects.filter(pk=token.pk).update(last_used=timezone.now())
104 user = token.user
106 # When LDAP authentication is active try to load user data from LDAP directory
107 if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 from netbox.authentication import LDAPBackend
109 ldap_backend = LDAPBackend()
111 # Load from LDAP if FIND_GROUP_PERMS is active
112 # Always query LDAP when user is not active, otherwise it is never activated again
113 if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
114 ldap_user = ldap_backend.populate_user(token.user.username)
115 # If the user is found in the LDAP directory use it, if not fallback to the local user
116 if ldap_user:
117 user = ldap_user
119 if not user.is_active: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 raise exceptions.AuthenticationFailed("User inactive")
122 return user, token
125class TokenPermissions(DjangoObjectPermissions):
126 """
127 Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
128 for unsafe requests (POST/PUT/PATCH/DELETE).
129 """
130 # Override the stock perm_map to enforce view permissions
131 perms_map = {
132 'GET': ['%(app_label)s.view_%(model_name)s'],
133 'OPTIONS': [],
134 'HEAD': ['%(app_label)s.view_%(model_name)s'],
135 'POST': ['%(app_label)s.add_%(model_name)s'],
136 'PUT': ['%(app_label)s.change_%(model_name)s'],
137 'PATCH': ['%(app_label)s.change_%(model_name)s'],
138 'DELETE': ['%(app_label)s.delete_%(model_name)s'],
139 }
141 def __init__(self):
143 # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
144 self.authenticated_users_only = settings.LOGIN_REQUIRED
146 super().__init__()
148 def _verify_write_permission(self, request):
150 # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
151 if request.method in SAFE_METHODS or request.auth.write_enabled: 151 ↛ 153line 151 didn't jump to line 153 because the condition on line 151 was always true
152 return True
153 return False
155 def has_permission(self, request, view):
157 # Enforce Token write ability
158 if isinstance(request.auth, Token) and not self._verify_write_permission(request): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 return False
161 return super().has_permission(request, view)
163 def has_object_permission(self, request, view, obj):
165 # Enforce Token write ability
166 if isinstance(request.auth, Token) and not self._verify_write_permission(request): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 return False
169 return super().has_object_permission(request, view, obj)
172class TokenWritePermission(BasePermission):
173 """
174 Verify the token has write_enabled for unsafe methods, without requiring specific model permissions.
175 Used for custom actions that accept user data but don't map to standard CRUD operations.
176 """
178 def has_permission(self, request, view):
179 if not isinstance(request.auth, Token): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 raise exceptions.PermissionDenied(
181 "TokenWritePermission requires token authentication."
182 )
183 return bool(request.method in SAFE_METHODS or request.auth.write_enabled)
186class TokenSyncPermission(TokenPermissions):
187 """
188 Require a model's sync permission for the sync action, in place of the add permission a POST otherwise
189 maps to. Token write ability is still enforced by the parent class.
190 """
191 perms_map = {
192 **TokenPermissions.perms_map,
193 'POST': ['%(app_label)s.sync_%(model_name)s'],
194 }
197class IsAuthenticatedOrLoginNotRequired(BasePermission):
198 """
199 Returns True if the user is authenticated or LOGIN_REQUIRED is False.
200 """
201 def has_permission(self, request, view):
202 if not settings.LOGIN_REQUIRED: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 return True
204 return request.user.is_authenticated
207class TokenScheme(OpenApiAuthenticationExtension):
208 target_class = 'netbox.api.authentication.TokenAuthentication'
209 name = 'tokenAuth'
210 match_subclasses = True
212 def get_security_definition(self, auto_schema):
213 return {
214 'type': 'apiKey',
215 'in': 'header',
216 'name': 'Authorization',
217 'description': '`Token <token>` (v1) or `Bearer <key>.<token>` (v2)',
218 }