Browse Source

Initial work on token authentication

Jeremy Stretch 8 years ago
parent
commit
0b10d98e0b

+ 10 - 3
netbox/netbox/settings.py

@@ -185,14 +185,21 @@ SECRETS_MIN_PUBKEY_SIZE = 2048
 
 
 # Django REST framework (API)
 # Django REST framework (API)
 REST_FRAMEWORK = {
 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_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_VERSION': VERSION.rsplit('.', 1)[0],  # Use major.minor as API version
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'PAGE_SIZE': PAGINATE_COUNT,
     'PAGE_SIZE': PAGINATE_COUNT,
 }
 }
-if LOGIN_REQUIRED:
-    REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
 
 
 # Django debug toolbar
 # Django debug toolbar
 INTERNAL_IPS = (
 INTERNAL_IPS = (

+ 8 - 0
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']

+ 31 - 0
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)),
+            ],
+        ),
+    ]

+ 0 - 0
netbox/users/migrations/__init__.py


+ 32 - 0
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()

+ 48 - 2
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.exceptions import APIException
+from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
 from rest_framework.serializers import Field
 from rest_framework.serializers import Field
 
 
+from users.models import Token
+
 
 
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
 
 
@@ -10,11 +17,51 @@ class ServiceUnavailable(APIException):
     default_detail = "Service temporarily unavailable, please try again later."
     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):
 class ChoiceFieldSerializer(Field):
     """
     """
     Represent a ChoiceField as (value, label).
     Represent a ChoiceField as (value, label).
     """
     """
-
     def __init__(self, choices, **kwargs):
     def __init__(self, choices, **kwargs):
         self._choices = {k: v for k, v in choices}
         self._choices = {k: v for k, v in choices}
         super(ChoiceFieldSerializer, self).__init__(**kwargs)
         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).
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
     """
     """
-
     def get_serializer_class(self):
     def get_serializer_class(self):
         if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
         if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
             return self.write_serializer_class
             return self.write_serializer_class