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):
|
class TokensConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
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.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
|
|||||||
|
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
Oleg-Komarov
commented
`+ token.save(update_fields={'ip_address_last_access','date_last_access'})`
|
|||||||
return (token.user, token)
|
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.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
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?
|
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>
|
@ -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>
|
@ -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
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>
|
<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
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 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
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)
|
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):
|
@ -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
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('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'),
|
||||||
|
@ -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="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>
|
||||||
|
|
||||||
|
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