API Tokens #134

Merged
Dalai Felinto merged 6 commits from tokens into main 2024-05-27 12:53:31 +02:00
17 changed files with 398 additions and 0 deletions

1
apitokens/__init__.py Normal file
View File

@ -0,0 +1 @@
default_app_config = 'apitokens.apps.TokensConfig'

6
apitokens/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TokensConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apitokens'

View File

@ -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)
dfelinto marked this conversation as resolved Outdated

this should be a class method on a model, please see a related comment in the CreateTokenView

this should be a class method on a model, please see a related comment in the `CreateTokenView`
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'})
dfelinto marked this conversation as resolved Outdated

+ token.save(update_fields={'ip_address_last_access','date_last_access'})

`+ token.save(update_fields={'ip_address_last_access','date_last_access'})`
return (token.user, token)

View File

@ -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)),
],
),
]

View File

36
apitokens/models.py Normal file
View File

@ -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):
dfelinto marked this conversation as resolved Outdated

"begin" is not a noun, let's use a different name, e.g. token_prefix

"begin" is not a noun, let's use a different name, e.g. `token_prefix`
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]

View File

@ -0,0 +1,30 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-8 mx-auto my-4">
<div class="box">
<h2>
Delete API Token?
</h2>
<p>
This will delete the token <b>{{ object.name }}</b> created at {{ object.date_created }}<br />
Any application which were relying on this token will require a new token.
</p>
<div class="btn-row-fluid">
<a href="{% url 'apitokens:list' %}" class="btn">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-block btn-danger">
<i class="i-trash"></i>
<span>{% trans 'Confirm Deletion' %}</span>
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,37 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-8 mx-auto my-4">
<div class="box">
<h2>
User API Token
</h2>
<p>
Create a new user token to be used with the <a href="{% url 'swagger' %}">API</a>.
</p>
<form method="post">
{% csrf_token %}
<div>
<label for="id_name">API Token Name:</label>
<input type="text" id="id_name" name="name" class="form-control" required>
{% if form.errors.name %}
<div class="warning">{{ form.errors.name }}</div>
{% endif %}
</div>
<div class="btn-row-fluid">
<a href="{% url 'apitokens:list' %}" class="btn">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<button type="submit" class="btn btn-block btn-success">
<i class="i-lock"></i>
<span>{% trans 'Create API Token' %}</span>
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,49 @@
{% extends 'users/settings/base.html' %}
{% block settings %}
<h1 class="mb-3">Tokens</h1>
<div class="row">
<div class="col">
<div>
List of user tokens to automate tasks via the <a href="{% url 'swagger' %}">API</a>.
</div>
{% if tokens %}
<table class="table table-hover">
<thead>
<tr>
<th>
Name
</th>
<th>
Token Begin
</th>
<th>
Created At
</th>
<th>
Last Access
</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td>{{token.name}}</td>
<td>{{token.token_prefix}}...</td>
<td>{{token.date_created}}</td>
<td>
{{ token.date_last_access|default_if_none:"-" }}
</td>
<td><a href="{{ token.get_delete_url }} " class="btn btn-danger i-trash"></a></td>
dfelinto marked this conversation as resolved Outdated
this if-else can be rewritten using https://docs.djangoproject.com/en/4.2/ref/templates/builtins/#default
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<br/>
<a href="{% url 'apitokens:create' %}" class="btn btn-primary">Create Token</a>
</div>
</div>
{% endblock settings %}

View File

View File

@ -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):
Review

I expected to have a different test: a one that triggers the actual code path where date_last_access is supposed to be updated.
It would catch the bug with the missing save that was fixed during review.

It might be easy to add it in the other PR, where we have a new endpoint that exercises that code path organically.

I expected to have a different test: a one that triggers the actual code path where date_last_access is supposed to be updated. It would catch the bug with the missing `save` that was fixed during review. It might be easy to add it in the other PR, where we have a new endpoint that exercises that code path organically.
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)

11
apitokens/urls.py Normal file
View File

@ -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/<int:pk>/', apitokens.views.DeleteTokenView.as_view(), name='delete'),
]

78
apitokens/views.py Normal file
View File

@ -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: <b>{name}</b>.')
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)
dfelinto marked this conversation as resolved Outdated

this should be defined in the model code (e.g. as a class method), then it will be possible to write tests that generate tokens

this should be defined in the model code (e.g. as a class method), then it will be possible to write tests that generate tokens
dfelinto marked this conversation as resolved Outdated

let's hide this implementation in a model class method, then the api will use it as well

let's hide this implementation in a model class method, then the api will use it as well
messages.info(self.request, token_key)
return super().form_valid(form)
class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
dfelinto marked this conversation as resolved Outdated

what is the purpose of wrapping token in an f-string here?

what is the purpose of wrapping token in an f-string here?
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)

View File

@ -60,6 +60,7 @@ INSTALLED_APPS = [
'rangefilter', 'rangefilter',
'reviewers', 'reviewers',
'stats', 'stats',
'apitokens',
'taggit', 'taggit',
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
} }
dfelinto marked this conversation as resolved Outdated

why do we need SessionAuthentication and BasicAuthentication?

if we plan to use only our own UserTokenAuthentication, checking the header on each request, we won't need the other mechanisms

why do we need SessionAuthentication and BasicAuthentication? if we plan to use only our own UserTokenAuthentication, checking the header on each request, we won't need the other mechanisms
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {

View File

@ -40,6 +40,7 @@ urlpatterns = [
path('', include('teams.urls')), path('', include('teams.urls')),
path('', include('reviewers.urls')), path('', include('reviewers.urls')),
path('', include('notifications.urls')), path('', include('notifications.urls')),
path('', include('apitokens.urls')),
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')), path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'), path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'), path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),

View File

@ -1,5 +1,6 @@
import logging import logging
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import serializers from rest_framework import serializers
from rest_framework.views import APIView from rest_framework.views import APIView
@ -104,6 +105,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
class ExtensionsAPIView(APIView): class ExtensionsAPIView(APIView):
permission_classes = [AllowAny]
serializer_class = ListedExtensionsSerializer serializer_class = ListedExtensionsSerializer
@extend_schema( @extend_schema(

View File

@ -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="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" %}
<div class="nav-pills-divider"></div> <div class="nav-pills-divider"></div>
{% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %} {% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}