Răsfoiți Sursa

Merge pull request #6592 from netbox-community/5264-tokens-api-endpoint

Closes #5264: REST API endpoint for tokens
Jeremy Stretch 4 ani în urmă
părinte
comite
7c779f4f09

+ 19 - 0
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

+ 40 - 2
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": ""
+}
+```

+ 1 - 0
netbox/netbox/urls.py

@@ -24,6 +24,7 @@ schema_view = get_schema_view(
     openapi_info,
     validators=['flex', 'ssv'],
     public=True,
+    permission_classes=()
 )
 
 _patterns = [

+ 10 - 1
netbox/users/api/nested_serializers.py

@@ -3,11 +3,12 @@ from django.contrib.contenttypes.models import ContentType
 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 NestedUserSerializer(WritableNestedSerializer):
         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(

+ 29 - 1
netbox/users/api/serializers.py

@@ -3,10 +3,18 @@ from django.contrib.contenttypes.models import ContentType
 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 GroupSerializer(ValidatedModelSerializer):
         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(

+ 9 - 1
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.APIRootView = views.UsersRootView
 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('permissions', views.ObjectPermissionViewSet)
 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)),
+]

+ 54 - 1
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
 #

+ 12 - 1
netbox/users/filtersets.py

@@ -3,7 +3,7 @@ from django.contrib.auth.models import Group, User
 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 @@ class UserFilterSet(BaseFilterSet):
         )
 
 
+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',

+ 2 - 1
netbox/users/models.py

@@ -216,7 +216,8 @@ class Token(BigIDModel):
             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()
 

+ 64 - 1
netbox/users/tests/test_api.py

@@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User
 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 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
         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']