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()
|
||||||
|
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)
|
@ -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)
|
||||||
|
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 %}
|
||||||
|
-
|
||||||
|
{% 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)
|
||||||
|
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):
|
@ -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',),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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