API Tokens #134
1
apitokens/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'apitokens.apps.TokensConfig'
|
6
apitokens/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TokensConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apitokens'
|
33
apitokens/authentication.py
Normal 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
|
||||
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
Oleg-Komarov
commented
`+ token.save(update_fields={'ip_address_last_access','date_last_access'})`
|
||||
return (token.user, token)
|
30
apitokens/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
apitokens/migrations/__init__.py
Normal file
36
apitokens/models.py
Normal 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
Oleg-Komarov
commented
"begin" is not a noun, let's use a different name, e.g. "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]
|
30
apitokens/templates/apitokens/apitoken_confirm_delete.html
Normal 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 %}
|
37
apitokens/templates/apitokens/apitoken_create.html
Normal 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 %}
|
49
apitokens/templates/apitokens/usertoken_list.html
Normal 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
Oleg-Komarov
commented
this if-else can be rewritten using https://docs.djangoproject.com/en/4.2/ref/templates/builtins/#default 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 %}
|
0
apitokens/tests/__init__.py
Normal file
79
apitokens/tests/test_user_token.py
Normal 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):
|
||||
Oleg-Komarov
commented
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 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
@ -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
@ -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
Oleg-Komarov
commented
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
Oleg-Komarov
commented
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
Oleg-Komarov
commented
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)
|
@ -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',),
|
||||
}
|
||||
dfelinto marked this conversation as resolved
Outdated
Oleg-Komarov
commented
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 = {
|
||||
|
@ -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'),
|
||||
|
@ -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(
|
||||
|
@ -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" %}
|
||||
|
this should be a class method on a model, please see a related comment in the
CreateTokenView