From 0a58aa75628126ee942f1b8d52d46439ec1207c3 Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Fri, 17 May 2024 16:15:34 +0200 Subject: [PATCH 1/6] API Tokens This allows users to create tokens to be used with the API. The goal is to use those for the API that will allow users to upload new versions of an extension. The Tokens can be created/managed on the user profile. Note: There are still no API entries that can use these tokens. --- blender_extensions/settings.py | 6 ++ blender_extensions/urls.py | 1 + tokens/__init__.py | 1 + tokens/apps.py | 6 ++ tokens/migrations/0001_initial.py | 28 ++++++++ tokens/migrations/__init__.py | 0 tokens/models.py | 20 ++++++ .../tokens/token_confirm_delete.html | 30 ++++++++ tokens/templates/tokens/token_create.html | 37 ++++++++++ tokens/templates/tokens/usertoken_list.html | 43 ++++++++++++ tokens/urls.py | 11 +++ tokens/views.py | 69 +++++++++++++++++++ users/templates/users/settings/tabs.html | 2 + 13 files changed, 254 insertions(+) create mode 100644 tokens/__init__.py create mode 100644 tokens/apps.py create mode 100644 tokens/migrations/0001_initial.py create mode 100644 tokens/migrations/__init__.py create mode 100644 tokens/models.py create mode 100644 tokens/templates/tokens/token_confirm_delete.html create mode 100644 tokens/templates/tokens/token_create.html create mode 100644 tokens/templates/tokens/usertoken_list.html create mode 100644 tokens/urls.py create mode 100644 tokens/views.py diff --git a/blender_extensions/settings.py b/blender_extensions/settings.py index 33fc1998..3bf3b25a 100644 --- a/blender_extensions/settings.py +++ b/blender_extensions/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = [ 'reviewers', 'stats', 'taggit', + 'tokens', 'drf_spectacular', 'drf_spectacular_sidecar', 'rest_framework', @@ -273,6 +274,11 @@ ACTSTREAM_SETTINGS = { REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), } SPECTACULAR_SETTINGS = { diff --git a/blender_extensions/urls.py b/blender_extensions/urls.py index fb6f91d8..c97d2a11 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('tokens.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/tokens/__init__.py b/tokens/__init__.py new file mode 100644 index 00000000..0916ae84 --- /dev/null +++ b/tokens/__init__.py @@ -0,0 +1 @@ +default_app_config = 'tokens.apps.TokensConfig' diff --git a/tokens/apps.py b/tokens/apps.py new file mode 100644 index 00000000..3557a101 --- /dev/null +++ b/tokens/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TokensConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tokens' diff --git a/tokens/migrations/0001_initial.py b/tokens/migrations/0001_initial.py new file mode 100644 index 00000000..18ce6327 --- /dev/null +++ b/tokens/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2024-05-17 13:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +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')), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/tokens/migrations/__init__.py b/tokens/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tokens/models.py b/tokens/models.py new file mode 100644 index 00000000..4c482bcc --- /dev/null +++ b/tokens/models.py @@ -0,0 +1,20 @@ +import uuid + +from django.db import models +from django.urls import reverse + +from users.models import User + + +class UserToken(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens') + token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + def get_delete_url(self): + return reverse('tokens:delete', kwargs={'pk': self.pk}) + + def __str__(self): + return f"{self.user.username} - {self.token} - {self.name}" diff --git a/tokens/templates/tokens/token_confirm_delete.html b/tokens/templates/tokens/token_confirm_delete.html new file mode 100644 index 00000000..b6f69daf --- /dev/null +++ b/tokens/templates/tokens/token_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.created_at }}
+ Any application which were relying on this token will require a new token. +

