From 6b51c30dd60a661cf57f53771c19ab4f6cd9b0e8 Mon Sep 17 00:00:00 2001 From: catborise Date: Sat, 24 Oct 2026 23:33:12 +0300 Subject: [PATCH] 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. --- accounts/auth.py | 24 ++++++++++++++ .../migrations/0010_api_token_last_used.py | 18 +++++++++++ accounts/models.py | 3 ++ accounts/templates/profile.html | 2 ++ accounts/tests/test_api_tokens.py | 31 +++++++++++++++++++ accounts/views.py | 5 +++ webvirtcloud/settings.py.template | 2 +- webvirtcloud/tests/test_otp_bypass.py | 2 +- 8 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 accounts/auth.py create mode 100644 accounts/migrations/0010_api_token_last_used.py 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):