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

Closes #5264: REST API endpoint for tokens
This commit is contained in:
Jeremy Stretch 2021-06-14 09:01:20 -04:00 committed by GitHub
commit 7c779f4f09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 240 additions and 9 deletions

View File

@ -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

View File

@ -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": ""
}
```

View File

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

View File

@ -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(

View File

@ -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(

View File

@ -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)),
]

View File

@ -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
#

View File

@ -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',

View File

@ -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()

View File

@ -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']