diff --git a/accounts/auth.py b/accounts/auth.py
new file mode 100644
index 00000000..0e17d23c
--- /dev/null
+++ b/accounts/auth.py
@@ -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"
diff --git a/accounts/migrations/0010_api_token_last_used.py b/accounts/migrations/0010_api_token_last_used.py
new file mode 100644
index 00000000..82102cfd
--- /dev/null
+++ b/accounts/migrations/0010_api_token_last_used.py
@@ -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'),
+ ),
+ ]
diff --git a/accounts/models.py b/accounts/models.py
index 495a3f4d..dcdcaaf8 100644
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -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"]
diff --git a/accounts/templates/profile.html b/accounts/templates/profile.html
index 9e861fbd..2d4396f7 100644
--- a/accounts/templates/profile.html
+++ b/accounts/templates/profile.html
@@ -96,6 +96,7 @@
{% trans "Starts with" %} |
{% trans "Created" %} |
{% trans "Expires" %} |
+ {% trans "Last used" %} |
|
@@ -106,6 +107,7 @@
{{ token.token_key }} |
{{ token.created|date:"Y-m-d H:i" }} |
{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %} |
+ {% if token.last_used %}{{ token.last_used|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %} |
{% bs_icon 'trash' %}
diff --git a/accounts/tests/test_api_tokens.py b/accounts/tests/test_api_tokens.py
index 44ff822d..4afe4cd0 100644
--- a/accounts/tests/test_api_tokens.py
+++ b/accounts/tests/test_api_tokens.py
@@ -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"])
diff --git a/accounts/views.py b/accounts/views.py
index af0aa9b6..300b56e2 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -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)
diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template
index d29e8006..55b79d1c 100644
--- a/webvirtcloud/settings.py.template
+++ b/webvirtcloud/settings.py.template
@@ -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.
diff --git a/webvirtcloud/tests/test_otp_bypass.py b/webvirtcloud/tests/test_otp_bypass.py
index ce05fdbb..6e21d014 100644
--- a/webvirtcloud/tests/test_otp_bypass.py
+++ b/webvirtcloud/tests/test_otp_bypass.py
@@ -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):
|