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

+ 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.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