From 1ee23ba6fa206668186baf8a0e3d2c140f85b333 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:04:29 -0400 Subject: [PATCH 01/16] Initial work on #20210 --- contrib/openapi.json | 235 ++++++++++++++++-- docs/integrations/rest-api.md | 23 +- netbox/account/tables.py | 3 +- netbox/account/views.py | 2 +- netbox/core/tests/test_api.py | 4 +- netbox/netbox/api/authentication.py | 108 +++++--- netbox/netbox/configuration_testing.py | 4 + netbox/netbox/settings.py | 8 + netbox/netbox/tests/test_authentication.py | 124 +++++++-- netbox/templates/users/token.html | 19 +- netbox/users/api/serializers_/tokens.py | 28 +-- netbox/users/choices.py | 17 ++ netbox/users/constants.py | 4 + netbox/users/filtersets.py | 2 +- netbox/users/forms/bulk_import.py | 15 +- netbox/users/forms/filtersets.py | 7 +- netbox/users/forms/model_forms.py | 35 ++- .../users/migrations/0014_users_token_v2.py | 65 +++++ netbox/users/models/tokens.py | 140 +++++++++-- netbox/users/tables.py | 3 +- netbox/users/tests/test_api.py | 6 +- netbox/users/tests/test_filtersets.py | 30 ++- netbox/users/tests/test_views.py | 31 +-- netbox/users/utils.py | 17 ++ netbox/utilities/testing/api.py | 5 +- netbox/utilities/testing/views.py | 24 +- 26 files changed, 787 insertions(+), 172 deletions(-) create mode 100644 netbox/users/choices.py create mode 100644 netbox/users/migrations/0014_users_token_v2.py diff --git a/contrib/openapi.json b/contrib/openapi.json index 839aba0b4..3618a36af 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166111,6 +166111,91 @@ "type": "string" } }, + { + "in": "query", + "name": "pepper", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "pepper__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "q", @@ -166171,6 +166256,19 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "version", + "schema": { + "type": "integer", + "x-spec-enum-id": "b5df70f0bffd12cb", + "enum": [ + 1, + 2 + ] + }, + "description": "* `1` - v1\n* `2` - v2" + }, { "in": "query", "name": "write_enabled", @@ -228068,6 +228166,17 @@ "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", "properties": { + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "user": { "oneOf": [ { @@ -228078,6 +228187,10 @@ } ] }, + "description": { + "type": "string", + "maxLength": 200 + }, "expires": { "type": "string", "format": "date-time", @@ -228088,19 +228201,20 @@ "format": "date-time", "nullable": true }, - "key": { - "type": "string", - "writeOnly": true, - "maxLength": 40, - "minLength": 40 - }, "write_enabled": { "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { + "pepper": { + "type": "integer", + "maximum": 32767, + "minimum": 0, + "nullable": true, + "description": "ID of the cryptographic pepper used to hash the token (v2 only)" + }, + "token": { "type": "string", - "maxLength": 200 + "minLength": 1 } } }, @@ -244302,9 +244416,30 @@ "type": "string", "readOnly": true }, + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, + "key": { + "type": "string", + "readOnly": true, + "nullable": true, + "description": "v2 token identification key" + }, "user": { "$ref": "#/components/schemas/BriefUser" }, + "description": { + "type": "string", + "maxLength": 200 + }, "created": { "type": "string", "format": "date-time", @@ -244324,9 +244459,15 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { - "type": "string", - "maxLength": 200 + "pepper": { + "type": "integer", + "maximum": 32767, + "minimum": 0, + "nullable": true, + "description": "ID of the cryptographic pepper used to hash the token (v2 only)" + }, + "token": { + "type": "string" } }, "required": [ @@ -244334,6 +244475,7 @@ "display", "display_url", "id", + "key", "url", "user" ] @@ -244360,6 +244502,17 @@ "type": "string", "readOnly": true }, + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "user": { "allOf": [ { @@ -244368,6 +244521,10 @@ ], "readOnly": true }, + "key": { + "type": "string", + "readOnly": true + }, "created": { "type": "string", "format": "date-time", @@ -244383,10 +244540,6 @@ "format": "date-time", "readOnly": true }, - "key": { - "type": "string", - "readOnly": true - }, "write_enabled": { "type": "boolean", "description": "Permit create/update/delete operations using this key" @@ -244394,6 +244547,9 @@ "description": { "type": "string", "maxLength": 200 + }, + "token": { + "type": "string" } }, "required": [ @@ -244411,6 +244567,17 @@ "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", "properties": { + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "expires": { "type": "string", "format": "date-time", @@ -244433,6 +244600,10 @@ "type": "string", "writeOnly": true, "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 } }, "required": [ @@ -244444,6 +244615,17 @@ "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", "properties": { + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "user": { "oneOf": [ { @@ -244454,6 +244636,10 @@ } ] }, + "description": { + "type": "string", + "maxLength": 200 + }, "expires": { "type": "string", "format": "date-time", @@ -244464,19 +244650,20 @@ "format": "date-time", "nullable": true }, - "key": { - "type": "string", - "writeOnly": true, - "maxLength": 40, - "minLength": 40 - }, "write_enabled": { "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { + "pepper": { + "type": "integer", + "maximum": 32767, + "minimum": 0, + "nullable": true, + "description": "ID of the cryptographic pepper used to hash the token (v2 only)" + }, + "token": { "type": "string", - "maxLength": 200 + "minLength": 1 } }, "required": [ @@ -256709,7 +256896,7 @@ "type": "apiKey", "in": "header", "name": "Authorization", - "description": "Token-based authentication with required prefix \"Token\"" + "description": "Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header" } } }, diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 47fb65494..9cecbca3d 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -657,14 +657,17 @@ A token is a unique identifier mapped to a NetBox user account. Each user may ha By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. -Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. +!!! info "Token Versions" + Beginning with NetBox v4.5, two types of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens, as these provide much stronger security than v1 tokens. Support for v1 tokens will be removed in a future NetBox release. + +When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. !!! info "Restricting Token Retrieval" The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. -### Restricting Write Operations +#### Restricting Write Operations By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. @@ -681,10 +684,22 @@ It is possible to provision authentication tokens for other users via the REST A ### Authenticating to the API -An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: +An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's key and plaintext value with a period: ``` -$ curl -H "Authorization: Token $TOKEN" \ +Authorization: Bearer . +``` + +v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.) + +``` +Authorization: Token +``` + +Below is an example REST API request utilizing a v2 token. + +``` +$ curl -H "Authorization: Bearer ." \ -H "Accept: application/json; indent=4" \ https://netbox/api/dcim/sites/ { diff --git a/netbox/account/tables.py b/netbox/account/tables.py index bcc0a0ccd..0b15a8a13 100644 --- a/netbox/account/tables.py +++ b/netbox/account/tables.py @@ -53,5 +53,6 @@ class UserTokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = UserToken fields = ( - 'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', + 'pk', 'id', 'version', 'key', 'pepper', 'description', 'write_enabled', 'created', 'expires', 'last_used', + 'allowed_ips', ) diff --git a/netbox/account/views.py b/netbox/account/views.py index f5ef534ce..b513f04e4 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -343,7 +343,7 @@ class UserTokenView(LoginRequiredMixin, View): def get(self, request, pk): token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None + key = token.key if token.v2 or settings.ALLOW_TOKEN_RETRIEVAL else None return render(request, 'account/token.html', { 'object': token, diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 4d612e157..46070c4b4 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -135,8 +135,8 @@ class BackgroundTaskTestCase(TestCase): """ # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser', is_active=True) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'} + self.token = Token.objects.create(version=1, user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} # Clear all queues prior to running each test get_queue('default').connection.flushall() diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index f0bd5fd27..9c73259bf 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -2,47 +2,86 @@ import logging from django.conf import settings from django.utils import timezone -from rest_framework import authentication, exceptions +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS from netbox.config import get_config from users.models import Token from utilities.request import get_client_ip +V1_KEYWORD = 'token' +V2_KEYWORD = 'bearer' -class TokenAuthentication(authentication.TokenAuthentication): + +class TokenAuthentication(BaseAuthentication): """ A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token def authenticate(self, request): - result = super().authenticate(request) + if not (auth := get_authorization_header(request).split()): + return - if result: - token = result[1] + # Check for Token/Bearer keyword in HTTP header value & infer token version + if auth[0].lower() == V1_KEYWORD.lower().encode(): + version = 1 + elif auth[0].lower() == V2_KEYWORD.lower().encode(): + version = 2 + else: + return - # Enforce source IP restrictions (if any) set on the token - if token.allowed_ips: - client_ip = get_client_ip(request) - if client_ip is None: - raise exceptions.AuthenticationFailed( - "Client IP address could not be determined for validation. Check that the HTTP server is " - "correctly configured to pass the required header(s)." - ) - if not token.validate_client_ip(client_ip): - raise exceptions.AuthenticationFailed( - f"Source IP {client_ip} is not permitted to authenticate using this token." - ) - - return result - - def authenticate_credentials(self, key): - model = self.get_model() + # Extract token key from authorization header + if len(auth) != 2: + raise exceptions.AuthenticationFailed("Invalid authorization header: Error parsing token") try: - token = model.objects.prefetch_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") + auth_value = auth[1].decode() + except UnicodeError: + raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") + + # Look for a matching token in the database + if version == 1: + key, plaintext = None, auth_value + else: + try: + key, plaintext = auth_value.split('.', 1) + except ValueError: + raise exceptions.AuthenticationFailed( + "Invalid authorization header: Could not parse key from v2 token. Did you mean to use 'Token' " + "instead of 'Bearer'?" + ) + try: + qs = Token.objects.prefetch_related('user') + if version == 1: + # Fetch v1 token by querying plaintext value directly + token = qs.get(version=version, plaintext=plaintext) + else: + # Fetch v2 token by key, then validate the plaintext + token = qs.get(version=version, key=key) + if not token.validate(plaintext): + # TODO: Consider security implications of enabling validation of token key without valid plaintext + raise exceptions.AuthenticationFailed(f"Validation failed for v2 token {key}") + except Token.DoesNotExist: + raise exceptions.AuthenticationFailed(f"Invalid v{version} token") + + # Enforce source IP restrictions (if any) set on the token + if token.allowed_ips: + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) + + # Enforce the Token's expiration time, if one has been set. + if token.is_expired: + raise exceptions.AuthenticationFailed("Token expired") # Update last used, but only once per minute at most. This reduces write load on the database if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: @@ -54,11 +93,8 @@ class TokenAuthentication(authentication.TokenAuthentication): else: Token.objects.filter(pk=token.pk).update(last_used=timezone.now()) - # Enforce the Token's expiration time, if one has been set. - if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") - user = token.user + # When LDAP authentication is active try to load user data from LDAP directory if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: from netbox.authentication import LDAPBackend @@ -132,3 +168,17 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): if not settings.LOGIN_REQUIRED: return True return request.user.is_authenticated + + +class TokenScheme(OpenApiAuthenticationExtension): + target_class = 'netbox.api.authentication.TokenAuthentication' + name = 'tokenAuth' + match_subclasses = True + + def get_security_definition(self, auto_schema): + return { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': 'Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header', + } diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 52973e94d..bbe6dbada 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -45,6 +45,10 @@ DEFAULT_PERMISSIONS = {} ALLOW_TOKEN_RETRIEVAL = True +API_TOKEN_PEPPERS = { + 0: 'TEST-VALUE-DO-NOT-USE', +} + LOGGING = { 'version': 1, 'disable_existing_loggers': True diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c0d7f9230..a912c2d6e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -65,6 +65,7 @@ elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): ADMINS = getattr(configuration, 'ADMINS', []) ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False) ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required +API_TOKEN_PEPPERS = getattr(configuration, 'API_TOKEN_PEPPERS', {}) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [ { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", @@ -215,6 +216,13 @@ if len(SECRET_KEY) < 50: f" python {BASE_DIR}/generate_secret_key.py" ) +# Validate API token peppers +for key in API_TOKEN_PEPPERS: + if type(key) is not int: + raise ImproperlyConfigured(f"Invalid API_TOKEN_PEPPERS key: {key}. All keys must be integers.") +if not API_TOKEN_PEPPERS: + warnings.warn("API_TOKEN_PEPPERS is not defined. v2 API tokens cannot be used.") + # Validate update repo URL and timeout if RELEASE_CHECK_URL: try: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 9eb21661d..e30ae8700 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -16,35 +16,79 @@ from utilities.testing.api import APITestCase class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) - def test_token_authentication(self): - url = reverse('dcim-api:site-list') - + def test_no_token(self): # Request without a token should return a 403 - response = self.client.get(url) + response = self.client.get(reverse('dcim-api:site-list')) self.assertEqual(response.status_code, 403) + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v1_token_valid(self): + # Create a v1 token + token = Token.objects.create(version=1, user=self.user) + # Valid token should return a 200 - token = Token.objects.create(user=self.user) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') - self.assertEqual(response.status_code, 200) + header = f'Token {token.token}' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 200, response.data) # Check that the token's last_used time has been updated token.refresh_from_db() self.assertIsNotNone(token.last_used) + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v1_token_invalid(self): + # Invalid token should return a 403 + header = 'Token XXXXXXXXXX' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data['detail'], "Invalid v1 token") + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v2_token_valid(self): + # Create a v2 token + token = Token.objects.create(version=2, user=self.user) + + # Valid token should return a 200 + header = f'Bearer {token.key}.{token.token}' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 200, response.data) + + # Check that the token's last_used time has been updated + token.refresh_from_db() + self.assertIsNotNone(token.last_used) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v2_token_invalid(self): + # Invalid token should return a 403 + header = 'Bearer XXXXXXXXXX.XXXXXXXXXX' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data['detail'], "Invalid v2 token") + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_expiration(self): url = reverse('dcim-api:site-list') - # Request without a non-expired token should succeed - token = Token.objects.create(user=self.user) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + # Create v1 & v2 tokens + future = datetime.datetime(2100, 1, 1, tzinfo=datetime.timezone.utc) + token1 = Token.objects.create(version=1, user=self.user, expires=future) + token2 = Token.objects.create(version=2, user=self.user, expires=future) + + # Request with a non-expired token should succeed + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 200) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') self.assertEqual(response.status_code, 200) # Request with an expired token should fail - token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) - token.save() - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token1.expires = past + token1.save() + token2.expires = past + token2.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.key}') + self.assertEqual(response.status_code, 403) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}') self.assertEqual(response.status_code, 403) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @@ -55,28 +99,60 @@ class TokenAuthenticationTestCase(APITestCase): 'slug': 'site-1', } + # Create v1 & v2 tokens + token1 = Token.objects.create(version=1, user=self.user, write_enabled=False) + token2 = Token.objects.create(version=2, user=self.user, write_enabled=False) + # Request with a write-disabled token should fail - token = Token.objects.create(user=self.user, write_enabled=False) - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 403) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') self.assertEqual(response.status_code, 403) # Request with a write-enabled token should succeed - token.write_enabled = True - token.save() - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + token1.write_enabled = True + token1.save() + token2.write_enabled = True + token2.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 403) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') self.assertEqual(response.status_code, 403) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_allowed_ips(self): url = reverse('dcim-api:site-list') + # Create v1 & v2 tokens + token1 = Token.objects.create(version=1, user=self.user, allowed_ips=['192.0.2.0/24']) + token2 = Token.objects.create(version=2, user=self.user, allowed_ips=['192.0.2.0/24']) + # Request from a non-allowed client IP should fail - token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Token {token1.token}', + REMOTE_ADDR='127.0.0.1' + ) + self.assertEqual(response.status_code, 403) + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + REMOTE_ADDR='127.0.0.1' + ) self.assertEqual(response.status_code, 403) - # Request with an expired token should fail - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + # Request from an allowed client IP should succeed + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Token {token1.token}', + REMOTE_ADDR='192.0.2.1' + ) + self.assertEqual(response.status_code, 200) + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + REMOTE_ADDR='192.0.2.1' + ) self.assertEqual(response.status_code, 200) @@ -426,8 +502,8 @@ class ObjectPermissionAPIViewTestCase(TestCase): Create a test user and token for API calls. """ self.user = User.objects.create(username='testuser') - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + self.token = Token.objects.create(version=1, user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 674476d51..039d02759 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -14,9 +14,24 @@

