diff --git a/apitokens/__init__.py b/apitokens/__init__.py
new file mode 100644
index 00000000..bc516ba6
--- /dev/null
+++ b/apitokens/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'apitokens.apps.TokensConfig'
diff --git a/apitokens/apps.py b/apitokens/apps.py
new file mode 100644
index 00000000..07fa77eb
--- /dev/null
+++ b/apitokens/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class TokensConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apitokens'
diff --git a/apitokens/authentication.py b/apitokens/authentication.py
new file mode 100644
index 00000000..857b7bf8
--- /dev/null
+++ b/apitokens/authentication.py
@@ -0,0 +1,33 @@
+import datetime
+
+from rest_framework.authentication import BaseAuthentication
+from rest_framework.exceptions import AuthenticationFailed
+from .models import UserToken
+from utils import clean_ip_address
+
+
+class UserTokenAuthentication(BaseAuthentication):
+ def authenticate(self, request):
+ auth_header = request.headers.get('Authorization')
+
+ if not auth_header:
+ return None
+
+ try:
+ token_type, token_key = auth_header.split()
+ if token_type.lower() != 'bearer':
+ return None
+ except ValueError:
+ return None
+
+ try:
+ token_hash = UserToken.generate_hash(token_key=token_key)
+ token = UserToken.objects.get(token_hash=token_hash)
+ except UserToken.DoesNotExist:
+ raise AuthenticationFailed('Invalid token')
+
+ token.ip_address_last_access = clean_ip_address(request)
+ token.date_last_access = datetime.datetime.now()
+ token.save(update_fields={'ip_address_last_access', 'date_last_access'})
+
+ return (token.user, token)
diff --git a/apitokens/migrations/0001_initial.py b/apitokens/migrations/0001_initial.py
new file mode 100644
index 00000000..4d208dfb
--- /dev/null
+++ b/apitokens/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# Generated by Django 4.2.11 on 2024-05-25 09:43
+
+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='UserToken',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255)),
+ ('token_prefix', models.CharField(editable=False, max_length=5)),
+ ('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
+ ('date_created', models.DateTimeField(auto_now_add=True)),
+ ('date_last_access', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('ip_address_last_access', models.GenericIPAddressField(null=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/apitokens/migrations/__init__.py b/apitokens/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apitokens/models.py b/apitokens/models.py
new file mode 100644
index 00000000..13e9e25f
--- /dev/null
+++ b/apitokens/models.py
@@ -0,0 +1,36 @@
+import hashlib
+import secrets
+
+from django.db import models
+from django.urls import reverse
+
+from users.models import User
+
+
+class UserToken(models.Model):
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens')
+ name = models.CharField(max_length=255)
+ token_prefix = models.CharField(max_length=5, editable=False)
+ token_hash = models.CharField(max_length=64, editable=False, unique=True)
+ date_created = models.DateTimeField(auto_now_add=True)
+ date_last_access = models.DateTimeField(null=True, blank=True, editable=False)
+ ip_address_last_access = models.GenericIPAddressField(protocol='both', null=True)
+
+ def get_delete_url(self):
+ return reverse('apitokens:delete', kwargs={'pk': self.pk})
+
+ def __str__(self):
+ return f"{self.user.username} - {self.token_prefix} - {self.name}"
+
+ @staticmethod
+ def generate_hash(token_key: str) -> str:
+ return hashlib.sha256(token_key.encode()).hexdigest()
+
+ @staticmethod
+ def generate_token_key() -> str:
+ return secrets.token_urlsafe(32)
+
+ @classmethod
+ def generate_token_prefix(cls, token_key: str) -> str:
+ token_prefix_length = cls._meta.get_field('token_prefix').max_length
+ return token_key[:token_prefix_length]
diff --git a/apitokens/templates/apitokens/apitoken_confirm_delete.html b/apitokens/templates/apitokens/apitoken_confirm_delete.html
new file mode 100644
index 00000000..2ff847ca
--- /dev/null
+++ b/apitokens/templates/apitokens/apitoken_confirm_delete.html
@@ -0,0 +1,30 @@
+{% extends "common/base.html" %}
+{% load i18n %}
+{% block content %}
+
+
+
+
+ Delete API Token?
+
+
+ This will delete the token {{ object.name }} created at {{ object.date_created }}
+ Any application which were relying on this token will require a new token.
+
+
+
+
+
+{% endblock content %}
diff --git a/apitokens/templates/apitokens/apitoken_create.html b/apitokens/templates/apitokens/apitoken_create.html
new file mode 100644
index 00000000..abff4af9
--- /dev/null
+++ b/apitokens/templates/apitokens/apitoken_create.html
@@ -0,0 +1,37 @@
+{% extends "common/base.html" %}
+{% load i18n %}
+{% block content %}
+
+
+
+
+ User API Token
+
+
+ Create a new user token to be used with the API.
+
+
+
+
+
+
+{% endblock content %}
diff --git a/apitokens/templates/apitokens/usertoken_list.html b/apitokens/templates/apitokens/usertoken_list.html
new file mode 100644
index 00000000..b98e2a01
--- /dev/null
+++ b/apitokens/templates/apitokens/usertoken_list.html
@@ -0,0 +1,49 @@
+{% extends 'users/settings/base.html' %}
+
+{% block settings %}
+ Tokens
+
+
+
+ List of user tokens to automate tasks via the
API.
+
+ {% if tokens %}
+
+
+
+
+ Name
+ |
+
+ Token Begin
+ |
+
+ Created At
+ |
+
+ Last Access
+ |
+
+
+
+ {% for token in tokens %}
+
+ {{token.name}} |
+ {{token.token_prefix}}... |
+ {{token.date_created}} |
+
+ {{ token.date_last_access|default_if_none:"-" }}
+ |
+ |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
Create Token
+
+
+
+{% endblock settings %}
diff --git a/apitokens/tests/__init__.py b/apitokens/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apitokens/tests/test_user_token.py b/apitokens/tests/test_user_token.py
new file mode 100644
index 00000000..b763f356
--- /dev/null
+++ b/apitokens/tests/test_user_token.py
@@ -0,0 +1,79 @@
+from datetime import datetime
+from django.utils import timezone
+
+from django.test import TestCase
+from django.urls import reverse
+
+from apitokens.models import UserToken
+from common.tests.factories.users import UserFactory
+
+
+class UserTokenTest(TestCase):
+ def setUp(self) -> None:
+ self.user = UserFactory()
+ self.client.force_login(self.user)
+ self.assertEqual(UserToken.objects.count(), 0)
+ return super().setUp()
+
+ def test_token_displayed_only_once(self):
+ response = self.client.post(
+ reverse('apitokens:create'),
+ {
+ 'name': 'Test Token',
+ },
+ )
+
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(response, reverse('apitokens:list'))
+ self.assertEqual(UserToken.objects.count(), 1)
+ token = UserToken.objects.first()
+
+ # Check if the success message with the token value is displayed
+ messages = list(response.wsgi_request._messages)
+ self.assertEqual(len(messages), 2)
+
+ token_key = messages[0].message
+ self.assertIn(token.token_prefix, token_key)
+ self.assertIn('Your new token has been generated', messages[1].message)
+
+ token_hash = UserToken.generate_hash(token_key)
+ self.assertEqual(token, UserToken.objects.get(token_hash=token_hash))
+
+ # Verify the token value is shown only on the creation page
+ response = self.client.get(reverse('apitokens:list'))
+ self.assertNotContains(response, token_key)
+
+ def test_list_page_does_not_display_full_token_value(self):
+ token_key = UserToken.generate_token_key()
+
+ token_prefix = UserToken.generate_token_prefix(token_key)
+ token_hash = UserToken.generate_hash(token_key)
+ token = UserToken.objects.create(
+ user=self.user, name='Test Token', token_prefix=token_prefix, token_hash=token_hash
+ )
+
+ response = self.client.get(reverse('apitokens:list'))
+ self.assertContains(response, str(token.token_prefix))
+ self.assertNotContains(response, str(token_key))
+
+ def test_list_page_shows_last_access_time(self):
+ token = UserToken.objects.create(user=self.user, name='Test Token')
+
+ # Create a timezone-aware datetime object.
+ date_last_access_str = '1994-01-02 10:10:36'
+ date_last_access_naive = datetime.strptime(date_last_access_str, '%Y-%m-%d %H:%M:%S')
+ date_last_access_aware = timezone.make_aware(
+ date_last_access_naive, timezone.get_default_timezone()
+ )
+ token.date_last_access = date_last_access_aware
+
+ # Format the datetime to match the expected response format.
+ formatted_date = (
+ date_last_access_aware.strftime('%b. %-d, %Y, %-I:%M %p')
+ .replace('AM', 'a.m.')
+ .replace('PM', 'p.m.')
+ )
+
+ token.save()
+ response = self.client.get(reverse('apitokens:list'))
+ self.assertContains(response, formatted_date)
diff --git a/apitokens/urls.py b/apitokens/urls.py
new file mode 100644
index 00000000..f76eddb8
--- /dev/null
+++ b/apitokens/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+import apitokens.views
+
+
+app_name = 'apitokens'
+urlpatterns = [
+ path('settings/tokens/', apitokens.views.TokensView.as_view(), name='list'),
+ path('tokens/create/', apitokens.views.CreateTokenView.as_view(), name='create'),
+ path('tokens/delete//', apitokens.views.DeleteTokenView.as_view(), name='delete'),
+]
diff --git a/apitokens/views.py b/apitokens/views.py
new file mode 100644
index 00000000..8e72a5c0
--- /dev/null
+++ b/apitokens/views.py
@@ -0,0 +1,78 @@
+import logging
+
+from django import forms
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.messages.views import SuccessMessageMixin
+from django.contrib import messages
+from django.views.generic.edit import CreateView
+from django.views.generic import ListView, DeleteView
+from django.shortcuts import reverse
+from django.utils.safestring import mark_safe
+from django.urls import reverse_lazy
+
+
+from .models import UserToken
+
+logger = logging.getLogger(__name__)
+
+
+class TokensView(LoginRequiredMixin, ListView):
+ model = UserToken
+ context_object_name = 'tokens'
+
+ def get_queryset(self):
+ return self.request.user.tokens.all()
+
+
+class UserTokenForm(forms.ModelForm):
+ class Meta:
+ model = UserToken
+ fields = ('name',)
+
+ def __init__(self, *args, **kwargs):
+ self.user = kwargs.pop('user', None) # Get user information from kwargs
+ super().__init__(*args, **kwargs)
+
+ def clean_name(self):
+ name = self.cleaned_data.get('name')
+ if UserToken.objects.filter(user=self.user, name=name).exists():
+ error_message = mark_safe(f'A token with this name already exists: {name}.')
+ raise forms.ValidationError(error_message)
+ return name
+
+
+class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
+ model = UserToken
+ form_class = UserTokenForm
+ template_name = 'apitokens/apitoken_create.html'
+ success_message = (
+ "Your new token has been generated. Copy it now as it will not be shown again."
+ )
+
+ def get_success_url(self):
+ return reverse('apitokens:list')
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['user'] = self.request.user
+ return kwargs
+
+ def form_valid(self, form):
+ form.instance.user = self.request.user
+
+ token_key = UserToken.generate_token_key()
+ form.instance.token_hash = UserToken.generate_hash(token_key)
+ form.instance.token_prefix = UserToken.generate_token_prefix(token_key)
+
+ messages.info(self.request, token_key)
+ return super().form_valid(form)
+
+
+class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
+ model = UserToken
+ template_name = 'apitokens/apitoken_confirm_delete.html'
+ success_url = reverse_lazy('apitokens:list')
+ success_message = "Token deleted successfully"
+
+ def get_queryset(self):
+ return super().get_queryset().filter(user=self.request.user)
diff --git a/blender_extensions/settings.py b/blender_extensions/settings.py
index 33fc1998..61abe392 100644
--- a/blender_extensions/settings.py
+++ b/blender_extensions/settings.py
@@ -60,6 +60,7 @@ INSTALLED_APPS = [
'rangefilter',
'reviewers',
'stats',
+ 'apitokens',
'taggit',
'drf_spectacular',
'drf_spectacular_sidecar',
@@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
+ 'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
+ 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
}
SPECTACULAR_SETTINGS = {
diff --git a/blender_extensions/urls.py b/blender_extensions/urls.py
index fb6f91d8..7c64a3a1 100644
--- a/blender_extensions/urls.py
+++ b/blender_extensions/urls.py
@@ -40,6 +40,7 @@ urlpatterns = [
path('', include('teams.urls')),
path('', include('reviewers.urls')),
path('', include('notifications.urls')),
+ path('', include('apitokens.urls')),
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
diff --git a/extensions/views/api.py b/extensions/views/api.py
index cb58ba7d..8f60fc02 100644
--- a/extensions/views/api.py
+++ b/extensions/views/api.py
@@ -1,5 +1,6 @@
import logging
+from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import serializers
from rest_framework.views import APIView
@@ -104,6 +105,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
class ExtensionsAPIView(APIView):
+ permission_classes = [AllowAny]
serializer_class = ListedExtensionsSerializer
@extend_schema(
diff --git a/users/templates/users/settings/tabs.html b/users/templates/users/settings/tabs.html
index 37278b97..3d5009ec 100644
--- a/users/templates/users/settings/tabs.html
+++ b/users/templates/users/settings/tabs.html
@@ -4,6 +4,8 @@
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
+ {% include "common/components/nav_link.html" with name="apitokens:list" title="Tokens" classes="i-lock py-2" %}
+
{% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}