Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #5264: REST API endpoint for tokens #6592

Merged
merged 6 commits into from
Jun 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/release-notes/version-3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
42 changes: 40 additions & 2 deletions docs/rest-api/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 change: 1 addition & 0 deletions netbox/netbox/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
openapi_info,
validators=['flex', 'ssv'],
public=True,
permission_classes=()
)

_patterns = [
Expand Down
11 changes: 10 additions & 1 deletion netbox/users/api/nested_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]

Expand All @@ -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(
Expand Down
30 changes: 29 additions & 1 deletion netbox/users/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion netbox/users/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from django.urls import include, path

from netbox.api import OrderedDefaultRouter
from . import views

Expand All @@ -9,11 +11,17 @@
router.register('users', views.UserViewSet)
router.register('groups', views.GroupViewSet)

# Tokens
router.register('tokens', views.TokenViewSet)

# Permissions
router.register('permissions', views.ObjectPermissionViewSet)

# User preferences
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)),
]
55 changes: 54 additions & 1 deletion netbox/users/api/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
#
Expand Down
13 changes: 12 additions & 1 deletion netbox/users/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion netbox/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
65 changes: 64 additions & 1 deletion netbox/users/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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']
Expand Down