{% trans "Token" %}

- - + + + {% if object.version == 1 %} + + + + + {% else %} + + + + + + + + + {% endif %} - - + + {% endif %} diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index f7da4dd13..3b5ec08ee 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -32,7 +32,7 @@ class TokenSerializer(ValidatedModelSerializer): model = Token fields = ( 'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires', - 'last_used', 'write_enabled', 'pepper', 'allowed_ips', 'token', + 'last_used', 'write_enabled', 'pepper_id', 'allowed_ips', 'token', ) read_only_fields = ('key',) brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description') diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 3cbef8b1a..a67761354 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -133,7 +133,7 @@ class TokenFilterSet(BaseFilterSet): class Meta: model = Token - fields = ('id', 'version', 'key', 'pepper', 'write_enabled', 'description', 'last_used') + fields = ('id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'last_used') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 39f1cbf48..ab0301244 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -64,7 +64,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='token', - name='pepper', + name='pepper_id', field=models.PositiveSmallIntegerField(blank=True, null=True), ), migrations.AddField( diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 96aa8e821..fe112c1c1 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -80,8 +80,8 @@ class Token(models.Model): validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], help_text=_('v2 token identification key'), ) - pepper = models.PositiveSmallIntegerField( - verbose_name=_('pepper'), + pepper_id = models.PositiveSmallIntegerField( + verbose_name=_('pepper ID'), blank=True, null=True, help_text=_('ID of the cryptographic pepper used to hash the token (v2 only)'), @@ -179,7 +179,7 @@ class Token(models.Model): """ Recalculate and save the HMAC digest using the currently defined pepper and token values. """ - self.pepper, pepper_value = get_current_pepper() + self.pepper_id, pepper_value = get_current_pepper() self.hmac_digest = hmac.new( pepper_value.encode('utf-8'), self.token.encode('utf-8'), @@ -202,7 +202,7 @@ class Token(models.Model): return token == self.key if self.v2: try: - pepper = settings.API_TOKEN_PEPPERS[self.pepper] + pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] except KeyError: # Invalid pepper ID return False diff --git a/netbox/users/tables.py b/netbox/users/tables.py index b8683cc87..249803840 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -22,7 +22,7 @@ class TokenTable(UserTokenTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'version', 'key', 'pepper', 'user', 'description', 'write_enabled', 'created', 'expires', + 'pk', 'id', 'version', 'key', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) From 43fc7fb58aebe7f23252c64114d8dd02572056a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 16:05:09 -0400 Subject: [PATCH 04/16] Add constraints to enforce v1/v2-dependent fields --- .../users/migrations/0014_users_token_v2.py | 25 +++++++++++++++++++ netbox/users/models/tokens.py | 22 ++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index ab0301244..1c0bb5c1d 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -72,4 +72,29 @@ class Migration(migrations.Migration): name='hmac_digest', field=models.CharField(blank=True, max_length=64, null=True), ), + + # Add constraints to enforce v1/v2-dependent fields + migrations.AddConstraint( + model_name='token', + constraint=models.CheckConstraint( + name='enforce_version_dependent_fields', + condition=models.Q( + models.Q( + ('hmac_digest__isnull', True), + ('key__isnull', True), + ('pepper_id__isnull', True), + ('plaintext__isnull', False), + ('version', 1) + ), + models.Q( + ('hmac_digest__isnull', False), + ('key__isnull', False), + ('pepper_id__isnull', False), + ('plaintext__isnull', True), + ('version', 2) + ), + _connector='OR' + ) + ) + ), ] diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index fe112c1c1..3e8e0f108 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -7,6 +7,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -110,6 +111,27 @@ class Token(models.Model): ordering = ('-created',) verbose_name = _('token') verbose_name_plural = _('tokens') + constraints = [ + models.CheckConstraint( + name='enforce_version_dependent_fields', + condition=( + Q( + version=1, + key__isnull=True, + pepper_id__isnull=True, + hmac_digest__isnull=True, + plaintext__isnull=False + ) | + Q( + version=2, + key__isnull=False, + pepper_id__isnull=False, + hmac_digest__isnull=False, + plaintext__isnull=True + ) + ), + ), + ] def __init__(self, *args, token=None, **kwargs): super().__init__(*args, **kwargs) From f82f084c02f0d1b2aafc6650b7174dbc541686aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 16:33:04 -0400 Subject: [PATCH 05/16] Misc cleanup --- netbox/users/models/tokens.py | 36 +++++++++++++++++++++++------------ netbox/users/utils.py | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 3e8e0f108..e452d2ab7 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -29,6 +29,8 @@ class Token(models.Model): An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. """ + _token = None + version = models.PositiveSmallIntegerField( verbose_name=_('version'), choices=TokenVersionChoices, @@ -136,12 +138,12 @@ class Token(models.Model): def __init__(self, *args, token=None, **kwargs): super().__init__(*args, **kwargs) + # This stores the initial plaintext value (if given) on the creation of a new Token. If not provided, a + # random token value will be generated and assigned immediately prior to saving the Token instance. self.token = token def __str__(self): - if self.v1: - return self.partial - return self.key + return self.key if self.v2 else self.partial def get_absolute_url(self): return reverse('users:token', args=[self.pk]) @@ -156,14 +158,19 @@ class Token(models.Model): @property def partial(self): + """ + Return a sanitized representation of a v1 token. + """ return f'**********************************{self.plaintext[-6:]}' if self.plaintext else '' @property def token(self): - return getattr(self, '_token', None) + return self._token @token.setter def token(self, value): + if not self._state.adding: + raise ValueError("Cannot assign a new plaintext value for an existing token.") self._token = value if value is not None: if self.v1: @@ -173,8 +180,11 @@ class Token(models.Model): self.update_digest() def clean(self): - if self._state.adding and self.v2 and not settings.API_TOKEN_PEPPERS: - raise ValidationError(_("Cannot create v2 tokens: API_TOKEN_PEPPERS is not defined.")) + if self._state.adding: + if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS: + raise ValidationError(_( + "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS." + ).format(id=self.pepper_id)) def save(self, *args, **kwargs): # If creating a new Token and no token value has been specified, generate one @@ -201,9 +211,9 @@ class Token(models.Model): """ Recalculate and save the HMAC digest using the currently defined pepper and token values. """ - self.pepper_id, pepper_value = get_current_pepper() + self.pepper_id, pepper = get_current_pepper() self.hmac_digest = hmac.new( - pepper_value.encode('utf-8'), + pepper.encode('utf-8'), self.token.encode('utf-8'), hashlib.sha256 ).hexdigest() @@ -216,12 +226,14 @@ class Token(models.Model): def validate(self, token): """ - Returns true if the given token value validates. + Validate the given plaintext against the token. + + For v1 tokens, check that the given value is equal to the stored plaintext. For v2 tokens, calculate an HMAC + from the Token's pepper ID and the given plaintext value, and check whether the result matches the recorded + digest. """ - if self.is_expired: - return False if self.v1: - return token == self.key + return token == self.token if self.v2: try: pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] diff --git a/netbox/users/utils.py b/netbox/users/utils.py index 045d192c7..5db8cb65e 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -22,5 +22,5 @@ def get_current_pepper(): """ if len(settings.API_TOKEN_PEPPERS) < 1: raise ImproperlyConfigured("Must define API_TOKEN_PEPPERS to use v2 API tokens") - newest_id = sorted(settings.API_TOKEN_PEPPERS)[-1] + newest_id = sorted(settings.API_TOKEN_PEPPERS.keys())[-1] return newest_id, settings.API_TOKEN_PEPPERS[newest_id] From adce67a7cfc47e7851a63ce8567b96401cc36f99 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 16:37:28 -0400 Subject: [PATCH 06/16] Standardize on the use of v2 tokens in tests --- netbox/core/tests/test_api.py | 4 ++-- netbox/netbox/tests/test_authentication.py | 4 ++-- netbox/utilities/testing/api.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 46070c4b4..29530bfa6 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -135,8 +135,8 @@ class BackgroundTaskTestCase(TestCase): """ # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser', is_active=True) - self.token = Token.objects.create(version=1, user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} # Clear all queues prior to running each test get_queue('default').connection.flushall() diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index e30ae8700..bd7d41186 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -502,8 +502,8 @@ class ObjectPermissionAPIViewTestCase(TestCase): Create a test user and token for API calls. """ self.user = User.objects.create(username='testuser') - self.token = Token.objects.create(version=1, user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 32e5fe53f..973b05cb3 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -49,8 +49,8 @@ class APITestCase(ModelTestCase): # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) - self.token = Token.objects.create(version=1, user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.plaintext}'} + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} def _get_view_namespace(self): return f'{self.view_namespace or self.model._meta.app_label}-api' From f6290dd7af2c1879e7b0560ff485ad1194c3a3d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 09:16:50 -0400 Subject: [PATCH 07/16] Toggle plaintext display for v1 tokens --- netbox/templates/account/token.html | 62 ++--------------------------- netbox/templates/users/token.html | 9 ++++- 2 files changed, 12 insertions(+), 59 deletions(-) diff --git a/netbox/templates/account/token.html b/netbox/templates/account/token.html index f8ce7badd..6fcf9e359 100644 --- a/netbox/templates/account/token.html +++ b/netbox/templates/account/token.html @@ -1,62 +1,8 @@ -{% extends 'generic/object.html' %} -{% load form_helpers %} -{% load helpers %} +{% extends 'users/token.html' %} {% load i18n %} -{% load plugins %} {% block breadcrumbs %} - + {% endblock breadcrumbs %} - -{% block title %}{% trans "Token" %} {{ object }}{% endblock %} - -{% block subtitle %}{% endblock %} - -{% block content %} -
-
-
-

{% trans "Token" %}

-
{% trans "Key" %}{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}{% trans "Version" %}{{ object.version }}
{% trans "Token" %}{{ object.partial }}
{% trans "Key" %}{{ object }}
{% trans "Pepper" %}{{ object.pepper }}
{% trans "User" %} diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index 150291ee6..f7da4dd13 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.auth import authenticate from rest_framework import serializers from rest_framework.exceptions import AuthenticationFailed, PermissionDenied @@ -15,14 +14,13 @@ __all__ = ( class TokenSerializer(ValidatedModelSerializer): - key = serializers.CharField( - min_length=40, - max_length=40, - allow_blank=True, + token = serializers.CharField( required=False, - write_only=not settings.ALLOW_TOKEN_RETRIEVAL + default=Token.generate, + ) + user = UserSerializer( + nested=True ) - user = UserSerializer(nested=True) allowed_ips = serializers.ListField( child=IPNetworkSerializer(), required=False, @@ -33,15 +31,11 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token fields = ( - 'id', 'url', 'display_url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', - 'description', 'allowed_ips', + 'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires', + 'last_used', 'write_enabled', 'pepper', 'allowed_ips', 'token', ) - brief_fields = ('id', 'url', 'display', 'key', 'write_enabled', 'description') - - def to_internal_value(self, data): - if not getattr(self.instance, 'key', None) and 'key' not in data: - data['key'] = Token.generate_key() - return super().to_internal_value(data) + read_only_fields = ('key',) + brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description') def validate(self, data): @@ -75,8 +69,8 @@ class TokenProvisionSerializer(TokenSerializer): class Meta: model = Token fields = ( - 'id', 'url', 'display_url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', - 'description', 'allowed_ips', 'username', 'password', + 'id', 'url', 'display_url', 'display', 'version', 'user', 'key', 'created', 'expires', 'last_used', 'key', + 'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token', ) def validate(self, data): diff --git a/netbox/users/choices.py b/netbox/users/choices.py new file mode 100644 index 000000000..547633c4e --- /dev/null +++ b/netbox/users/choices.py @@ -0,0 +1,17 @@ +from django.utils.translation import gettext_lazy as _ + +from utilities.choices import ChoiceSet + +__all__ = ( + 'TokenVersionChoices', +) + + +class TokenVersionChoices(ChoiceSet): + V1 = 1 + V2 = 2 + + CHOICES = [ + (V1, _('v1')), + (V2, _('v2')), + ] diff --git a/netbox/users/constants.py b/netbox/users/constants.py index e92623c82..b02c482e0 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -1,3 +1,5 @@ +import string + from django.db.models import Q @@ -7,3 +9,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( ) CONSTRAINT_TOKEN_USER = '$user' + +TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 4e1510410..3cbef8b1a 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -133,7 +133,7 @@ class TokenFilterSet(BaseFilterSet): class Meta: model = Token - fields = ('id', 'key', 'write_enabled', 'description', 'last_used') + fields = ('id', 'version', 'key', 'pepper', 'write_enabled', 'description', 'last_used') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index f478dedbf..bdda61a44 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -1,6 +1,7 @@ from django import forms from django.utils.translation import gettext as _ from users.models import * +from users.choices import TokenVersionChoices from utilities.forms import CSVModelForm @@ -34,12 +35,18 @@ class UserImportForm(CSVModelForm): class TokenImportForm(CSVModelForm): - key = forms.CharField( - label=_('Key'), + version = forms.ChoiceField( + choices=TokenVersionChoices, + initial=TokenVersionChoices.V2, required=False, - help_text=_("If no key is provided, one will be generated automatically.") + help_text=_("Specify version 1 or 2 (v2 will be used by default)") + ) + token = forms.CharField( + label=_('Token'), + required=False, + help_text=_("If no token is provided, one will be generated automatically.") ) class Meta: model = Token - fields = ('user', 'key', 'write_enabled', 'expires', 'description',) + fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 61e55949c..96f5a48d2 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin +from users.choices import TokenVersionChoices from users.models import Group, ObjectPermission, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField @@ -110,7 +111,11 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): model = Token fieldsets = ( FieldSet('q', 'filter_id',), - FieldSet('user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), + FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), + ) + version = forms.ChoiceField( + choices=TokenVersionChoices, + required=False, ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 4f4e2fd43..9b6c8aaba 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -12,6 +12,7 @@ from core.models import ObjectType from ipam.formfields import IPNetworkFormField from ipam.validators import prefix_validator from netbox.preferences import PREFERENCES +from users.choices import TokenVersionChoices from users.constants import * from users.models import * from utilities.data import flatten_dict @@ -115,10 +116,10 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): class UserTokenForm(forms.ModelForm): - key = forms.CharField( - label=_('Key'), + token = forms.CharField( + label=_('Token'), help_text=_( - 'Keys must be at least 40 characters in length. Be sure to record your key prior to ' + 'Tokens must be at least 40 characters in length. Be sure to record your key prior to ' 'submitting this form, as it may no longer be accessible once the token has been created.' ), widget=forms.TextInput( @@ -138,7 +139,7 @@ class UserTokenForm(forms.ModelForm): class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), @@ -147,13 +148,27 @@ class UserTokenForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Omit the key field if token retrieval is not permitted - if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: - del self.fields['key'] + if self.instance.pk: + # Disable the version & user fields for existing Tokens + self.fields['version'].disabled = True + self.fields['user'].disabled = True + + # Omit the key field when editing an existing token if token retrieval is not permitted + if self.instance.v1 and settings.ALLOW_TOKEN_RETRIEVAL: + self.fields['token'].initial = self.instance.key + else: + del self.fields['token'] # Generate an initial random key if none has been specified - if not self.instance.pk and not self.initial.get('key'): - self.initial['key'] = Token.generate_key() + if self.instance._state.adding and not self.initial.get('token'): + self.initial['version'] = TokenVersionChoices.V2 + self.initial['token'] = Token.generate() + + def save(self, commit=True): + if self.cleaned_data.get('token'): + self.instance.token = self.cleaned_data['token'] + + return super().save(commit=commit) class TokenForm(UserTokenForm): @@ -165,7 +180,7 @@ class TokenForm(UserTokenForm): class Meta: model = Token fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py new file mode 100644 index 000000000..9e18e4a72 --- /dev/null +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -0,0 +1,65 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0013_user_remove_is_staff'), + ] + + operations = [ + # Rename the original key field to "plaintext" + migrations.RenameField( + model_name='token', + old_name='key', + new_name='plaintext', + ), + migrations.RunSQL( + sql="ALTER INDEX IF EXISTS users_token_key_820deccd_like RENAME TO users_token_plaintext_46c6f315_like", + ), + migrations.RunSQL( + sql="ALTER INDEX IF EXISTS users_token_key_key RENAME TO users_token_plaintext_key", + ), + # Make plaintext (formerly key) nullable for v2 tokens + migrations.AlterField( + model_name='token', + name='plaintext', + field=models.CharField( + max_length=40, + unique=True, + blank=True, + null=True, + validators=[django.core.validators.MinLengthValidator(40)] + ), + ), + # Add version field to distinguish v1 and v2 tokens + migrations.AddField( + model_name='token', + name='version', + field=models.PositiveSmallIntegerField(default=1), # Mark all existing Tokens as v1 + preserve_default=False, + ), + # Change the default version for new tokens to v2 + migrations.AlterField( + model_name='token', + name='version', + field=models.PositiveSmallIntegerField(default=2), + ), + # Add new key, pepper, and hmac_digest fields for v2 tokens + migrations.AddField( + model_name='token', + name='key', + field=models.CharField(blank=True, max_length=16, null=True, unique=True), + ), + migrations.AddField( + model_name='token', + name='pepper', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='token', + name='hmac_digest', + field=models.CharField(blank=True, max_length=64, null=True), + ), + ] diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 3c1284bc9..cf35c4e6a 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,8 +1,12 @@ import binascii +import hashlib +import hmac +import random import os from django.conf import settings from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.urls import reverse @@ -11,6 +15,9 @@ from django.utils.translation import gettext_lazy as _ from netaddr import IPNetwork from ipam.fields import IPNetworkField +from users.choices import TokenVersionChoices +from users.constants import TOKEN_CHARSET +from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -23,11 +30,21 @@ class Token(models.Model): An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. """ + version = models.PositiveSmallIntegerField( + verbose_name=_('version'), + choices=TokenVersionChoices, + default=TokenVersionChoices.V2, + ) user = models.ForeignKey( to='users.User', on_delete=models.CASCADE, related_name='tokens' ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) created = models.DateTimeField( verbose_name=_('created'), auto_now_add=True @@ -42,21 +59,40 @@ class Token(models.Model): blank=True, null=True ) - key = models.CharField( - verbose_name=_('key'), - max_length=40, - unique=True, - validators=[MinLengthValidator(40)] - ) write_enabled = models.BooleanField( verbose_name=_('write enabled'), default=True, help_text=_('Permit create/update/delete operations using this key') ) - description = models.CharField( - verbose_name=_('description'), - max_length=200, - blank=True + # For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2. + plaintext = models.CharField( + verbose_name=_('plaintext'), + max_length=40, + unique=True, + blank=True, + null=True, + validators=[MinLengthValidator(40)], + ) + key = models.CharField( + verbose_name=_('key'), + max_length=16, + unique=True, + blank=True, + null=True, + help_text=_('v2 token identification key'), + ) + pepper = models.PositiveSmallIntegerField( + verbose_name=_('pepper'), + blank=True, + null=True, + help_text=_('ID of the cryptographic pepper used to hash the token (v2 only)'), + ) + hmac_digest = models.CharField( + verbose_name=_('digest'), + max_length=64, + blank=True, + null=True, + help_text=_('SHA256 hash of the token and pepper (v2 only)'), ) allowed_ips = ArrayField( base_field=IPNetworkField(), @@ -72,36 +108,108 @@ class Token(models.Model): objects = RestrictedQuerySet.as_manager() class Meta: + ordering = ('-created',) verbose_name = _('token') verbose_name_plural = _('tokens') - ordering = ('-created',) + + def __init__(self, *args, token=None, **kwargs): + super().__init__(*args, **kwargs) + + self.token = token def __str__(self): - return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + if self.v1: + return self.partial + return self.key def get_absolute_url(self): return reverse('users:token', args=[self.pk]) + @property + def v1(self): + return self.version == 1 + + @property + def v2(self): + return self.version == 2 + @property def partial(self): - return f'**********************************{self.key[-6:]}' if self.key else '' + return f'**********************************{self.plaintext[-6:]}' if self.plaintext else '' + + @property + def token(self): + return getattr(self, '_token', None) + + @token.setter + def token(self, value): + self._token = value + if value is not None: + if self.v1: + self.plaintext = value + elif self.v2: + self.key = self.key or self.generate(16) + self.update_digest() + + def clean(self): + if self._state.adding and self.v2 and not settings.API_TOKEN_PEPPERS: + raise ValidationError(_("Cannot create v2 tokens: API_TOKEN_PEPPERS is not defined.")) def save(self, *args, **kwargs): - if not self.key: - self.key = self.generate_key() + # If creating a new Token and no token value has been specified, generate one + if self._state.adding and self.token is None: + self.token = self.generate() + return super().save(*args, **kwargs) @staticmethod def generate_key(): - # Generate a random 160-bit key expressed in hexadecimal. + """ + DEPRECATED: Generate and return a random 160-bit key expressed in hexadecimal. + """ return binascii.hexlify(os.urandom(20)).decode() + @staticmethod + def generate(length=40): + """ + Generate and return a random token value of the given length. + """ + return ''.join(random.choice(TOKEN_CHARSET) for _ in range(length)) + + def update_digest(self): + """ + Recalculate and save the HMAC digest using the currently defined pepper and token values. + """ + self.pepper, pepper_value = get_current_pepper() + self.hmac_digest = hmac.new( + pepper_value.encode('utf-8'), + self.token.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + @property def is_expired(self): if self.expires is None or timezone.now() < self.expires: return False return True + def validate(self, token): + """ + Returns true if the given token value validates. + """ + if self.is_expired: + return False + if self.v1: + return token == self.key + if self.v2: + try: + pepper = settings.API_TOKEN_PEPPERS[self.pepper] + except KeyError: + # Invalid pepper ID + return False + digest = hmac.new(pepper.encode('utf-8'), token.encode('utf-8'), hashlib.sha256).hexdigest() + return digest == self.hmac_digest + def validate_client_ip(self, client_ip): """ Validate the API client IP address against the source IP restrictions (if any) set on the token. diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 40cbeca47..b8683cc87 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -22,7 +22,8 @@ class TokenTable(UserTokenTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'key', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', + 'pk', 'id', 'version', 'key', 'pepper', 'user', 'description', 'write_enabled', 'created', 'expires', + 'last_used', 'allowed_ips', ) diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 71496f007..f0218179a 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -197,7 +197,7 @@ class TokenTest( APIViewTestCases.DeleteObjectViewTestCase ): model = Token - brief_fields = ['description', 'display', 'id', 'key', 'url', 'write_enabled'] + brief_fields = ['description', 'display', 'id', 'key', 'url', 'version', 'write_enabled'] bulk_update_data = { 'description': 'New description', } @@ -256,8 +256,8 @@ class TokenTest( response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) - self.assertIn('key', response.data) - self.assertEqual(len(response.data['key']), 40) + self.assertIn('token', response.data) + self.assertEqual(len(response.data['token']), 40) self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['expires'], data['expires']) token = Token.objects.get(user=user) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index e15df0d18..f7404cedd 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -266,7 +266,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): class TokenTestCase(TestCase, BaseFilterSetTests): queryset = Token.objects.all() filterset = filtersets.TokenFilterSet - ignore_fields = ('allowed_ips',) + ignore_fields = ('plaintext', 'hmac_digest', 'allowed_ips') @classmethod def setUpTestData(cls): @@ -282,21 +282,39 @@ class TokenTestCase(TestCase, BaseFilterSetTests): past_date = make_aware(datetime.datetime(2000, 1, 1)) tokens = ( Token( - user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar1' + version=1, + user=users[0], + expires=future_date, + write_enabled=True, + description='foobar1', ), Token( - user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar2' + version=2, + user=users[1], + expires=future_date, + write_enabled=True, + description='foobar2', ), Token( - user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False + version=2, + user=users[2], + expires=past_date, + write_enabled=False, ), ) - Token.objects.bulk_create(tokens) + for token in tokens: + token.save() def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_version(self): + params = {'version': 1} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'version': 2} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_user(self): users = User.objects.order_by('id')[:2] params = {'user_id': [users[0].pk, users[1].pk]} @@ -313,7 +331,7 @@ class TokenTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_key(self): - tokens = Token.objects.all()[:2] + tokens = Token.objects.filter(version=2) params = {'key': [tokens[0].key, tokens[1].key]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index e66c00d0a..0395c2209 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -215,6 +215,7 @@ class TokenTestCase( ): model = Token maxDiff = None + validation_excluded_fields = ['token', 'user'] @classmethod def setUpTestData(cls): @@ -223,32 +224,34 @@ class TokenTestCase( create_test_user('User 2'), ) tokens = ( - Token(key='123456789012345678901234567890123456789A', user=users[0]), - Token(key='123456789012345678901234567890123456789B', user=users[0]), - Token(key='123456789012345678901234567890123456789C', user=users[1]), + Token(user=users[0]), + Token(user=users[0]), + Token(user=users[1]), ) - Token.objects.bulk_create(tokens) + for token in tokens: + token.save() cls.form_data = { + 'version': 2, + 'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5', 'user': users[0].pk, - 'key': '1234567890123456789012345678901234567890', - 'description': 'testdescription', + 'description': 'Test token', } cls.csv_data = ( - "key,user,description", - f"123456789012345678901234567890123456789D,{users[0].pk},testdescriptionD", - f"123456789012345678901234567890123456789E,{users[1].pk},testdescriptionE", - f"123456789012345678901234567890123456789F,{users[1].pk},testdescriptionF", + "token,user,description", + f"123456789012345678901234567890123456789A,{users[0].pk},Test token", + f"123456789012345678901234567890123456789B,{users[1].pk},Test token", + f"123456789012345678901234567890123456789C,{users[1].pk},Test token", ) cls.csv_update_data = ( "id,description", - f"{tokens[0].pk},testdescriptionH", - f"{tokens[1].pk},testdescriptionI", - f"{tokens[2].pk},testdescriptionJ", + f"{tokens[0].pk},New description", + f"{tokens[1].pk},New description", + f"{tokens[2].pk},New description", ) cls.bulk_edit_data = { - 'description': 'newdescription', + 'description': 'New description', } diff --git a/netbox/users/utils.py b/netbox/users/utils.py index 114d8ab6d..045d192c7 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -1,5 +1,12 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX +__all__ = ( + 'clean_username', + 'get_current_pepper', +) + def clean_username(value): """Clean username removing any unsupported character""" @@ -7,3 +14,13 @@ def clean_username(value): value = NO_SPECIAL_REGEX.sub('', value) value = value.replace(':', '') return value + + +def get_current_pepper(): + """ + Return the ID and value of the newest (highest ID) cryptographic pepper. + """ + if len(settings.API_TOKEN_PEPPERS) < 1: + raise ImproperlyConfigured("Must define API_TOKEN_PEPPERS to use v2 API tokens") + newest_id = sorted(settings.API_TOKEN_PEPPERS)[-1] + return newest_id, settings.API_TOKEN_PEPPERS[newest_id] diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 1fe881367..32e5fe53f 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -49,8 +49,8 @@ class APITestCase(ModelTestCase): # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'} + self.token = Token.objects.create(version=1, user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.plaintext}'} def _get_view_namespace(self): return f'{self.view_namespace or self.model._meta.app_label}-api' @@ -153,6 +153,7 @@ class APIViewTestCases: url = f'{self._get_list_url()}?brief=1' response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), self._get_queryset().count()) self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index f00b21d08..c054dc5a2 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -240,10 +240,12 @@ class ViewTestCases: :form_data: Data to be used when updating the first existing object. """ form_data = {} + form_edit_data = {} validation_excluded_fields = [] def test_edit_object_without_permission(self): instance = self._get_queryset().first() + form_data = self.form_edit_data or self.form_data # Try GET without permission with disable_warnings('django.request'): @@ -252,7 +254,7 @@ class ViewTestCases: # Try POST without permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) @@ -260,6 +262,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_permission(self): instance = self._get_queryset().first() + form_data = self.form_edit_data or self.form_data # Assign model-level permission obj_perm = ObjectPermission( @@ -275,21 +278,21 @@ class ViewTestCases: # Add custom field data if the model supports it if issubclass(self.model, CustomFieldsMixin): - add_custom_field_data(self.form_data, self.model) + add_custom_field_data(form_data, self.model) # If supported, add a changelog message if issubclass(self.model, ChangeLoggingMixin): - if 'changelog_message' not in self.form_data: - self.form_data['changelog_message'] = get_random_string(10) + if 'changelog_message' not in form_data: + form_data['changelog_message'] = get_random_string(10) # Try POST with model-level permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance.pk) - self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation if issubclass(self.model, ChangeLoggingMixin): @@ -299,11 +302,12 @@ class ViewTestCases: ) self.assertEqual(len(objectchanges), 1) self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(objectchanges[0].message, self.form_data['changelog_message']) + self.assertEqual(objectchanges[0].message, form_data['changelog_message']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] + form_data = self.form_edit_data or self.form_data # Assign constrained permission obj_perm = ObjectPermission( @@ -324,16 +328,16 @@ class ViewTestCases: # Try to edit a permitted object request = { 'path': self._get_url('edit', instance1), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance1.pk) - self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) # Try to edit a non-permitted object request = { 'path': self._get_url('edit', instance2), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } self.assertHttpStatus(self.client.post(**request), 404) From 5dc48f3a8895f7c1e5a359d0075bd309e89dd248 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:26:22 -0400 Subject: [PATCH 02/16] Enforce a fixed key length for v2 tokens --- netbox/users/constants.py | 3 +++ .../users/migrations/0014_users_token_v2.py | 12 +++++++++++- netbox/users/models/tokens.py | 19 +++++++++---------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/netbox/users/constants.py b/netbox/users/constants.py index b02c482e0..647249179 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -10,4 +10,7 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( CONSTRAINT_TOKEN_USER = '$user' +# API tokens +TOKEN_KEY_LENGTH = 16 +TOKEN_DEFAULT_LENGTH = 40 TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 9e18e4a72..39f1cbf48 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -21,6 +21,7 @@ class Migration(migrations.Migration): migrations.RunSQL( sql="ALTER INDEX IF EXISTS users_token_key_key RENAME TO users_token_plaintext_key", ), + # Make plaintext (formerly key) nullable for v2 tokens migrations.AlterField( model_name='token', @@ -33,6 +34,7 @@ class Migration(migrations.Migration): validators=[django.core.validators.MinLengthValidator(40)] ), ), + # Add version field to distinguish v1 and v2 tokens migrations.AddField( model_name='token', @@ -40,17 +42,25 @@ class Migration(migrations.Migration): field=models.PositiveSmallIntegerField(default=1), # Mark all existing Tokens as v1 preserve_default=False, ), + # Change the default version for new tokens to v2 migrations.AlterField( model_name='token', name='version', field=models.PositiveSmallIntegerField(default=2), ), + # Add new key, pepper, and hmac_digest fields for v2 tokens migrations.AddField( model_name='token', name='key', - field=models.CharField(blank=True, max_length=16, null=True, unique=True), + field=models.CharField( + blank=True, + max_length=16, + null=True, + unique=True, + validators=[django.core.validators.MinLengthValidator(16)] + ), ), migrations.AddField( model_name='token', diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index cf35c4e6a..96aa8e821 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,8 +1,6 @@ -import binascii import hashlib import hmac import random -import os from django.conf import settings from django.contrib.postgres.fields import ArrayField @@ -16,7 +14,7 @@ from netaddr import IPNetwork from ipam.fields import IPNetworkField from users.choices import TokenVersionChoices -from users.constants import TOKEN_CHARSET +from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet @@ -75,10 +73,11 @@ class Token(models.Model): ) key = models.CharField( verbose_name=_('key'), - max_length=16, + max_length=TOKEN_KEY_LENGTH, unique=True, blank=True, null=True, + validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], help_text=_('v2 token identification key'), ) pepper = models.PositiveSmallIntegerField( @@ -148,7 +147,7 @@ class Token(models.Model): if self.v1: self.plaintext = value elif self.v2: - self.key = self.key or self.generate(16) + self.key = self.key or self.generate_key() self.update_digest() def clean(self): @@ -162,15 +161,15 @@ class Token(models.Model): return super().save(*args, **kwargs) - @staticmethod - def generate_key(): + @classmethod + def generate_key(cls): """ - DEPRECATED: Generate and return a random 160-bit key expressed in hexadecimal. + Generate and return a random alphanumeric key for v2 tokens. """ - return binascii.hexlify(os.urandom(20)).decode() + return cls.generate(length=TOKEN_KEY_LENGTH) @staticmethod - def generate(length=40): + def generate(length=TOKEN_DEFAULT_LENGTH): """ Generate and return a random token value of the given length. """ From 11099b01bb3ba4ed80736afac6fab62c5ca31cf7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:38:17 -0400 Subject: [PATCH 03/16] Rename pepper field to pepper_id for clarity --- contrib/openapi.json | 20 +++++++++---------- netbox/account/tables.py | 4 ++-- netbox/templates/users/token.html | 4 ++-- netbox/users/api/serializers_/tokens.py | 2 +- netbox/users/filtersets.py | 2 +- .../users/migrations/0014_users_token_v2.py | 2 +- netbox/users/models/tokens.py | 8 ++++---- netbox/users/tables.py | 2 +- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index 3618a36af..7fda93ac5 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166113,7 +166113,7 @@ }, { "in": "query", - "name": "pepper", + "name": "pepper_id", "schema": { "type": "array", "items": { @@ -166126,14 +166126,14 @@ }, { "in": "query", - "name": "pepper__empty", + "name": "pepper_id__empty", "schema": { "type": "boolean" } }, { "in": "query", - "name": "pepper__gt", + "name": "pepper_id__gt", "schema": { "type": "array", "items": { @@ -166146,7 +166146,7 @@ }, { "in": "query", - "name": "pepper__gte", + "name": "pepper_id__gte", "schema": { "type": "array", "items": { @@ -166159,7 +166159,7 @@ }, { "in": "query", - "name": "pepper__lt", + "name": "pepper_id__lt", "schema": { "type": "array", "items": { @@ -166172,7 +166172,7 @@ }, { "in": "query", - "name": "pepper__lte", + "name": "pepper_id__lte", "schema": { "type": "array", "items": { @@ -166185,7 +166185,7 @@ }, { "in": "query", - "name": "pepper__n", + "name": "pepper_id__n", "schema": { "type": "array", "items": { @@ -228205,7 +228205,7 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "pepper": { + "pepper_id": { "type": "integer", "maximum": 32767, "minimum": 0, @@ -244459,7 +244459,7 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "pepper": { + "pepper_id": { "type": "integer", "maximum": 32767, "minimum": 0, @@ -244654,7 +244654,7 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "pepper": { + "pepper_id": { "type": "integer", "maximum": 32767, "minimum": 0, diff --git a/netbox/account/tables.py b/netbox/account/tables.py index 0b15a8a13..02dce8bdc 100644 --- a/netbox/account/tables.py +++ b/netbox/account/tables.py @@ -53,6 +53,6 @@ class UserTokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = UserToken fields = ( - 'pk', 'id', 'version', 'key', 'pepper', 'description', 'write_enabled', 'created', 'expires', 'last_used', - 'allowed_ips', + 'pk', 'id', 'version', 'key', 'pepper_id', 'description', 'write_enabled', 'created', 'expires', + 'last_used', 'allowed_ips', ) diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 039d02759..86e96a6f3 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -28,8 +28,8 @@ {{ object }}
{% trans "Pepper" %}{{ object.pepper }}{% trans "Pepper ID" %}{{ object.pepper_id }}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Key" %} - {% if key %} -
- {% copy_content "token_id" %} -
-
{{ key }}
- {% else %} - {{ object.partial }} - {% endif %} -
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Write enabled" %}{% checkmark object.write_enabled %}
{% trans "Created" %}{{ object.created|isodatetime }}
{% trans "Expires" %}{{ object.expires|isodatetime|placeholder }}
{% trans "Last used" %}{{ object.last_used|isodatetime|placeholder }}
{% trans "Allowed IPs" %}{{ object.allowed_ips|join:", "|placeholder }}
- - - -{% endblock %} diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 86e96a6f3..b3eb80b87 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -20,7 +20,14 @@ {% if object.version == 1 %} {% trans "Token" %} - {{ object.partial }} + + {% if settings.ALLOW_TOKEN_RETRIEVAL %} + {{ object.plaintext }} + + {% else %} + {{ object.partial }} + {% endif %} + {% else %} From d69042f26e31bcc6832aac2e6090f1b2e2cc0090 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 09:53:44 -0400 Subject: [PATCH 08/16] Clean up token tables --- netbox/account/tables.py | 58 --------------------------------- netbox/account/views.py | 6 ++-- netbox/netbox/tables/columns.py | 2 +- netbox/users/tables.py | 39 ++++++++++++++++++++-- 4 files changed, 41 insertions(+), 64 deletions(-) delete mode 100644 netbox/account/tables.py diff --git a/netbox/account/tables.py b/netbox/account/tables.py deleted file mode 100644 index 02dce8bdc..000000000 --- a/netbox/account/tables.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.utils.translation import gettext as _ - -from account.models import UserToken -from netbox.tables import NetBoxTable, columns - -__all__ = ( - 'UserTokenTable', -) - - -TOKEN = """{{ record }}""" - -ALLOWED_IPS = """{{ value|join:", " }}""" - -COPY_BUTTON = """ -{% if settings.ALLOW_TOKEN_RETRIEVAL %} - {% copy_content record.pk prefix="token_" color="success" %} -{% endif %} -""" - - -class UserTokenTable(NetBoxTable): - """ - Table for users to manager their own API tokens under account views. - """ - key = columns.TemplateColumn( - verbose_name=_('Key'), - template_code=TOKEN, - ) - write_enabled = columns.BooleanColumn( - verbose_name=_('Write Enabled') - ) - created = columns.DateTimeColumn( - timespec='minutes', - verbose_name=_('Created'), - ) - expires = columns.DateTimeColumn( - timespec='minutes', - verbose_name=_('Expires'), - ) - last_used = columns.DateTimeColumn( - verbose_name=_('Last Used'), - ) - allowed_ips = columns.TemplateColumn( - verbose_name=_('Allowed IPs'), - template_code=ALLOWED_IPS - ) - actions = columns.ActionsColumn( - actions=('edit', 'delete'), - extra_buttons=COPY_BUTTON - ) - - class Meta(NetBoxTable.Meta): - model = UserToken - fields = ( - 'pk', 'id', 'version', 'key', 'pepper_id', 'description', 'write_enabled', 'created', 'expires', - 'last_used', 'allowed_ips', - ) diff --git a/netbox/account/views.py b/netbox/account/views.py index b513f04e4..2b1d64fe3 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -26,8 +26,9 @@ from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from netbox.views import generic -from users import forms, tables +from users import forms from users.models import UserConfig +from users.tables import TokenTable from utilities.request import safe_for_redirect from utilities.string import remove_linebreaks from utilities.views import register_model_view @@ -328,7 +329,8 @@ class UserTokenListView(LoginRequiredMixin, View): def get(self, request): tokens = UserToken.objects.filter(user=request.user) - table = tables.UserTokenTable(tokens) + table = TokenTable(tokens) + table.columns.hide('user') table.configure(request) return render(request, 'account/token_list.html', { diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index f480c2085..12b781cf4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -270,7 +270,7 @@ class ActionsColumn(tables.Column): if not (self.actions or self.extra_buttons): return '' # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs) - if type(record) is not model or not getattr(record, 'pk', None): + if not isinstance(record, model) or not getattr(record, 'pk', None): return '' if request := getattr(table, 'context', {}).get('request'): diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 249803840..c4b561164 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -1,7 +1,6 @@ import django_tables2 as tables from django.utils.translation import gettext as _ -from account.tables import UserTokenTable from netbox.tables import NetBoxTable, columns from users.models import Group, ObjectPermission, Token, User @@ -12,19 +11,53 @@ __all__ = ( 'UserTable', ) +TOKEN = """{{ record }}""" -class TokenTable(UserTokenTable): +COPY_BUTTON = """ +{% if settings.ALLOW_TOKEN_RETRIEVAL %} + {% copy_content record.pk prefix="token_" color="success" %} +{% endif %} +""" + + +class TokenTable(NetBoxTable): user = tables.Column( linkify=True, verbose_name=_('User') ) + token = columns.TemplateColumn( + verbose_name=_('token'), + template_code=TOKEN, + ) + write_enabled = columns.BooleanColumn( + verbose_name=_('Write Enabled') + ) + created = columns.DateTimeColumn( + timespec='minutes', + verbose_name=_('Created'), + ) + expires = columns.DateTimeColumn( + timespec='minutes', + verbose_name=_('Expires'), + ) + last_used = columns.DateTimeColumn( + verbose_name=_('Last Used'), + ) + allowed_ips = columns.ArrayColumn( + verbose_name=_('Allowed IPs'), + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'version', 'key', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', + 'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) + default_columns = ('token', 'version', 'user', 'write_enabled', 'description', 'allowed_ips') class UserTable(NetBoxTable): From a54c508da29c5532b3cf4299eee9746f9e987114 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 09:58:20 -0400 Subject: [PATCH 09/16] Misc cleanup --- netbox/account/views.py | 2 -- netbox/users/tables.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/account/views.py b/netbox/account/views.py index 2b1d64fe3..da4aa6d74 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -345,11 +345,9 @@ class UserTokenView(LoginRequiredMixin, View): def get(self, request, pk): token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - key = token.key if token.v2 or settings.ALLOW_TOKEN_RETRIEVAL else None return render(request, 'account/token.html', { 'object': token, - 'key': key, }) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index c4b561164..c5207d899 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -11,7 +11,7 @@ __all__ = ( 'UserTable', ) -TOKEN = """{{ record }}""" +TOKEN = """{{ record }}""" COPY_BUTTON = """ {% if settings.ALLOW_TOKEN_RETRIEVAL %} From ac335c3d879f33a7197edefb63930667919350f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 10:23:00 -0400 Subject: [PATCH 10/16] Clean up filterset tests --- netbox/netbox/configuration_testing.py | 2 +- netbox/users/filtersets.py | 14 +++++++++++++- netbox/users/tests/test_filtersets.py | 14 +++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index bbe6dbada..36f9d7338 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -46,7 +46,7 @@ DEFAULT_PERMISSIONS = {} ALLOW_TOKEN_RETRIEVAL = True API_TOKEN_PEPPERS = { - 0: 'TEST-VALUE-DO-NOT-USE', + 1: 'TEST-VALUE-DO-NOT-USE', } LOGGING = { diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index a67761354..36fbdcb0d 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -130,15 +130,27 @@ class TokenFilterSet(BaseFilterSet): field_name='expires', lookup_expr='lte' ) + last_used = django_filters.DateTimeFilter() + last_used__gte = django_filters.DateTimeFilter( + field_name='last_used', + lookup_expr='gte' + ) + last_used__lte = django_filters.DateTimeFilter( + field_name='last_used', + lookup_expr='lte' + ) class Meta: model = Token - fields = ('id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'last_used') + fields = ( + 'id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'created', 'expires', 'last_used', + ) def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( + Q(key=value) | Q(user__username__icontains=value) | Q(description__icontains=value) ) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index f7404cedd..1f7336cc3 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -315,6 +315,15 @@ class TokenTestCase(TestCase, BaseFilterSetTests): params = {'version': 2} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_key(self): + tokens = Token.objects.filter(version=2) + params = {'key': [tokens[0].key, tokens[1].key]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_pepper_id(self): + params = {'pepper_id': [1]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_user(self): users = User.objects.order_by('id')[:2] params = {'user_id': [users[0].pk, users[1].pk]} @@ -330,11 +339,6 @@ class TokenTestCase(TestCase, BaseFilterSetTests): params = {'expires__lte': '2021-01-01T00:00:00'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_key(self): - tokens = Token.objects.filter(version=2) - params = {'key': [tokens[0].key, tokens[1].key]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_write_enabled(self): params = {'write_enabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From 6388705e57a94be300e7970bc6070ba03ed5f347 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 10:45:54 -0400 Subject: [PATCH 11/16] Clean up TokenForm --- netbox/users/forms/filtersets.py | 3 ++- netbox/users/forms/model_forms.py | 18 +++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 96f5a48d2..32e52b5f9 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -8,6 +8,7 @@ from users.models import Group, ObjectPermission, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet +from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import DateTimePicker __all__ = ( @@ -114,7 +115,7 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), ) version = forms.ChoiceField( - choices=TokenVersionChoices, + choices=add_blank_choice(TokenVersionChoices), required=False, ) user_id = DynamicModelMultipleChoiceField( diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 9b6c8aaba..582062ebb 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -16,11 +16,7 @@ from users.choices import TokenVersionChoices from users.constants import * from users.models import * from utilities.data import flatten_dict -from utilities.forms.fields import ( - ContentTypeMultipleChoiceField, - DynamicModelMultipleChoiceField, - JSONField, -) +from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints @@ -155,17 +151,17 @@ class UserTokenForm(forms.ModelForm): # Omit the key field when editing an existing token if token retrieval is not permitted if self.instance.v1 and settings.ALLOW_TOKEN_RETRIEVAL: - self.fields['token'].initial = self.instance.key + self.initial['token'] = self.instance.plaintext else: del self.fields['token'] # Generate an initial random key if none has been specified - if self.instance._state.adding and not self.initial.get('token'): + elif self.instance._state.adding and not self.initial.get('token'): self.initial['version'] = TokenVersionChoices.V2 self.initial['token'] = Token.generate() def save(self, commit=True): - if self.cleaned_data.get('token'): + if self.instance._state.adding and self.cleaned_data.get('token'): self.instance.token = self.cleaned_data['token'] return super().save(commit=commit) @@ -177,14 +173,10 @@ class TokenForm(UserTokenForm): label=_('User') ) - class Meta: - model = Token + class Meta(UserTokenForm.Meta): fields = [ 'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips', ] - widgets = { - 'expires': DateTimePicker(), - } class UserForm(forms.ModelForm): From 917a2c261873b1fedcc3d59a45553a67abc10645 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 11:41:04 -0400 Subject: [PATCH 12/16] Validate peppers on init --- netbox/netbox/configuration_example.py | 10 ++++++++++ netbox/netbox/configuration_testing.py | 2 +- netbox/netbox/settings.py | 8 ++++---- netbox/utilities/security.py | 24 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 netbox/utilities/security.py diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 612f75a40..18d30d29a 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -68,6 +68,16 @@ REDIS = { # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY SECRET_KEY = '' +# Define a mapping of cryptographic peppers to use when hashing API tokens. A minimum of one pepper is required to +# enable v2 API tokens (NetBox v4.5+). Define peppers as a mapping of numeric ID to pepper value, as shown below. Each +# pepper must be at least 50 characters in length. +# +# API_TOKEN_PEPPERS = { +# 1: "", +# 2: "", +# } +API_TOKEN_PEPPERS = {} + ######################### # # diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 36f9d7338..6d1de2008 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -46,7 +46,7 @@ DEFAULT_PERMISSIONS = {} ALLOW_TOKEN_RETRIEVAL = True API_TOKEN_PEPPERS = { - 1: 'TEST-VALUE-DO-NOT-USE', + 1: 'TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE', } LOGGING = { diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a912c2d6e..828f73109 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,6 +19,7 @@ from netbox.plugins import PluginConfig from netbox.registry import registry import storages.utils # type: ignore from utilities.release import load_release_data +from utilities.security import validate_peppers from utilities.string import trailing_slash # @@ -217,10 +218,9 @@ if len(SECRET_KEY) < 50: ) # Validate API token peppers -for key in API_TOKEN_PEPPERS: - if type(key) is not int: - raise ImproperlyConfigured(f"Invalid API_TOKEN_PEPPERS key: {key}. All keys must be integers.") -if not API_TOKEN_PEPPERS: +if API_TOKEN_PEPPERS: + validate_peppers(API_TOKEN_PEPPERS) +else: warnings.warn("API_TOKEN_PEPPERS is not defined. v2 API tokens cannot be used.") # Validate update repo URL and timeout diff --git a/netbox/utilities/security.py b/netbox/utilities/security.py new file mode 100644 index 000000000..47a18d265 --- /dev/null +++ b/netbox/utilities/security.py @@ -0,0 +1,24 @@ +from django.core.exceptions import ImproperlyConfigured + +__all__ = ( + 'validate_peppers', +) + + +def validate_peppers(peppers): + """ + Validate the given dictionary of cryptographic peppers for type & sufficient length. + """ + if type(peppers) is not dict: + raise ImproperlyConfigured("API_TOKEN_PEPPERS must be a dictionary.") + for key, pepper in peppers.items(): + if type(key) is not int: + raise ImproperlyConfigured(f"Invalid API_TOKEN_PEPPERS key: {key}. All keys must be integers.") + if not 0 <= key <= 32767: + raise ImproperlyConfigured( + f"Invalid API_TOKEN_PEPPERS key: {key}. Key values must be between 0 and 32767, inclusive." + ) + if type(pepper) is not str: + raise ImproperlyConfigured(f"Invalid pepper {key}: Pepper value must be a string.") + if len(pepper) < 50: + raise ImproperlyConfigured(f"Invalid pepper {key}: Pepper must be at least 50 characters in length.") From 9b85d92ad0e066ee9b633415a38b04861fadf71c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 12:08:24 -0400 Subject: [PATCH 13/16] Clean up auth backend --- contrib/openapi.json | 79 ++++------------------------- netbox/netbox/api/authentication.py | 33 ++++++++---- netbox/users/utils.py | 5 +- 3 files changed, 34 insertions(+), 83 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index 7fda93ac5..810f9936d 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166003,86 +166003,25 @@ "in": "query", "name": "last_used", "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "last_used__empty", - "schema": { - "type": "boolean" + "type": "string", + "format": "date-time" } }, - { - "in": "query", - "name": "last_used__gt", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, { "in": "query", "name": "last_used__gte", "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "last_used__lt", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" + "type": "string", + "format": "date-time" + } }, { "in": "query", "name": "last_used__lte", "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "last_used__n", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" + "type": "string", + "format": "date-time" + } }, { "name": "limit", @@ -256896,7 +256835,7 @@ "type": "apiKey", "in": "header", "name": "Authorization", - "description": "Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header" + "description": "`Token ` (v1) or `Bearer .` (v2)" } } }, diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 9c73259bf..27247169a 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -11,8 +11,8 @@ from netbox.config import get_config from users.models import Token from utilities.request import get_client_ip -V1_KEYWORD = 'token' -V2_KEYWORD = 'bearer' +V1_KEYWORD = 'Token' +V2_KEYWORD = 'Bearer' class TokenAuthentication(BaseAuthentication): @@ -22,26 +22,37 @@ class TokenAuthentication(BaseAuthentication): model = Token def authenticate(self, request): + # Ignore; Authorization header is not present if not (auth := get_authorization_header(request).split()): return - # Check for Token/Bearer keyword in HTTP header value & infer token version + # Infer token version from Token/Bearer keyword in HTTP header if auth[0].lower() == V1_KEYWORD.lower().encode(): version = 1 elif auth[0].lower() == V2_KEYWORD.lower().encode(): version = 2 else: + # Ignore; unrecognized header value return - # Extract token key from authorization header + # Extract token from authorization header. This should be in one of the following two forms: + # * Authorization: Token (v1) + # * Authorization: Bearer . (v2) if len(auth) != 2: - raise exceptions.AuthenticationFailed("Invalid authorization header: Error parsing token") + if version == 1: + raise exceptions.AuthenticationFailed( + 'Invalid authorization header: Must be in the form "Token "' + ) + else: + raise exceptions.AuthenticationFailed( + 'Invalid authorization header: Must be in the form "Bearer ."' + ) + + # Extract the key (if v2) & token plaintext from the auth header try: auth_value = auth[1].decode() except UnicodeError: raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") - - # Look for a matching token in the database if version == 1: key, plaintext = None, auth_value else: @@ -52,6 +63,8 @@ class TokenAuthentication(BaseAuthentication): "Invalid authorization header: Could not parse key from v2 token. Did you mean to use 'Token' " "instead of 'Bearer'?" ) + + # Look for a matching token in the database try: qs = Token.objects.prefetch_related('user') if version == 1: @@ -61,8 +74,8 @@ class TokenAuthentication(BaseAuthentication): # Fetch v2 token by key, then validate the plaintext token = qs.get(version=version, key=key) if not token.validate(plaintext): - # TODO: Consider security implications of enabling validation of token key without valid plaintext - raise exceptions.AuthenticationFailed(f"Validation failed for v2 token {key}") + # Key is valid but plaintext is not. Raise DoesNotExist to guard against key enumeration. + raise Token.DoesNotExist() except Token.DoesNotExist: raise exceptions.AuthenticationFailed(f"Invalid v{version} token") @@ -180,5 +193,5 @@ class TokenScheme(OpenApiAuthenticationExtension): 'type': 'apiKey', 'in': 'header', 'name': 'Authorization', - 'description': 'Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header', + 'description': '`Token ` (v1) or `Bearer .` (v2)', } diff --git a/netbox/users/utils.py b/netbox/users/utils.py index 5db8cb65e..c355873a8 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -1,5 +1,4 @@ from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX __all__ = ( @@ -20,7 +19,7 @@ def get_current_pepper(): """ Return the ID and value of the newest (highest ID) cryptographic pepper. """ - if len(settings.API_TOKEN_PEPPERS) < 1: - raise ImproperlyConfigured("Must define API_TOKEN_PEPPERS to use v2 API tokens") + if not settings.API_TOKEN_PEPPERS: + raise ValueError("API_TOKEN_PEPPERS is not defined") newest_id = sorted(settings.API_TOKEN_PEPPERS.keys())[-1] return newest_id, settings.API_TOKEN_PEPPERS[newest_id] From bb75bceec54560000a62815dbe4616fb0c10eeac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 13:55:48 -0400 Subject: [PATCH 14/16] Clean up tests --- netbox/netbox/tests/test_authentication.py | 40 +++++++++++++++------- netbox/users/tests/test_api.py | 3 +- netbox/users/tests/test_views.py | 6 ++-- netbox/utilities/testing/views.py | 24 ++++++------- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index bd7d41186..e33e72f5d 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -94,30 +94,46 @@ class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_write_enabled(self): url = reverse('dcim-api:site-list') - data = { - 'name': 'Site 1', - 'slug': 'site-1', - } + data = [ + { + 'name': 'Site 1', + 'slug': 'site-1', + }, + { + 'name': 'Site 2', + 'slug': 'site-2', + }, + ] + self.add_permissions('dcim.view_site', 'dcim.add_site') # Create v1 & v2 tokens token1 = Token.objects.create(version=1, user=self.user, write_enabled=False) token2 = Token.objects.create(version=2, user=self.user, write_enabled=False) - # Request with a write-disabled token should fail - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') + token1_header = f'Token {token1.token}' + token2_header = f'Bearer {token2.key}.{token2.token}' + + # GET request with a write-disabled token should succeed + response = self.client.get(url, HTTP_AUTHORIZATION=token1_header) + self.assertEqual(response.status_code, 200) + response = self.client.get(url, HTTP_AUTHORIZATION=token2_header) + self.assertEqual(response.status_code, 200) + + # POST request with a write-disabled token should fail + response = self.client.post(url, data[0], format='json', HTTP_AUTHORIZATION=token1_header) self.assertEqual(response.status_code, 403) - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') + response = self.client.post(url, data[1], format='json', HTTP_AUTHORIZATION=token2_header) self.assertEqual(response.status_code, 403) - # Request with a write-enabled token should succeed + # POST request with a write-enabled token should succeed token1.write_enabled = True token1.save() token2.write_enabled = True token2.save() - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') - self.assertEqual(response.status_code, 403) - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') - self.assertEqual(response.status_code, 403) + response = self.client.post(url, data[0], format='json', HTTP_AUTHORIZATION=token1_header) + self.assertEqual(response.status_code, 201) + response = self.client.post(url, data[1], format='json', HTTP_AUTHORIZATION=token2_header) + self.assertEqual(response.status_code, 201) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_allowed_ips(self): diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index f0218179a..741c578b6 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -2,6 +2,7 @@ from django.test import override_settings from django.urls import reverse from core.models import ObjectType +from users.constants import TOKEN_DEFAULT_LENGTH from users.models import Group, ObjectPermission, Token, User from utilities.data import deepmerge from utilities.testing import APIViewTestCases, APITestCase, create_test_user @@ -257,7 +258,7 @@ class TokenTest( response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) self.assertIn('token', response.data) - self.assertEqual(len(response.data['token']), 40) + self.assertEqual(len(response.data['token']), TOKEN_DEFAULT_LENGTH) self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['expires'], data['expires']) token = Token.objects.get(user=user) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 0395c2209..24aec6941 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -240,9 +240,9 @@ class TokenTestCase( cls.csv_data = ( "token,user,description", - f"123456789012345678901234567890123456789A,{users[0].pk},Test token", - f"123456789012345678901234567890123456789B,{users[1].pk},Test token", - f"123456789012345678901234567890123456789C,{users[1].pk},Test token", + f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token", + f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token", + f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token", ) cls.csv_update_data = ( diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index c054dc5a2..f00b21d08 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -240,12 +240,10 @@ class ViewTestCases: :form_data: Data to be used when updating the first existing object. """ form_data = {} - form_edit_data = {} validation_excluded_fields = [] def test_edit_object_without_permission(self): instance = self._get_queryset().first() - form_data = self.form_edit_data or self.form_data # Try GET without permission with disable_warnings('django.request'): @@ -254,7 +252,7 @@ class ViewTestCases: # Try POST without permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) @@ -262,7 +260,6 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_permission(self): instance = self._get_queryset().first() - form_data = self.form_edit_data or self.form_data # Assign model-level permission obj_perm = ObjectPermission( @@ -278,21 +275,21 @@ class ViewTestCases: # Add custom field data if the model supports it if issubclass(self.model, CustomFieldsMixin): - add_custom_field_data(form_data, self.model) + add_custom_field_data(self.form_data, self.model) # If supported, add a changelog message if issubclass(self.model, ChangeLoggingMixin): - if 'changelog_message' not in form_data: - form_data['changelog_message'] = get_random_string(10) + if 'changelog_message' not in self.form_data: + self.form_data['changelog_message'] = get_random_string(10) # Try POST with model-level permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance.pk) - self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation if issubclass(self.model, ChangeLoggingMixin): @@ -302,12 +299,11 @@ class ViewTestCases: ) self.assertEqual(len(objectchanges), 1) self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(objectchanges[0].message, form_data['changelog_message']) + self.assertEqual(objectchanges[0].message, self.form_data['changelog_message']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] - form_data = self.form_edit_data or self.form_data # Assign constrained permission obj_perm = ObjectPermission( @@ -328,16 +324,16 @@ class ViewTestCases: # Try to edit a permitted object request = { 'path': self._get_url('edit', instance1), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance1.pk) - self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Try to edit a non-permitted object request = { 'path': self._get_url('edit', instance2), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 404) From 82db8a9c02b18ca8a73e8982ce3653d94f7fc92e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 14:24:21 -0400 Subject: [PATCH 15/16] Update documentation --- docs/configuration/required-parameters.md | 25 +++++++++++++++++++++++ docs/installation/3-netbox.md | 17 +++++++++++++++ docs/integrations/rest-api.md | 14 ++++++------- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 19222740d..cced030b1 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -23,6 +23,31 @@ ALLOWED_HOSTS = ['*'] --- +## API_TOKEN_PEPPERS + +!!! info "This parameter was introduced in NetBox v4.5." + +[Cryptographic peppers](https://en.wikipedia.org/wiki/Pepper_(cryptography)) are employed to generate hashes of sensitive values on the server. This parameter defines the peppers used to hash v2 API tokens in NetBox. You must define at least one pepper before creating a v2 API token. See the [API documentation](../integrations/rest-api.md#authentication) for further information about how peppers are used. + +```python +API_TOKEN_PEPPERS = { + # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION + 1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_', +} +``` + +!!! warning "Peppers are sensitive" + Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible. + +Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value. + +It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes. + +!!! tip + Although NetBox will run without `API_TOKEN_PEPPERS` defined, the use of v2 API tokens will be unavailable. + +--- + ## DATABASE !!! warning "Legacy Configuration Parameter" diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index c192a3094..fd9b21f50 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -120,6 +120,23 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins ALLOWED_HOSTS = ['*'] ``` +### API_TOKEN_PEPPERS + +Define at least one random cryptographic pepper, identified by a numeric ID starting at 1. This will be used to generate SHA256 checksums for API tokens. + +```python +API_TOKEN_PEPPERS = { + # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION + 1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_', +} +``` + +!!! tip + As with [`SECRET_KEY`](#secret_key) below, you can use the `generate_secret_key.py` script to generate a random pepper: + ```no-highlight + python3 ../generate_secret_key.py + ``` + ### DATABASES This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins. diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 9cecbca3d..6bc329d78 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -653,19 +653,17 @@ The NetBox REST API primarily employs token-based authentication. For convenienc ### Tokens -A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. +A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value. By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. -!!! info "Token Versions" - Beginning with NetBox v4.5, two types of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens, as these provide much stronger security than v1 tokens. Support for v1 tokens will be removed in a future NetBox release. - -When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. - Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. -!!! info "Restricting Token Retrieval" - The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. +#### v1 and v2 Tokens + +Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release. + +v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved. #### Restricting Write Operations From c63e60a62b66ff0437c837741d9bb0047712bf4c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Oct 2025 17:04:10 -0400 Subject: [PATCH 16/16] Add a token prefix --- contrib/openapi.json | 8 ++--- docs/features/api-integration.md | 2 +- docs/integrations/rest-api.md | 8 ++--- netbox/core/tests/test_api.py | 3 +- netbox/netbox/api/authentication.py | 35 +++++++------------ netbox/netbox/tests/test_authentication.py | 17 ++++----- netbox/users/constants.py | 3 +- .../users/migrations/0014_users_token_v2.py | 4 +-- netbox/users/models/tokens.py | 3 +- netbox/utilities/testing/api.py | 3 +- 10 files changed, 41 insertions(+), 45 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index 810f9936d..f78bc4064 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -213929,7 +213929,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -214038,7 +214038,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -231032,7 +231032,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } } }, @@ -251418,7 +251418,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ diff --git a/docs/features/api-integration.md b/docs/features/api-integration.md index 94a39d731..28aefda92 100644 --- a/docs/features/api-integration.md +++ b/docs/features/api-integration.md @@ -8,7 +8,7 @@ NetBox's REST API, powered by the [Django REST Framework](https://www.django-res ```no-highlight curl -s -X POST \ --H "Authorization: Token $TOKEN" \ +-H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ http://netbox/api/ipam/prefixes/ \ --data '{"prefix": "192.0.2.0/24", "site": {"name": "Branch 12"}}' diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 6bc329d78..ed3eab316 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -682,13 +682,13 @@ It is possible to provision authentication tokens for other users via the REST A ### Authenticating to the API -An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's key and plaintext value with a period: +An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's prefix (`nbt_`) and key with its plaintext value, separated by a period: ``` -Authorization: Bearer . +Authorization: Bearer nbt_. ``` -v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.) +Legacy v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.) ``` Authorization: Token @@ -697,7 +697,7 @@ Authorization: Token Below is an example REST API request utilizing a v2 token. ``` -$ curl -H "Authorization: Bearer ." \ +$ curl -H "Authorization: Bearer nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S" \ -H "Accept: application/json; indent=4" \ https://netbox/api/dcim/sites/ { diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 29530bfa6..a1dcf04d5 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -8,6 +8,7 @@ from rq.job import Job as RQ_Job, JobStatus from rq.registry import FailedJobRegistry, StartedJobRegistry from rest_framework import status +from users.constants import TOKEN_PREFIX from users.models import Token, User from utilities.testing import APITestCase, APIViewTestCases, TestCase from utilities.testing.utils import disable_logging @@ -136,7 +137,7 @@ class BackgroundTaskTestCase(TestCase): # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser', is_active=True) self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} # Clear all queues prior to running each test get_queue('default').connection.flushall() diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 27247169a..daa512ee0 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_ from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS from netbox.config import get_config +from users.constants import TOKEN_PREFIX from users.models import Token from utilities.request import get_client_ip @@ -22,40 +23,30 @@ class TokenAuthentication(BaseAuthentication): model = Token def authenticate(self, request): - # Ignore; Authorization header is not present + # Authorization header is not present; ignore if not (auth := get_authorization_header(request).split()): return - - # Infer token version from Token/Bearer keyword in HTTP header - if auth[0].lower() == V1_KEYWORD.lower().encode(): - version = 1 - elif auth[0].lower() == V2_KEYWORD.lower().encode(): - version = 2 - else: - # Ignore; unrecognized header value + # Unrecognized header; ignore + if auth[0].lower() not in (V1_KEYWORD.lower().encode(), V2_KEYWORD.lower().encode()): return - - # Extract token from authorization header. This should be in one of the following two forms: - # * Authorization: Token (v1) - # * Authorization: Bearer . (v2) + # Check for extraneous token content if len(auth) != 2: - if version == 1: - raise exceptions.AuthenticationFailed( - 'Invalid authorization header: Must be in the form "Token "' - ) - else: - raise exceptions.AuthenticationFailed( - 'Invalid authorization header: Must be in the form "Bearer ."' - ) - + raise exceptions.AuthenticationFailed( + 'Invalid authorization header: Must be in the form "Bearer ." or "Token "' + ) # Extract the key (if v2) & token plaintext from the auth header try: auth_value = auth[1].decode() except UnicodeError: raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") + + # Infer token version from presence or absence of prefix + version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1 + if version == 1: key, plaintext = None, auth_value else: + auth_value = auth_value.removeprefix(TOKEN_PREFIX) try: key, plaintext = auth_value.split('.', 1) except ValueError: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index e33e72f5d..528d7e3f5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -8,6 +8,7 @@ from rest_framework.test import APIClient from core.models import ObjectType from dcim.models import Rack, Site +from users.constants import TOKEN_PREFIX from users.models import Group, ObjectPermission, Token, User from utilities.testing import TestCase from utilities.testing.api import APITestCase @@ -49,7 +50,7 @@ class TokenAuthenticationTestCase(APITestCase): token = Token.objects.create(version=2, user=self.user) # Valid token should return a 200 - header = f'Bearer {token.key}.{token.token}' + header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}' response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) self.assertEqual(response.status_code, 200, response.data) @@ -60,7 +61,7 @@ class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_v2_token_invalid(self): # Invalid token should return a 403 - header = 'Bearer XXXXXXXXXX.XXXXXXXXXX' + header = f'Bearer {TOKEN_PREFIX}XXXXXX.XXXXXXXXXX' response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) self.assertEqual(response.status_code, 403) self.assertEqual(response.data['detail'], "Invalid v2 token") @@ -77,7 +78,7 @@ class TokenAuthenticationTestCase(APITestCase): # Request with a non-expired token should succeed response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}') self.assertEqual(response.status_code, 200) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}') self.assertEqual(response.status_code, 200) # Request with an expired token should fail @@ -88,7 +89,7 @@ class TokenAuthenticationTestCase(APITestCase): token2.save() response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.key}') self.assertEqual(response.status_code, 403) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}') + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}') self.assertEqual(response.status_code, 403) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @@ -111,7 +112,7 @@ class TokenAuthenticationTestCase(APITestCase): token2 = Token.objects.create(version=2, user=self.user, write_enabled=False) token1_header = f'Token {token1.token}' - token2_header = f'Bearer {token2.key}.{token2.token}' + token2_header = f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}' # GET request with a write-disabled token should succeed response = self.client.get(url, HTTP_AUTHORIZATION=token1_header) @@ -152,7 +153,7 @@ class TokenAuthenticationTestCase(APITestCase): self.assertEqual(response.status_code, 403) response = self.client.get( url, - HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}', REMOTE_ADDR='127.0.0.1' ) self.assertEqual(response.status_code, 403) @@ -166,7 +167,7 @@ class TokenAuthenticationTestCase(APITestCase): self.assertEqual(response.status_code, 200) response = self.client.get( url, - HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}', REMOTE_ADDR='192.0.2.1' ) self.assertEqual(response.status_code, 200) @@ -519,7 +520,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): """ self.user = User.objects.create(username='testuser') self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): diff --git a/netbox/users/constants.py b/netbox/users/constants.py index 647249179..6a997073c 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -11,6 +11,7 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( CONSTRAINT_TOKEN_USER = '$user' # API tokens -TOKEN_KEY_LENGTH = 16 +TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only +TOKEN_KEY_LENGTH = 12 TOKEN_DEFAULT_LENGTH = 40 TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 1c0bb5c1d..df45cf85d 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -56,10 +56,10 @@ class Migration(migrations.Migration): name='key', field=models.CharField( blank=True, - max_length=16, + max_length=12, null=True, unique=True, - validators=[django.core.validators.MinLengthValidator(16)] + validators=[django.core.validators.MinLengthValidator(12)] ), ), migrations.AddField( diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index e452d2ab7..8d9da0ef6 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -15,7 +15,7 @@ from netaddr import IPNetwork from ipam.fields import IPNetworkField from users.choices import TokenVersionChoices -from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH +from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH, TOKEN_PREFIX from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet @@ -235,6 +235,7 @@ class Token(models.Model): if self.v1: return token == self.token if self.v2: + token = token.removeprefix(TOKEN_PREFIX) try: pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] except KeyError: diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 973b05cb3..56cabef5d 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -17,6 +17,7 @@ from core.choices import ObjectChangeActionChoices from core.models import ObjectChange, ObjectType from ipam.graphql.types import IPAddressFamilyType from netbox.models.features import ChangeLoggingMixin +from users.constants import TOKEN_PREFIX from users.models import ObjectPermission, Token, User from utilities.api import get_graphql_type_for_model from .base import ModelTestCase @@ -50,7 +51,7 @@ class APITestCase(ModelTestCase): self.user = User.objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} def _get_view_namespace(self): return f'{self.view_namespace or self.model._meta.app_label}-api'