From 0b10d98e0bd55eb11fb5058fa5b7f645d6abc99e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Mar 2017 17:17:39 -0500 Subject: [PATCH] Initial work on token authentication --- netbox/netbox/settings.py | 13 ++++-- netbox/users/admin.py | 8 ++++ netbox/users/migrations/0001_api_tokens.py | 31 ++++++++++++++ netbox/users/migrations/__init__.py | 0 netbox/users/models.py | 32 ++++++++++++++ netbox/utilities/api.py | 50 +++++++++++++++++++++- 6 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 netbox/users/admin.py create mode 100644 netbox/users/migrations/0001_api_tokens.py create mode 100644 netbox/users/migrations/__init__.py diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 647e5f957..454e4ccd5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -185,14 +185,21 @@ SECRETS_MIN_PUBKEY_SIZE = 2048 # Django REST framework (API) REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'utilities.api.TokenAuthentication', + ), + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.DjangoFilterBackend', + ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'DEFAULT_PERMISSION_CLASSES': ( + 'utilities.api.TokenPermissions', + ), 'DEFAULT_VERSION': VERSION.rsplit('.', 1)[0], # Use major.minor as API version 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, } -if LOGIN_REQUIRED: - REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',) # Django debug toolbar INTERNAL_IPS = ( diff --git a/netbox/users/admin.py b/netbox/users/admin.py new file mode 100644 index 000000000..29c149f21 --- /dev/null +++ b/netbox/users/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Token + + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + list_display = ['user', 'key', 'created', 'expires', 'write_enabled', 'description'] diff --git a/netbox/users/migrations/0001_api_tokens.py b/netbox/users/migrations/0001_api_tokens.py new file mode 100644 index 000000000..0f0943925 --- /dev/null +++ b/netbox/users/migrations/0001_api_tokens.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-07 20:57 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('key', models.CharField(max_length=64, unique=True)), + ('write_enabled', models.BooleanField(default=True, help_text=b'Permit POST/PUT/DELETE operations using this key')), + ('description', models.CharField(blank=True, max_length=100)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/netbox/users/migrations/__init__.py b/netbox/users/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/models.py b/netbox/users/models.py index e69de29bb..02a95c384 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -0,0 +1,32 @@ +import binascii +import os + +from django.contrib.auth.models import User +from django.db import models +from django.utils.encoding import python_2_unicode_compatible + + +@python_2_unicode_compatible +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. + """ + user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + expires = models.DateTimeField(blank=True, null=True) + key = models.CharField(max_length=64, unique=True) + write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") + description = models.CharField(max_length=100, blank=True) + + def __str__(self): + return u"API key for {}".format(self.user) + + def save(self, *args, **kwargs): + if not self.key: + self.key = self.generate_key() + return super(Token, self).save(*args, **kwargs) + + def generate_key(self): + # Generate a random 256-bit key expressed in hexadecimal. + return binascii.hexlify(os.urandom(32)).decode() diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 6ec252bbd..64142812c 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,6 +1,13 @@ +from django.conf import settings +from django.utils import timezone + +from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException +from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS from rest_framework.serializers import Field +from users.models import Token + WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -10,11 +17,51 @@ class ServiceUnavailable(APIException): default_detail = "Service temporarily unavailable, please try again later." +class TokenAuthentication(authentication.TokenAuthentication): + """ + A custom authentication scheme which enforces Token expiration times. + """ + model = Token + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid token") + + # Enforce the Token's expiration time, if one has been set. + if token.expires and token.expires < timezone.now(): + raise exceptions.AuthenticationFailed("Token expired") + + if not token.user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return token.user, token + + +class TokenPermissions(DjangoModelPermissions): + """ + Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability + for unsafe requests (POST/PUT/PATCH/DELETE). + """ + def __init__(self): + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. + self.authenticated_users_only = settings.LOGIN_REQUIRED + super(TokenPermissions, self).__init__() + + def has_permission(self, request, view): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method not in SAFE_METHODS and isinstance(request.auth, Token): + if not request.auth.write_enabled: + return False + return super(TokenPermissions, self).has_permission(request, view) + + class ChoiceFieldSerializer(Field): """ Represent a ChoiceField as (value, label). """ - def __init__(self, choices, **kwargs): self._choices = {k: v for k, v in choices} super(ChoiceFieldSerializer, self).__init__(**kwargs) @@ -30,7 +77,6 @@ class WritableSerializerMixin(object): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). """ - def get_serializer_class(self): if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): return self.write_serializer_class