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

+
+ + + {% trans 'Cancel' %} + +
+ {% csrf_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. +

+ +
+ {% csrf_token %} +
+ + + {% if form.errors.name %} +
{{ form.errors.name }}
+ {% endif %} +
+
+ + + {% trans 'Cancel' %} + + +
+
+
+
+
+{% 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 %} + + + + + + + + + + + {% for token in tokens %} + + + + + + + + {% endfor %} + +
+ Name + + Token Begin + + Created At + + Last Access +
{{token.name}}{{token.token_prefix}}...{{token.date_created}} + {{ token.date_last_access|default_if_none:"-" }} +
+ {% 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" %}