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 89 additions and 60 deletions
Showing only changes of commit f8663bdb78 - Show all commits

1
apitokens/__init__.py Normal file
View File

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

View File

@ -3,4 +3,4 @@ from django.apps import AppConfig
class TokensConfig(AppConfig): class TokensConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'tokens' name = 'apitokens'

View File

@ -1,6 +1,10 @@
import datetime
import hashlib
from rest_framework.authentication import BaseAuthentication from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from .models import UserToken from .models import UserToken
from utils import clean_ip_address
class UserTokenAuthentication(BaseAuthentication): class UserTokenAuthentication(BaseAuthentication):
@ -18,8 +22,12 @@ class UserTokenAuthentication(BaseAuthentication):
return None return None
try: try:
token = UserToken.objects.get(token=token_key) token_hash = hashlib.sha256(token_key.encode()).hexdigest()
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`
token = UserToken.objects.get(token_hash=token_hash)
except UserToken.DoesNotExist: except UserToken.DoesNotExist:
raise AuthenticationFailed('Invalid token') raise AuthenticationFailed('Invalid token')
token.ip_address_last_access = clean_ip_address(request)
token.date_last_access = datetime.datetime.now()
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) return (token.user, token)

View File

@ -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.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import uuid
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -19,9 +18,12 @@ class Migration(migrations.Migration):
name='UserToken', name='UserToken',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
], ],
), ),

20
apitokens/models.py Normal file
View File

@ -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)
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`
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}"

View File

@ -8,11 +8,11 @@
Delete API Token? Delete API Token?
</h2> </h2>
<p> <p>
This will delete the token <b>{{ object.name }}</b> created at {{ object.created_at }}<br /> 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. Any application which were relying on this token will require a new token.
</p> </p>
<div class="btn-row-fluid"> <div class="btn-row-fluid">
<a href="{% url 'tokens:list' %}" class="btn"> <a href="{% url 'apitokens:list' %}" class="btn">
<i class="i-cancel"></i> <i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span> <span>{% trans 'Cancel' %}</span>
</a> </a>

View File

@ -14,20 +14,20 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div> <div>
<label for="id_name">Token Name:</label> <label for="id_name">API Token Name:</label>
<input type="text" id="id_name" name="name" class="form-control" required> <input type="text" id="id_name" name="name" class="form-control" required>
{% if form.errors.name %} {% if form.errors.name %}
<div class="warning">{{ form.errors.name }}</div> <div class="warning">{{ form.errors.name }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="btn-row-fluid"> <div class="btn-row-fluid">
<a href="{% url 'tokens:list' %}" class="btn"> <a href="{% url 'apitokens:list' %}" class="btn">
<i class="i-cancel"></i> <i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span> <span>{% trans 'Cancel' %}</span>
</a> </a>
<button type="submit" class="btn btn-block btn-success"> <button type="submit" class="btn btn-block btn-success">
<i class="i-lock"></i> <i class="i-lock"></i>
<span>{% trans 'Create Token' %}</span> <span>{% trans 'Create API Token' %}</span>
</button> </button>
</div> </div>
</form> </form>

View File

@ -15,19 +15,29 @@
Name Name
</th> </th>
<th> <th>
Token Token Begin
</th> </th>
<th> <th>
Created At Created At
</th> </th>
<th>
Last Access
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for token in tokens %} {% for token in tokens %}
<tr> <tr>
<td>{{token.name}}</td> <td>{{token.name}}</td>
<td>{{token.token}}</td> <td>{{token.token_begin}}...</td>
<td>{{token.created_at}}</td> <td>{{token.date_created}}</td>
<td>
{% if token.date_last_access %}
{{token.date_last_access}}
{% else %}
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
-
{% endif %}
</td>
<td><a href="{{ token.get_delete_url }} " class="btn btn-danger i-trash"></a></td> <td><a href="{{ token.get_delete_url }} " class="btn btn-danger i-trash"></a></td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -36,7 +46,7 @@
{% endif %} {% endif %}
<br/> <br/>
<a href="{% url 'tokens:create' %}" class="btn btn-primary">Create Token</a> <a href="{% url 'apitokens:create' %}" class="btn btn-primary">Create Token</a>
</div> </div>
</div> </div>

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

View File

@ -1,8 +1,11 @@
import hashlib
import logging import logging
import secrets
from django import forms from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.views.generic import ListView, DeleteView from django.views.generic import ListView, DeleteView
from django.shortcuts import reverse from django.shortcuts import reverse
@ -43,11 +46,13 @@ class UserTokenForm(forms.ModelForm):
class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = UserToken model = UserToken
form_class = UserTokenForm form_class = UserTokenForm
template_name = 'tokens/token_create.html' template_name = 'apitokens/apitoken_create.html'
success_message = "Token created successfully" success_message = (
"Your new token has been generated. Copy it now as it will not be shown again."
)
def get_success_url(self): def get_success_url(self):
return reverse('tokens:list') return reverse('apitokens:list')
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
@ -56,13 +61,21 @@ class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
form.instance.user = self.request.user form.instance.user = self.request.user
token = secrets.token_urlsafe(32)
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
token_hash = hashlib.sha256(token.encode()).hexdigest()
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
form.instance.token_hash = token_hash
form.instance.token_begin = token[:5]
messages.info(self.request, f'Your new token: {token}')
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?
return super().form_valid(form) return super().form_valid(form)
class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = UserToken model = UserToken
template_name = 'tokens/token_confirm_delete.html' template_name = 'apitokens/apitoken_confirm_delete.html'
success_url = reverse_lazy('tokens:list') success_url = reverse_lazy('apitokens:list')
success_message = "Token deleted successfully" success_message = "Token deleted successfully"
def get_queryset(self): def get_queryset(self):

View File

@ -60,8 +60,8 @@ INSTALLED_APPS = [
'rangefilter', 'rangefilter',
'reviewers', 'reviewers',
'stats', 'stats',
'apitokens',
'taggit', 'taggit',
'tokens',
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
'rest_framework', 'rest_framework',
@ -274,11 +274,7 @@ ACTSTREAM_SETTINGS = {
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
'tokens.authentication.UserTokenAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), '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

View File

@ -40,7 +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('tokens.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 +0,0 @@
default_app_config = 'tokens.apps.TokensConfig'

View File

@ -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}"

View File

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

View File

@ -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="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" %}
<div class="nav-pills-divider"></div> <div class="nav-pills-divider"></div>