1
0
Fork 0
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:
catborise 2026-10-24 23:33:12 +03:00
parent d9088b410e
commit 6b51c30dd6
8 changed files with 85 additions and 2 deletions

24
accounts/auth.py Normal file
View 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"

View 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'),
),
]

View file

@ -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"]

View file

@ -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' %}

View file

@ -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"])

View file

@ -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)

View file

@ -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.

View file

@ -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):