API Tokens #134
1
apitokens/__init__.py
Normal file
1
apitokens/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'apitokens.apps.TokensConfig'
|
@ -3,4 +3,4 @@ from django.apps import AppConfig
|
||||
|
||||
class TokensConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'tokens'
|
||||
name = 'apitokens'
|
@ -1,6 +1,10 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
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):
|
||||
@ -18,8 +22,12 @@ class UserTokenAuthentication(BaseAuthentication):
|
||||
return None
|
||||
|
||||
try:
|
||||
token = UserToken.objects.get(token=token_key)
|
||||
token_hash = hashlib.sha256(token_key.encode()).hexdigest()
|
||||
dfelinto marked this conversation as resolved
Outdated
|
||||
token = UserToken.objects.get(token_hash=token_hash)
|
||||
except UserToken.DoesNotExist:
|
||||
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
Oleg-Komarov
commented
`+ token.save(update_fields={'ip_address_last_access','date_last_access'})`
|
||||
return (token.user, token)
|
@ -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.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -19,9 +18,12 @@ class Migration(migrations.Migration):
|
||||
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)),
|
||||
('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)),
|
||||
],
|
||||
),
|
20
apitokens/models.py
Normal file
20
apitokens/models.py
Normal 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
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`
|
||||
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}"
|
@ -8,11 +8,11 @@
|
||||
Delete API Token?
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
<div class="btn-row-fluid">
|
||||
<a href="{% url 'tokens:list' %}" class="btn">
|
||||
<a href="{% url 'apitokens:list' %}" class="btn">
|
||||
<i class="i-cancel"></i>
|
||||
<span>{% trans 'Cancel' %}</span>
|
||||
</a>
|
@ -14,20 +14,20 @@
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<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>
|
||||
{% 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">
|
||||
<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 Token' %}</span>
|
||||
<span>{% trans 'Create API Token' %}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@ -15,19 +15,29 @@
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Token
|
||||
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}}</td>
|
||||
<td>{{token.created_at}}</td>
|
||||
<td>{{token.token_begin}}...</td>
|
||||
<td>{{token.date_created}}</td>
|
||||
<td>
|
||||
{% if token.date_last_access %}
|
||||
{{token.date_last_access}}
|
||||
{% else %}
|
||||
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
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ token.get_delete_url }} " class="btn btn-danger i-trash"></a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -36,7 +46,7 @@
|
||||
{% endif %}
|
||||
<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>
|
||||
|
11
apitokens/urls.py
Normal file
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'),
|
||||
]
|
@ -1,8 +1,11 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
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
|
||||
@ -43,11 +46,13 @@ class UserTokenForm(forms.ModelForm):
|
||||
class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = UserToken
|
||||
form_class = UserTokenForm
|
||||
template_name = 'tokens/token_create.html'
|
||||
success_message = "Token created successfully"
|
||||
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('tokens:list')
|
||||
return reverse('apitokens:list')
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
@ -56,13 +61,21 @@ class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
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
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
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
|
||||
|
||||
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
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?
|
||||
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')
|
||||
template_name = 'apitokens/apitoken_confirm_delete.html'
|
||||
success_url = reverse_lazy('apitokens:list')
|
||||
success_message = "Token deleted successfully"
|
||||
|
||||
def get_queryset(self):
|
@ -60,8 +60,8 @@ INSTALLED_APPS = [
|
||||
'rangefilter',
|
||||
'reviewers',
|
||||
'stats',
|
||||
'apitokens',
|
||||
'taggit',
|
||||
'tokens',
|
||||
'drf_spectacular',
|
||||
'drf_spectacular_sidecar',
|
||||
'rest_framework',
|
||||
@ -274,11 +274,7 @@ ACTSTREAM_SETTINGS = {
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'tokens.authentication.UserTokenAuthentication',
|
||||
),
|
||||
'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
|
||||
|
||||
|
@ -40,7 +40,7 @@ urlpatterns = [
|
||||
path('', include('teams.urls')),
|
||||
path('', include('reviewers.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/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
||||
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
||||
|
@ -1 +0,0 @@
|
||||
default_app_config = 'tokens.apps.TokensConfig'
|
@ -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}"
|
@ -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'),
|
||||
]
|
@ -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="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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user
this should be a class method on a model, please see a related comment in the
CreateTokenView