+
+ + + {% trans 'Cancel' %} + +
+ {% csrf_token %} + +
+
+
+
+
+{% endblock content %} diff --git a/tokens/templates/tokens/token_create.html b/tokens/templates/tokens/token_create.html new file mode 100644 index 00000000..bfb93ff1 --- /dev/null +++ b/tokens/templates/tokens/token_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/tokens/templates/tokens/usertoken_list.html b/tokens/templates/tokens/usertoken_list.html new file mode 100644 index 00000000..df6f2830 --- /dev/null +++ b/tokens/templates/tokens/usertoken_list.html @@ -0,0 +1,43 @@ +{% 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 + + Created At +
{{token.name}}{{token.token}}{{token.created_at}}
+ {% endif %} +
+ + Create Token +
+
+ +{% endblock settings %} diff --git a/tokens/urls.py b/tokens/urls.py new file mode 100644 index 00000000..83b479e9 --- /dev/null +++ b/tokens/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +import tokens.views + + +app_name = 'tokens' +urlpatterns = [ + path('settings/tokens/', tokens.views.TokensView.as_view(), name='list'), + path('tokens/create/', tokens.views.CreateTokenView.as_view(), name='create'), + path('tokens/delete//', tokens.views.DeleteTokenView.as_view(), name='delete'), +] diff --git a/tokens/views.py b/tokens/views.py new file mode 100644 index 00000000..a9d7e99c --- /dev/null +++ b/tokens/views.py @@ -0,0 +1,69 @@ +import logging + +from django import forms +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +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 = 'tokens/token_create.html' + success_message = "Token created successfully" + + def get_success_url(self): + return reverse('tokens: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 + return super().form_valid(form) + + +class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = UserToken + template_name = 'tokens/token_confirm_delete.html' + success_url = reverse_lazy('tokens:list') + success_message = "Token deleted successfully" + + def get_queryset(self): + return super().get_queryset().filter(user=self.request.user) diff --git a/users/templates/users/settings/tabs.html b/users/templates/users/settings/tabs.html index 37278b97..740c1ec9 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="tokens: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" %} -- 2.30.2 From cdf12cdf6092e53dbb3600a83660ad0b3d9f6c1f Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Sat, 18 May 2024 17:01:39 +0200 Subject: [PATCH 2/6] Basic authentication using tokens There is still no API that uses this, but this is working already. --- blender_extensions/settings.py | 1 + tokens/authentication.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 tokens/authentication.py diff --git a/blender_extensions/settings.py b/blender_extensions/settings.py index 3bf3b25a..83a4545c 100644 --- a/blender_extensions/settings.py +++ b/blender_extensions/settings.py @@ -277,6 +277,7 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', + 'tokens.authentication.UserTokenAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), } diff --git a/tokens/authentication.py b/tokens/authentication.py new file mode 100644 index 00000000..5d702b20 --- /dev/null +++ b/tokens/authentication.py @@ -0,0 +1,25 @@ +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from .models import UserToken + + +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 = UserToken.objects.get(token=token_key) + except UserToken.DoesNotExist: + raise AuthenticationFailed('Invalid token') + + return (token.user, token) -- 2.30.2 From 8b17b55080f712bce8a5f7ac922ac4fe756d2106 Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Mon, 20 May 2024 22:59:09 +0200 Subject: [PATCH 3/6] Fix unittest / access to the public API --- extensions/views/api.py | 2 ++ 1 file changed, 2 insertions(+) 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( -- 2.30.2 From f8663bdb7893a470fb62212b0dd3b9c61149385e Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Tue, 21 May 2024 23:21:03 +0200 Subject: [PATCH 4/6] Address review questions * Rename app: tokens > apitokens * Remove id from model * Store last access time and ip (only show time to users) * Store the hash instead of the token * Store the begin of the token for easy identification * Remove other authentication methods * Use a 32 random byte token --- apitokens/__init__.py | 1 + {tokens => apitokens}/apps.py | 2 +- {tokens => apitokens}/authentication.py | 10 +++++++- .../migrations/0001_initial.py | 10 ++++---- {tokens => apitokens}/migrations/__init__.py | 0 apitokens/models.py | 20 ++++++++++++++++ .../apitokens/apitoken_confirm_delete.html | 4 ++-- .../templates/apitokens/apitoken_create.html | 6 ++--- .../templates/apitokens}/usertoken_list.html | 18 +++++++++++---- apitokens/urls.py | 11 +++++++++ {tokens => apitokens}/views.py | 23 +++++++++++++++---- blender_extensions/settings.py | 8 ++----- blender_extensions/urls.py | 2 +- tokens/__init__.py | 1 - tokens/models.py | 20 ---------------- tokens/urls.py | 11 --------- users/templates/users/settings/tabs.html | 2 +- 17 files changed, 89 insertions(+), 60 deletions(-) create mode 100644 apitokens/__init__.py rename {tokens => apitokens}/apps.py (84%) rename {tokens => apitokens}/authentication.py (67%) rename {tokens => apitokens}/migrations/0001_initial.py (59%) rename {tokens => apitokens}/migrations/__init__.py (100%) create mode 100644 apitokens/models.py rename tokens/templates/tokens/token_confirm_delete.html => apitokens/templates/apitokens/apitoken_confirm_delete.html (89%) rename tokens/templates/tokens/token_create.html => apitokens/templates/apitokens/apitoken_create.html (84%) rename {tokens/templates/tokens => apitokens/templates/apitokens}/usertoken_list.html (65%) create mode 100644 apitokens/urls.py rename {tokens => apitokens}/views.py (74%) delete mode 100644 tokens/__init__.py delete mode 100644 tokens/models.py delete mode 100644 tokens/urls.py 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/tokens/apps.py b/apitokens/apps.py similarity index 84% rename from tokens/apps.py rename to apitokens/apps.py index 3557a101..07fa77eb 100644 --- a/tokens/apps.py +++ b/apitokens/apps.py @@ -3,4 +3,4 @@ from django.apps import AppConfig class TokensConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'tokens' + name = 'apitokens' diff --git a/tokens/authentication.py b/apitokens/authentication.py similarity index 67% rename from tokens/authentication.py rename to apitokens/authentication.py index 5d702b20..24c01e88 100644 --- a/tokens/authentication.py +++ b/apitokens/authentication.py @@ -1,6 +1,10 @@ +import datetime +import hashlib + 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): @@ -18,8 +22,12 @@ class UserTokenAuthentication(BaseAuthentication): return None try: - token = UserToken.objects.get(token=token_key) + token_hash = hashlib.sha256(token_key.encode()).hexdigest() + 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() + return (token.user, token) diff --git a/tokens/migrations/0001_initial.py b/apitokens/migrations/0001_initial.py similarity index 59% rename from tokens/migrations/0001_initial.py rename to apitokens/migrations/0001_initial.py index 18ce6327..fab5e458 100644 --- a/tokens/migrations/0001_initial.py +++ b/apitokens/migrations/0001_initial.py @@ -1,9 +1,8 @@ -# Generated by Django 4.2.11 on 2024-05-17 13:15 +# Generated by Django 4.2.11 on 2024-05-21 21:26 from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import uuid class Migration(migrations.Migration): @@ -19,9 +18,12 @@ class Migration(migrations.Migration): name='UserToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('name', models.CharField(max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), + ('token_begin', 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/tokens/migrations/__init__.py b/apitokens/migrations/__init__.py similarity index 100% rename from tokens/migrations/__init__.py rename to apitokens/migrations/__init__.py diff --git a/apitokens/models.py b/apitokens/models.py new file mode 100644 index 00000000..bd9a7afb --- /dev/null +++ b/apitokens/models.py @@ -0,0 +1,20 @@ +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_begin = 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_begin} - {self.name}" diff --git a/tokens/templates/tokens/token_confirm_delete.html b/apitokens/templates/apitokens/apitoken_confirm_delete.html similarity index 89% rename from tokens/templates/tokens/token_confirm_delete.html rename to apitokens/templates/apitokens/apitoken_confirm_delete.html index b6f69daf..2ff847ca 100644 --- a/tokens/templates/tokens/token_confirm_delete.html +++ b/apitokens/templates/apitokens/apitoken_confirm_delete.html @@ -8,11 +8,11 @@ Delete API Token?

- This will delete the token {{ object.name }} created at {{ object.created_at }}
+ 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' %} diff --git a/tokens/templates/tokens/token_create.html b/apitokens/templates/apitokens/apitoken_create.html similarity index 84% rename from tokens/templates/tokens/token_create.html rename to apitokens/templates/apitokens/apitoken_create.html index bfb93ff1..abff4af9 100644 --- a/tokens/templates/tokens/token_create.html +++ b/apitokens/templates/apitokens/apitoken_create.html @@ -14,20 +14,20 @@
{% csrf_token %}
- + {% if form.errors.name %}
{{ form.errors.name }}
{% endif %}
- + {% trans 'Cancel' %}
diff --git a/tokens/templates/tokens/usertoken_list.html b/apitokens/templates/apitokens/usertoken_list.html similarity index 65% rename from tokens/templates/tokens/usertoken_list.html rename to apitokens/templates/apitokens/usertoken_list.html index df6f2830..15fc2ba9 100644 --- a/tokens/templates/tokens/usertoken_list.html +++ b/apitokens/templates/apitokens/usertoken_list.html @@ -15,19 +15,29 @@ Name - Token + Token Begin Created At + + Last Access + {% for token in tokens %} {{token.name}} - {{token.token}} - {{token.created_at}} + {{token.token_begin}}... + {{token.date_created}} + + {% if token.date_last_access %} + {{token.date_last_access}} + {% else %} + - + {% endif %} + {% endfor %} @@ -36,7 +46,7 @@ {% endif %}
- Create Token + Create Token
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/tokens/views.py b/apitokens/views.py similarity index 74% rename from tokens/views.py rename to apitokens/views.py index a9d7e99c..a19e3623 100644 --- a/tokens/views.py +++ b/apitokens/views.py @@ -1,8 +1,11 @@ +import hashlib import logging +import secrets 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 @@ -43,11 +46,13 @@ class UserTokenForm(forms.ModelForm): class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = UserToken form_class = UserTokenForm - template_name = 'tokens/token_create.html' - success_message = "Token created successfully" + 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('tokens:list') + return reverse('apitokens:list') def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -56,13 +61,21 @@ class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView): def form_valid(self, form): form.instance.user = self.request.user + + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode()).hexdigest() + + form.instance.token_hash = token_hash + form.instance.token_begin = token[:5] + + messages.info(self.request, f'Your new token: {token}') return super().form_valid(form) class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): model = UserToken - template_name = 'tokens/token_confirm_delete.html' - success_url = reverse_lazy('tokens:list') + template_name = 'apitokens/apitoken_confirm_delete.html' + success_url = reverse_lazy('apitokens:list') success_message = "Token deleted successfully" def get_queryset(self): diff --git a/blender_extensions/settings.py b/blender_extensions/settings.py index 83a4545c..61abe392 100644 --- a/blender_extensions/settings.py +++ b/blender_extensions/settings.py @@ -60,8 +60,8 @@ INSTALLED_APPS = [ 'rangefilter', 'reviewers', 'stats', + 'apitokens', 'taggit', - 'tokens', 'drf_spectacular', 'drf_spectacular_sidecar', 'rest_framework', @@ -274,11 +274,7 @@ ACTSTREAM_SETTINGS = { REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.BasicAuthentication', - 'tokens.authentication.UserTokenAuthentication', - ), + 'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',), 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), } diff --git a/blender_extensions/urls.py b/blender_extensions/urls.py index c97d2a11..7c64a3a1 100644 --- a/blender_extensions/urls.py +++ b/blender_extensions/urls.py @@ -40,7 +40,7 @@ urlpatterns = [ path('', include('teams.urls')), path('', include('reviewers.urls')), path('', include('notifications.urls')), - path('', include('tokens.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/tokens/__init__.py b/tokens/__init__.py deleted file mode 100644 index 0916ae84..00000000 --- a/tokens/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'tokens.apps.TokensConfig' diff --git a/tokens/models.py b/tokens/models.py deleted file mode 100644 index 4c482bcc..00000000 --- a/tokens/models.py +++ /dev/null @@ -1,20 +0,0 @@ -import uuid - -from django.db import models -from django.urls import reverse - -from users.models import User - - -class UserToken(models.Model): - id = models.AutoField(primary_key=True) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens') - token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) - name = models.CharField(max_length=255) - created_at = models.DateTimeField(auto_now_add=True) - - def get_delete_url(self): - return reverse('tokens:delete', kwargs={'pk': self.pk}) - - def __str__(self): - return f"{self.user.username} - {self.token} - {self.name}" diff --git a/tokens/urls.py b/tokens/urls.py deleted file mode 100644 index 83b479e9..00000000 --- a/tokens/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -import tokens.views - - -app_name = 'tokens' -urlpatterns = [ - path('settings/tokens/', tokens.views.TokensView.as_view(), name='list'), - path('tokens/create/', tokens.views.CreateTokenView.as_view(), name='create'), - path('tokens/delete//', tokens.views.DeleteTokenView.as_view(), name='delete'), -] diff --git a/users/templates/users/settings/tabs.html b/users/templates/users/settings/tabs.html index 740c1ec9..3d5009ec 100644 --- a/users/templates/users/settings/tabs.html +++ b/users/templates/users/settings/tabs.html @@ -4,7 +4,7 @@ {% 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="tokens:list" title="Tokens" classes="i-lock py-2" %} + {% include "common/components/nav_link.html" with name="apitokens:list" title="Tokens" classes="i-lock py-2" %} -- 2.30.2 From 3fae0fec5b8e3075301cc043bfc9609bd4019b02 Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Tue, 21 May 2024 23:53:42 +0200 Subject: [PATCH 5/6] Simplify token message: only show the Token --- apitokens/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apitokens/views.py b/apitokens/views.py index a19e3623..bf808d16 100644 --- a/apitokens/views.py +++ b/apitokens/views.py @@ -68,7 +68,7 @@ class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView): form.instance.token_hash = token_hash form.instance.token_begin = token[:5] - messages.info(self.request, f'Your new token: {token}') + messages.info(self.request, f'{token}') return super().form_valid(form) -- 2.30.2 From 3698eb681afcd19652ff8b4333cbe0924d4070d8 Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Sat, 25 May 2024 11:44:41 +0200 Subject: [PATCH 6/6] Address review questions * Missing save last ip and last access * token_begin > token_prefix * inline if/else * Remove unecessary f-string * Move methods to class * Unittests --- apitokens/authentication.py | 4 +- apitokens/migrations/0001_initial.py | 4 +- apitokens/models.py | 20 ++++- .../templates/apitokens/usertoken_list.html | 8 +- apitokens/tests/__init__.py | 0 apitokens/tests/test_user_token.py | 79 +++++++++++++++++++ apitokens/views.py | 12 +-- 7 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 apitokens/tests/__init__.py create mode 100644 apitokens/tests/test_user_token.py diff --git a/apitokens/authentication.py b/apitokens/authentication.py index 24c01e88..857b7bf8 100644 --- a/apitokens/authentication.py +++ b/apitokens/authentication.py @@ -1,5 +1,4 @@ import datetime -import hashlib from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed @@ -22,12 +21,13 @@ class UserTokenAuthentication(BaseAuthentication): return None try: - token_hash = hashlib.sha256(token_key.encode()).hexdigest() + 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 index fab5e458..4d208dfb 100644 --- a/apitokens/migrations/0001_initial.py +++ b/apitokens/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-05-21 21:26 +# Generated by Django 4.2.11 on 2024-05-25 09:43 from django.conf import settings from django.db import migrations, models @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255)), - ('token_begin', models.CharField(editable=False, max_length=5)), + ('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)), diff --git a/apitokens/models.py b/apitokens/models.py index bd9a7afb..13e9e25f 100644 --- a/apitokens/models.py +++ b/apitokens/models.py @@ -1,3 +1,6 @@ +import hashlib +import secrets + from django.db import models from django.urls import reverse @@ -7,7 +10,7 @@ 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_begin = models.CharField(max_length=5, editable=False) + 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) @@ -17,4 +20,17 @@ class UserToken(models.Model): return reverse('apitokens:delete', kwargs={'pk': self.pk}) def __str__(self): - return f"{self.user.username} - {self.token_begin} - {self.name}" + 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/usertoken_list.html b/apitokens/templates/apitokens/usertoken_list.html index 15fc2ba9..b98e2a01 100644 --- a/apitokens/templates/apitokens/usertoken_list.html +++ b/apitokens/templates/apitokens/usertoken_list.html @@ -29,14 +29,10 @@ {% for token in tokens %} {{token.name}} - {{token.token_begin}}... + {{token.token_prefix}}... {{token.date_created}} - {% if token.date_last_access %} - {{token.date_last_access}} - {% else %} - - - {% endif %} + {{ token.date_last_access|default_if_none:"-" }} 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/views.py b/apitokens/views.py index bf808d16..8e72a5c0 100644 --- a/apitokens/views.py +++ b/apitokens/views.py @@ -1,6 +1,4 @@ -import hashlib import logging -import secrets from django import forms from django.contrib.auth.mixins import LoginRequiredMixin @@ -62,13 +60,11 @@ class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView): def form_valid(self, form): form.instance.user = self.request.user - token = secrets.token_urlsafe(32) - token_hash = hashlib.sha256(token.encode()).hexdigest() + 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) - form.instance.token_hash = token_hash - form.instance.token_begin = token[:5] - - messages.info(self.request, f'{token}') + messages.info(self.request, token_key) return super().form_valid(form) -- 2.30.2