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

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):
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>
</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)
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)

View File

@ -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 = {

View File

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

View File

@ -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(

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="apitokens:list" title="Tokens" classes="i-lock py-2" %}
<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" %}