diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 2faf14c8018..7674d57a6a3 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -8,6 +8,23 @@ ### New Features +### REST API Token Provisioning ([#5264](https://github.com/netbox-community/netbox/issues/5264)) + +This release introduces the `/api/users/tokens/` REST API endpoint, which includes a child endpoint that can be employed by a user to provision a new REST API token. This allows a user to gain REST API access without needing to first create a token via the web UI. + +``` +$ curl -X POST \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +https://netbox/api/users/tokens/provision/ +{ + "username": "hankhill", + "password: "I<3C3H8", +} +``` + +If the supplied credentials are valid, NetBox will create and return a new token for the user. + #### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963)) This release introduces the [`CUSTOM_VALIDATORS`](../configuration/optional-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description: @@ -50,6 +67,8 @@ CustomValidator can also be subclassed to enforce more complex logic by overridi ### REST API Changes +* Added the `/api/users/tokens/` endpoint + * The `provision/` child endpoint can be used to provision new REST API tokens by supplying a valid username and password * dcim.Cable * `length` is now a decimal value * dcim.Device diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 7fb789e0f76..24689623372 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -11,7 +11,7 @@ An authentication token is attached to a request by setting the `Authorization` ``` $ curl -H "Authorization: Token $TOKEN" \ -H "Accept: application/json; indent=4" \ -http://netbox/api/dcim/sites/ +https://netbox/api/dcim/sites/ { "count": 10, "next": null, @@ -23,8 +23,46 @@ http://netbox/api/dcim/sites/ A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: ``` -$ curl http://netbox/api/dcim/sites/ +$ curl https://netbox/api/dcim/sites/ { "detail": "Authentication credentials were not provided." } ``` + +## Initial Token Provisioning + +Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. + +To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: + +``` +$ curl -X POST \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +https://netbox/api/users/tokens/provision/ +{ + "username": "hankhill", + "password: "I<3C3H8", +} +``` + +Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled. + +```json +{ + "id": 6, + "url": "https://netbox/api/users/tokens/6/", + "display": "3c9cb9 (hankhill)", + "user": { + "id": 2, + "url": "https://netbox/api/users/users/2/", + "display": "hankhill", + "username": "hankhill" + }, + "created": "2021-06-11T20:09:13.339367Z", + "expires": null, + "key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9", + "write_enabled": true, + "description": "" +} +``` diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index d718cebc035..54d002d5daf 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -24,6 +24,7 @@ openapi_info, validators=['flex', 'ssv'], public=True, + permission_classes=() ) _patterns = [ diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 27ef3fc82a9..df9af0f1911 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -3,11 +3,12 @@ from rest_framework import serializers from netbox.api import ContentTypeField, WritableNestedSerializer -from users.models import ObjectPermission +from users.models import ObjectPermission, Token __all__ = [ 'NestedGroupSerializer', 'NestedObjectPermissionSerializer', + 'NestedTokenSerializer', 'NestedUserSerializer', ] @@ -28,6 +29,14 @@ class Meta: fields = ['id', 'url', 'display', 'username'] +class NestedTokenSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') + + class Meta: + model = Token + fields = ['id', 'url', 'display', 'key', 'write_enabled'] + + class NestedObjectPermissionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') object_types = ContentTypeField( diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 054f9ba483b..d490e8fe922 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -3,10 +3,18 @@ from rest_framework import serializers from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer -from users.models import ObjectPermission +from users.models import ObjectPermission, Token from .nested_serializers import * +__all__ = ( + 'GroupSerializer', + 'ObjectPermissionSerializer', + 'TokenSerializer', + 'UserSerializer', +) + + class UserSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') groups = SerializedPKRelatedField( @@ -47,6 +55,26 @@ class Meta: fields = ('id', 'url', 'display', 'name', 'user_count') +class TokenSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') + key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) + user = NestedUserSerializer() + + class Meta: + model = Token + fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + + def to_internal_value(self, data): + if 'key' not in data: + data['key'] = Token.generate_key() + return super().to_internal_value(data) + + +class TokenProvisionSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField() + + class ObjectPermissionSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') object_types = ContentTypeField( diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index df2e8c25a29..15e4d153081 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,3 +1,5 @@ +from django.urls import include, path + from netbox.api import OrderedDefaultRouter from . import views @@ -9,6 +11,9 @@ router.register('users', views.UserViewSet) router.register('groups', views.GroupViewSet) +# Tokens +router.register('tokens', views.TokenViewSet) + # Permissions router.register('permissions', views.ObjectPermissionViewSet) @@ -16,4 +21,7 @@ router.register('config', views.UserConfigViewSet, basename='userconfig') app_name = 'users-api' -urlpatterns = router.urls +urlpatterns = [ + path('tokens/provision/', views.TokenProvisionView.as_view(), name='token_provision'), + path('', include(router.urls)), +] diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index b0443b87e57..a8896e0ba52 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,13 +1,17 @@ +from django.contrib.auth import authenticate from django.contrib.auth.models import Group, User from django.db.models import Count +from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.routers import APIRootView +from rest_framework.status import HTTP_201_CREATED +from rest_framework.views import APIView from rest_framework.viewsets import ViewSet from netbox.api.views import ModelViewSet from users import filtersets -from users.models import ObjectPermission, UserConfig +from users.models import ObjectPermission, Token, UserConfig from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge from . import serializers @@ -37,6 +41,55 @@ class GroupViewSet(ModelViewSet): filterset_class = filtersets.GroupFilterSet +# +# REST API tokens +# + +class TokenViewSet(ModelViewSet): + queryset = RestrictedQuerySet(model=Token).prefetch_related('user') + serializer_class = serializers.TokenSerializer + filterset_class = filtersets.TokenFilterSet + + def get_queryset(self): + """ + Limit the non-superusers to their own Tokens. + """ + queryset = super().get_queryset() + # Workaround for schema generation (drf_yasg) + if getattr(self, 'swagger_fake_view', False): + return queryset.none() + if self.request.user.is_superuser: + return queryset + return queryset.filter(user=self.request.user) + + +class TokenProvisionView(APIView): + """ + Non-authenticated REST API endpoint via which a user may create a Token. + """ + permission_classes = [] + + def post(self, request): + serializer = serializers.TokenProvisionSerializer(data=request.data) + serializer.is_valid() + + # Authenticate the user account based on the provided credentials + user = authenticate( + request=request, + username=serializer.data['username'], + password=serializer.data['password'] + ) + if user is None: + raise AuthenticationFailed("Invalid username/password") + + # Create a new Token for the User + token = Token(user=user) + token.save() + data = serializers.TokenSerializer(token, context={'request': request}).data + + return Response(data, status=HTTP_201_CREATED) + + # # ObjectPermissions # diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 6625cba3621..87f7dce5720 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from netbox.filtersets import BaseFilterSet -from users.models import ObjectPermission +from users.models import ObjectPermission, Token __all__ = ( 'GroupFilterSet', @@ -60,6 +60,17 @@ def search(self, queryset, name, value): ) +class TokenFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Token + fields = ['id', 'user', 'created', 'expires', 'key', 'write_enabled'] + + class ObjectPermissionFilterSet(BaseFilterSet): user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', diff --git a/netbox/users/models.py b/netbox/users/models.py index 4a8274ab444..958681ba578 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -216,7 +216,8 @@ def save(self, *args, **kwargs): self.key = self.generate_key() return super().save(*args, **kwargs) - def generate_key(self): + @staticmethod + def generate_key(): # Generate a random 160-bit key expressed in hexadecimal. return binascii.hexlify(os.urandom(20)).decode() diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index c2488f2f6d0..9ddb76884f4 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from users.models import ObjectPermission +from users.models import ObjectPermission, Token from utilities.testing import APIViewTestCases, APITestCase from utilities.utils import deepmerge @@ -75,6 +75,69 @@ def setUpTestData(cls): Group.objects.bulk_create(users) +class TokenTest(APIViewTestCases.APIViewTestCase): + model = Token + brief_fields = ['display', 'id', 'key', 'url', 'write_enabled'] + bulk_update_data = { + 'description': 'New description', + } + + def setUp(self): + super().setUp() + + tokens = ( + # We already start with one Token, created by the test class + Token(user=self.user), + Token(user=self.user), + ) + # Use save() instead of bulk_create() to ensure keys get automatically generated + for token in tokens: + token.save() + + self.create_data = [ + { + 'user': self.user.pk, + }, + { + 'user': self.user.pk, + }, + { + 'user': self.user.pk, + }, + ] + + def test_provision_token_valid(self): + """ + Test the provisioning of a new REST API token given a valid username and password. + """ + data = { + 'username': 'user1', + 'password': 'abc123', + } + user = User.objects.create_user(**data) + url = reverse('users-api:token_provision') + + response = self.client.post(url, **self.header, data=data) + self.assertEqual(response.status_code, 201) + self.assertIn('key', response.data) + self.assertEqual(len(response.data['key']), 40) + token = Token.objects.get(user=user) + self.assertEqual(token.key, response.data['key']) + + def test_provision_token_invalid(self): + """ + Test the behavior of the token provisioning view when invalid credentials are supplied. + """ + data = { + 'username': 'nonexistentuser', + 'password': 'abc123', + } + url = reverse('users-api:token_provision') + + response = self.client.post(url, **self.header, data=data) + self.assertEqual(response.status_code, 403) + + class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): model = ObjectPermission brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users']