diff --git a/contrib/openapi.json b/contrib/openapi.json index 839aba0b4d3..f78bc4064f6 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166002,11 +166002,62 @@ { "in": "query", "name": "last_used", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "last_used__gte", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "last_used__lte", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "pepper_id", "schema": { "type": "array", "items": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int32" } }, "explode": true, @@ -166014,19 +166065,19 @@ }, { "in": "query", - "name": "last_used__empty", + "name": "pepper_id__empty", "schema": { "type": "boolean" } }, { "in": "query", - "name": "last_used__gt", + "name": "pepper_id__gt", "schema": { "type": "array", "items": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int32" } }, "explode": true, @@ -166034,12 +166085,12 @@ }, { "in": "query", - "name": "last_used__gte", + "name": "pepper_id__gte", "schema": { "type": "array", "items": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int32" } }, "explode": true, @@ -166047,12 +166098,12 @@ }, { "in": "query", - "name": "last_used__lt", + "name": "pepper_id__lt", "schema": { "type": "array", "items": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int32" } }, "explode": true, @@ -166060,12 +166111,12 @@ }, { "in": "query", - "name": "last_used__lte", + "name": "pepper_id__lte", "schema": { "type": "array", "items": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int32" } }, "explode": true, @@ -166073,44 +166124,17 @@ }, { "in": "query", - "name": "last_used__n", + "name": "pepper_id__n", "schema": { "type": "array", "items": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int32" } }, "explode": true, "style": "form" }, - { - "name": "limit", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } - }, - { - "name": "offset", - "required": false, - "in": "query", - "description": "The initial index from which to return the results.", - "schema": { - "type": "integer" - } - }, - { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", - "schema": { - "type": "string" - } - }, { "in": "query", "name": "q", @@ -166171,6 +166195,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", @@ -213892,7 +213929,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -214001,7 +214038,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -228068,6 +228105,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 +228126,10 @@ } ] }, + "description": { + "type": "string", + "maxLength": 200 + }, "expires": { "type": "string", "format": "date-time", @@ -228088,19 +228140,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_id": { + "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 } } }, @@ -230979,7 +231032,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } } }, @@ -244302,9 +244355,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 +244398,15 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { - "type": "string", - "maxLength": 200 + "pepper_id": { + "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 +244414,7 @@ "display", "display_url", "id", + "key", "url", "user" ] @@ -244360,6 +244441,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 +244460,10 @@ ], "readOnly": true }, + "key": { + "type": "string", + "readOnly": true + }, "created": { "type": "string", "format": "date-time", @@ -244383,10 +244479,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 +244486,9 @@ "description": { "type": "string", "maxLength": 200 + }, + "token": { + "type": "string" } }, "required": [ @@ -244411,6 +244506,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 +244539,10 @@ "type": "string", "writeOnly": true, "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 } }, "required": [ @@ -244444,6 +244554,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 +244575,10 @@ } ] }, + "description": { + "type": "string", + "maxLength": 200 + }, "expires": { "type": "string", "format": "date-time", @@ -244464,19 +244589,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_id": { + "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": [ @@ -251292,7 +251418,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -256709,7 +256835,7 @@ "type": "apiKey", "in": "header", "name": "Authorization", - "description": "Token-based authentication with required prefix \"Token\"" + "description": "`Token ` (v1) or `Bearer .` (v2)" } } }, diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 19222740df4..cced030b1b3 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/features/api-integration.md b/docs/features/api-integration.md index 94a39d73173..28aefda92a6 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/installation/3-netbox.md b/docs/installation/3-netbox.md index c192a30948a..fd9b21f50a2 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 47fb65494b1..ed3eab31642 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -653,18 +653,19 @@ 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. -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. - 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 +#### 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 +682,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 prefix (`nbt_`) and key with its plaintext value, separated by a period: + +``` +Authorization: Bearer nbt_. +``` + +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 +``` + +Below is an example REST API request utilizing a v2 token. ``` -$ curl -H "Authorization: Token $TOKEN" \ +$ curl -H "Authorization: Bearer nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S" \ -H "Accept: application/json; indent=4" \ https://netbox/api/dcim/sites/ { diff --git a/netbox/account/tables.py b/netbox/account/tables.py deleted file mode 100644 index bcc0a0ccd46..00000000000 --- a/netbox/account/tables.py +++ /dev/null @@ -1,57 +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', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', - ) diff --git a/netbox/account/views.py b/netbox/account/views.py index f5ef534ce4c..da4aa6d74e2 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -26,8 +26,9 @@ 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', { @@ -343,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 settings.ALLOW_TOKEN_RETRIEVAL else None return render(request, 'account/token.html', { 'object': token, - 'key': key, }) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 4d612e15788..a1dcf04d558 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -8,6 +8,7 @@ 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 @@ def setUp(self): # 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.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 f0bd5fd27d7..daa512ee0b2 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -2,47 +2,90 @@ 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.constants import TOKEN_PREFIX 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 result: - token = result[1] - - # 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() + # Authorization header is not present; ignore + if not (auth := get_authorization_header(request).split()): + return + # Unrecognized header; ignore + if auth[0].lower() not in (V1_KEYWORD.lower().encode(), V2_KEYWORD.lower().encode()): + return + # Check for extraneous token content + if len(auth) != 2: + 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: + raise exceptions.AuthenticationFailed( + "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: - token = model.objects.prefetch_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") + 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): + # 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") + + # 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 +97,8 @@ def authenticate_credentials(self, key): 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 +172,17 @@ def has_permission(self, request, view): 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': '`Token ` (v1) or `Bearer .` (v2)', + } diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 612f75a4096..18d30d29a5a 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -68,6 +68,16 @@ # 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 52973e94deb..6d1de200872 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -45,6 +45,10 @@ ALLOW_TOKEN_RETRIEVAL = True +API_TOKEN_PEPPERS = { + 1: 'TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-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 c0d7f923088..828f7310905 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,6 +19,7 @@ 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 # @@ -65,6 +66,7 @@ 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 +217,12 @@ f" python {BASE_DIR}/generate_secret_key.py" ) +# Validate 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 if RELEASE_CHECK_URL: try: diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index f480c2085cb..12b781cf4ba 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -270,7 +270,7 @@ def render(self, record, table, **kwargs): 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/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 9eb21661dd4..528d7e3f58e 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -8,6 +8,7 @@ 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 @@ -16,67 +17,159 @@ 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_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) + + # 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 = 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") + @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 {TOKEN_PREFIX}{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 {TOKEN_PREFIX}{token2.key}') self.assertEqual(response.status_code, 403) @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) + + token1_header = f'Token {token1.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) + self.assertEqual(response.status_code, 200) + response = self.client.get(url, HTTP_AUTHORIZATION=token2_header) + self.assertEqual(response.status_code, 200) - # 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}') + # 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) - - # 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}') + response = self.client.post(url, data[1], format='json', HTTP_AUTHORIZATION=token2_header) self.assertEqual(response.status_code, 403) + # 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[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): 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 {TOKEN_PREFIX}{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 {TOKEN_PREFIX}{token2.key}.{token2.token}', + REMOTE_ADDR='192.0.2.1' + ) self.assertEqual(response.status_code, 200) @@ -427,7 +520,7 @@ def setUp(self): """ 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.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/templates/account/token.html b/netbox/templates/account/token.html index f8ce7baddae..6fcf9e3598a 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 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 674476d51eb..b3eb80b8773 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -14,9 +14,31 @@

