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()
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()
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)
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 %}
-
{% 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)
token_hash = hashlib.sha256(token.encode()).hexdigest()
form.instance.token_hash = token_hash
form.instance.token_begin = token[:5]
messages.info(self.request, f'Your new token: {token}')
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',),
} }

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>