API Tokens #134

Merged
Dalai Felinto merged 6 commits from tokens into main 2024-05-27 12:53:31 +02:00
13 changed files with 254 additions and 0 deletions
Showing only changes of commit 0a58aa7562 - Show all commits

View File

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

1
tokens/__init__.py Normal file
View File

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

6
tokens/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 = 'tokens'
dfelinto marked this conversation as resolved Outdated

this is a very generic name, let's make it more specific, e.g. apitokens, apikeys or userapitokens

this is a very generic name, let's make it more specific, e.g. `apitokens`, `apikeys` or `userapitokens`

View File

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

View File

20
tokens/models.py Normal file
View File

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

none of our models specify the id field explicitly, but rely on default_auto_field = 'django.db.models.BigAutoField' defined in each of the apps.py files instead

can we skip this definition here, and use the BigAutoField inherited from the default?

none of our models specify the `id` field explicitly, but rely on `default_auto_field = 'django.db.models.BigAutoField'` defined in each of the `apps.py` files instead can we skip this definition here, and use the BigAutoField inherited from the default?
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

@ -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.created_at }}<br />
Any application which were relying on this token will require a new token.
</p>
<div class="btn-row-fluid">
<a href="{% url 'tokens: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">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 'tokens: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 Token' %}</span>
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,43 @@
{% 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
</th>
<th>
Created At
</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td>{{token.name}}</td>
<td>{{token.token}}</td>
dfelinto marked this conversation as resolved Outdated

A common advice and a best practice is to avoid displaying a full token after it was initially generated.

This also stems from the approach to avoid storing tokens in plain text, treating them as passwords: this means the server has a full token value only right after the token was generated.
This original value is displayed to the user just once (to be copied somewhere safe), and only a hash and a prefix (for convenience) gets stored in the database.

A common advice and a best practice is to avoid displaying a full token after it was initially generated. This also stems from the approach to avoid storing tokens in plain text, treating them as passwords: this means the server has a full token value only right after the token was generated. This original value is displayed to the user just once (to be copied somewhere safe), and only a hash and a prefix (for convenience) gets stored in the database.
<td>{{token.created_at}}</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 'tokens:create' %}" class="btn btn-primary">Create Token</a>
</div>
</div>
{% endblock settings %}

11
tokens/urls.py Normal file
View File

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

69
tokens/views.py Normal file
View File

@ -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: <b>{name}</b>.')
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)

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="tokens: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" %}