API Tokens #134
@ -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
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
|
@ -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
@ -0,0 +1 @@
|
||||
default_app_config = 'tokens.apps.TokensConfig'
|
6
tokens/apps.py
Normal 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
Oleg-Komarov
commented
this is a very generic name, let's make it more specific, e.g. this is a very generic name, let's make it more specific, e.g. `apitokens`, `apikeys` or `userapitokens`
|
28
tokens/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
tokens/migrations/__init__.py
Normal file
20
tokens/models.py
Normal 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
Oleg-Komarov
commented
none of our models specify the 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}"
|
30
tokens/templates/tokens/token_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.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 %}
|
37
tokens/templates/tokens/token_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">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 %}
|
43
tokens/templates/tokens/usertoken_list.html
Normal 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
Oleg-Komarov
commented
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. 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
@ -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
@ -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)
|
@ -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" %}
|
||||
|
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