{% trans "Token" %}

- - + + + {% if object.version == 1 %} + + + + + {% else %} + + + + + + + + + {% endif %}
{% trans "Key" %}{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}{% trans "Version" %}{{ object.version }}
{% trans "Token" %} + {% if settings.ALLOW_TOKEN_RETRIEVAL %} + {{ object.plaintext }} + + {% else %} + {{ object.partial }} + {% endif %} +
{% trans "Key" %}{{ object }}
{% trans "Pepper ID" %}{{ object.pepper_id }}
{% trans "User" %} diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index 150291ee6d5..3b5ec08ee34 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 @@ 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_id', '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 00000000000..547633c4e9f --- /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 e92623c820a..6a997073cd2 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,9 @@ ) CONSTRAINT_TOKEN_USER = '$user' + +# API tokens +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/filtersets.py b/netbox/users/filtersets.py index 4e15104107a..36fbdcb0df9 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', 'key', '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/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index f478dedbff7..bdda61a44d4 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 @@ def save(self, *args, **kwargs): 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 61e55949c67..32e52b5f966 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -3,10 +3,12 @@ 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 from utilities.forms.rendering import FieldSet +from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import DateTimePicker __all__ = ( @@ -110,7 +112,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=add_blank_choice(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 4f4e2fd439e..582062ebbec 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -12,14 +12,11 @@ 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 -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 @@ -115,10 +112,10 @@ def plugin_fields(self): 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 +135,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 +144,27 @@ class Meta: 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.initial['token'] = self.instance.plaintext + 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() + 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.instance._state.adding and self.cleaned_data.get('token'): + self.instance.token = self.cleaned_data['token'] + + return super().save(commit=commit) class TokenForm(UserTokenForm): @@ -162,14 +173,10 @@ class TokenForm(UserTokenForm): label=_('User') ) - class Meta: - model = Token + class Meta(UserTokenForm.Meta): fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips', ] - widgets = { - 'expires': DateTimePicker(), - } class UserForm(forms.ModelForm): 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 00000000000..df45cf85de5 --- /dev/null +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -0,0 +1,100 @@ +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=12, + null=True, + unique=True, + validators=[django.core.validators.MinLengthValidator(12)] + ), + ), + migrations.AddField( + model_name='token', + name='pepper_id', + 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), + ), + + # 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 3c1284bc9ce..8d9da0ef636 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,16 +1,22 @@ -import binascii -import os +import hashlib +import hmac +import random 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.db.models import Q from django.urls import reverse from django.utils import timezone 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, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH, TOKEN_PREFIX +from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -23,11 +29,23 @@ 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, + 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 +60,41 @@ 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=TOKEN_KEY_LENGTH, + unique=True, + blank=True, + null=True, + validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], + help_text=_('v2 token identification key'), + ) + 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)'), + ) + 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,29 +110,113 @@ class Token(models.Model): objects = RestrictedQuerySet.as_manager() class Meta: + ordering = ('-created',) verbose_name = _('token') verbose_name_plural = _('tokens') - ordering = ('-created',) + 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) + + # 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): - return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + return self.key if self.v2 else self.partial 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 a sanitized representation of a v1 token. + """ + return f'**********************************{self.plaintext[-6:]}' if self.plaintext else '' + + @property + def token(self): + 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: + self.plaintext = value + elif self.v2: + self.key = self.key or self.generate_key() + self.update_digest() + + def clean(self): + 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 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) + @classmethod + def generate_key(cls): + """ + Generate and return a random alphanumeric key for v2 tokens. + """ + return cls.generate(length=TOKEN_KEY_LENGTH) + @staticmethod - def generate_key(): - # Generate a random 160-bit key expressed in hexadecimal. - return binascii.hexlify(os.urandom(20)).decode() + def generate(length=TOKEN_DEFAULT_LENGTH): + """ + 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_id, pepper = get_current_pepper() + self.hmac_digest = hmac.new( + pepper.encode('utf-8'), + self.token.encode('utf-8'), + hashlib.sha256 + ).hexdigest() @property def is_expired(self): @@ -102,6 +224,26 @@ def is_expired(self): return False return True + def validate(self, token): + """ + 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.v1: + return token == self.token + if self.v2: + token = token.removeprefix(TOKEN_PREFIX) + try: + pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] + 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 40cbeca47d3..c5207d89925 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,18 +11,53 @@ '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', 'key', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', + '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): diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 71496f00774..741c578b6d5 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -2,6 +2,7 @@ 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 @@ -197,7 +198,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 +257,8 @@ def test_provision_token_valid(self): 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']), 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_filtersets.py b/netbox/users/tests/test_filtersets.py index e15df0d1896..1f7336cc3c1 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -266,7 +266,7 @@ def test_can_delete(self): 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,48 @@ def setUpTestData(cls): 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_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]} @@ -312,11 +339,6 @@ def test_expires(self): 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.all()[: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) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index e66c00d0a13..24aec6941f6 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 @@ def setUpTestData(cls): 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"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token", + f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token", + f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{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 114d8ab6dd7..c355873a8d0 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -1,5 +1,11 @@ +from django.conf import settings 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 +13,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 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] diff --git a/netbox/utilities/security.py b/netbox/utilities/security.py new file mode 100644 index 00000000000..47a18d26539 --- /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.") diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 1fe8813679e..56cabef5d8f 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -17,6 +17,7 @@ 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 @@ def setUp(self): 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.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' @@ -153,6 +154,7 @@ def test_list_objects_brief(self): 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)