mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(accounts): API tokens show their last use, and a user has at most ten
The API Tokens tab gets a "Last used" column, so an unused token can be told from one a script still needs. The token's last use is written when it authenticates, at most once a minute (accounts.auth.TokenAuthentication, knox's class with that one addition; the API schema describes it as knox's). A user has at most ten tokens; a new one is refused until one is deleted. Expired tokens do not count, and are deleted when a new token is made.
This commit is contained in:
parent
d9088b410e
commit
6b51c30dd6
8 changed files with 85 additions and 2 deletions
24
accounts/auth.py
Normal file
24
accounts/auth.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""API token authentication: knox's, noting when each token was last used."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.contrib.knox_auth_token import KnoxTokenScheme
|
||||
from knox.auth import TokenAuthentication as KnoxTokenAuthentication
|
||||
|
||||
# a token's last use is written at most once a minute, not on every request
|
||||
LAST_USED_EVERY = timedelta(minutes=1)
|
||||
|
||||
|
||||
class TokenAuthentication(KnoxTokenAuthentication):
|
||||
def validate_user(self, auth_token):
|
||||
user, token = super().validate_user(auth_token)
|
||||
now = timezone.now()
|
||||
if token.last_used is None or now - token.last_used >= LAST_USED_EVERY:
|
||||
type(token).objects.filter(pk=token.pk).update(last_used=now)
|
||||
return user, token
|
||||
|
||||
|
||||
class TokenScheme(KnoxTokenScheme):
|
||||
"""The API schema's description of these tokens, as of knox's."""
|
||||
target_class = "accounts.auth.TokenAuthentication"
|
||||
18
accounts/migrations/0010_api_token_last_used.py
Normal file
18
accounts/migrations/0010_api_token_last_used.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.18 on 2026-10-24 20:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_api_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='apitoken',
|
||||
name='last_used',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='last used'),
|
||||
),
|
||||
]
|
||||
|
|
@ -87,7 +87,10 @@ class PermissionSet(models.Model):
|
|||
class ApiToken(AbstractAuthToken):
|
||||
"""A token for the REST API, kept as a digest (django-rest-knox); the
|
||||
name says what uses it."""
|
||||
LIMIT = 10 # per user, expired ones not counted
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
last_used = models.DateTimeField(_("last used"), null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created"]
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
<th scope="col">{% trans "Starts with" %}</th>
|
||||
<th scope="col">{% trans "Created" %}</th>
|
||||
<th scope="col">{% trans "Expires" %}</th>
|
||||
<th scope="col">{% trans "Last used" %}</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -106,6 +107,7 @@
|
|||
<td><code>{{ token.token_key }}</code></td>
|
||||
<td>{{ token.created|date:"Y-m-d H:i" }}</td>
|
||||
<td>{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</td>
|
||||
<td>{% if token.last_used %}{{ token.last_used|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'accounts:api_token_delete' token.token_key %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
|
||||
{% bs_icon 'trash' %}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ passed the login (and its OTP), and scripts use them on the REST API."""
|
|||
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.models import ApiToken
|
||||
from django.contrib.auth import get_user_model
|
||||
|
|
@ -98,3 +99,33 @@ class ApiTokenTests(TestCase):
|
|||
# would reference auth_user unseen, and PostgreSQL could not empty
|
||||
# auth_user between tests
|
||||
self.assertNotIn("knox_authtoken", connection.introspection.table_names())
|
||||
def test_a_token_shows_when_it_was_last_used(self):
|
||||
token = self.make()
|
||||
self.assertIsNone(ApiToken.objects.get().last_used)
|
||||
self.api(token)
|
||||
first = ApiToken.objects.get().last_used
|
||||
self.assertAlmostEqual(first, timezone.now(), delta=timedelta(seconds=5))
|
||||
# within a minute a request writes nothing
|
||||
self.api(token)
|
||||
self.assertEqual(ApiToken.objects.get().last_used, first)
|
||||
later = timezone.now() + timedelta(minutes=2)
|
||||
with patch("accounts.auth.timezone.now", return_value=later):
|
||||
self.api(token)
|
||||
self.assertEqual(ApiToken.objects.get().last_used, later)
|
||||
self.client.force_login(self.user)
|
||||
self.assertIn("Last used", self.client.get(reverse("accounts:profile")).content.decode())
|
||||
|
||||
def test_a_user_has_at_most_ten_tokens(self):
|
||||
for number in range(10):
|
||||
self.make(name=f"t{number}")
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": "one more", "days": "30"})
|
||||
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 10)
|
||||
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")
|
||||
self.assertIn("at most 10", response.content.decode())
|
||||
|
||||
def test_expired_tokens_do_not_count_toward_the_limit(self):
|
||||
for number in range(10):
|
||||
self.make(name=f"t{number}")
|
||||
ApiToken.objects.filter(name="t0").update(expiry=timezone.now() - timedelta(days=1))
|
||||
self.make(name="new")
|
||||
self.assertEqual(sorted(ApiToken.objects.values_list("name", flat=True))[:2], ["new", "t1"])
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||
from django.urls import reverse
|
||||
from django.core.cache import cache
|
||||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
|
|
@ -66,6 +67,10 @@ def api_token_create(request):
|
|||
form = ApiTokenForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
|
||||
ApiToken.objects.filter(user=request.user, expiry__lt=timezone.now()).delete()
|
||||
if ApiToken.objects.filter(user=request.user).count() >= ApiToken.LIMIT:
|
||||
form.add_error(None, _("A user has at most %(limit)s API tokens: delete one first.") % {"limit": ApiToken.LIMIT})
|
||||
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
|
||||
name, days = form.cleaned_data["name"], form.cleaned_data["days"]
|
||||
with audit(request, None, f"API token {name}", "add"):
|
||||
_token, key = ApiToken.objects.create(request.user, expiry=timedelta(days=days) if days else None, name=name)
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ REST_FRAMEWORK = {
|
|||
# tokens are made in a session. The token class comes first, so a refused
|
||||
# request gets 401 with its challenge, not 403.
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"knox.auth.TokenAuthentication",
|
||||
"accounts.auth.TokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
# A view that forgets its own permission_classes is not public.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class BasicAuthDisabledTestCase(TestCase):
|
|||
# A token is no other door: it is made in a session that passed the
|
||||
# login and its OTP (accounts.views.api_token_create)
|
||||
classes = [f"{c.__module__}.{c.__name__}" for c in api_settings.DEFAULT_AUTHENTICATION_CLASSES]
|
||||
self.assertEqual(classes, ["knox.auth.TokenAuthentication",
|
||||
self.assertEqual(classes, ["accounts.auth.TokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication"])
|
||||
|
||||
def test_api_rejects_http_basic_credentials(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue