1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

feat(accounts): a superuser sees and deletes a user's API tokens

The user page a superuser opens from the user list has an API Tokens
tab: the user's tokens with their name, first characters, creation,
expiry and last use, and a delete button for one that may have leaked.
The token itself is never shown; only its digest is kept.

The delete reuses the owner's delete view with the user's id; only a
superuser may name another user. The confirmation and the log row say
whose token it was ("API token backup of alice"); the owner's own
confirmation now reads "Delete "API token backup"?" too. The profile
and the user page share one token table, and one script that opens a
tab named in the address (#apitokens).
This commit is contained in:
catborise 2026-10-25 13:42:15 +03:00
parent e7361561aa
commit 85b5c769e9
7 changed files with 124 additions and 43 deletions

View file

@ -34,6 +34,11 @@
{% trans "Public Keys" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#apitokens" type="button" role="tab" aria-controls="apitokens" aria-selected="false">
{% trans "API Tokens" %}
</button>
</li>
</ul>
<div class="tab-content">
@ -94,5 +99,15 @@
</table>
</div>
</div>
<div class="tab-pane fade" id="apitokens">
{% if api_tokens %}
{% include "api_token_table.html" with owner=user %}
{% else %}
<p class="text-body-secondary mt-3">{% trans "No API tokens." %}</p>
{% endif %}
</div>
</div>
{% endblock content %}
{% block script %}
{% include "tab_from_hash.html" %}
{% endblock %}

View file

@ -0,0 +1,33 @@
{% load i18n %}
{% load bootstrap_icons %}
{# the tokens of the signed-in user, or of owner on a superuser's view of a user #}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "Name" %}</th>
<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>
<tbody>
{% for token in api_tokens %}
<tr>
<td>{{ token.name }}</td>
<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="{% if owner %}{% url 'accounts:account_api_token_delete' owner.id token.token_key %}{% else %}{% url 'accounts:api_token_delete' token.token_key %}{% endif %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View file

@ -88,36 +88,7 @@
<div class="tab-pane tab-pane-bordered fade" id="apitokens">
<p class="text-body-secondary">{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as <code>Authorization: Token &lt;token&gt;</code>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}</p>
{% if api_tokens %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "Name" %}</th>
<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>
<tbody>
{% for token in api_tokens %}
<tr>
<td>{{ token.name }}</td>
<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' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "api_token_table.html" %}
{% endif %}
<div class="card">
<div class="card-header">
@ -139,11 +110,5 @@
</div>
{% endblock %}
{% block script %}
<script>
// a link to a tab (#apitokens) opens it
var linked = location.hash.length > 1 && document.querySelector('[data-bs-target="#' + CSS.escape(location.hash.slice(1)) + '"]');
if (linked) {
bootstrap.Tab.getOrCreateInstance(linked).show();
}
</script>
{% endblock %}
{% include "tab_from_hash.html" %}
{% endblock %}

View file

@ -0,0 +1,7 @@
<script>
// a link to a tab (#apitokens) opens it
var linked = location.hash.length > 1 && document.querySelector('[data-bs-target="#' + CSS.escape(location.hash.slice(1)) + '"]');
if (linked) {
bootstrap.Tab.getOrCreateInstance(linked).show();
}
</script>

View file

@ -129,3 +129,50 @@ class ApiTokenTests(TestCase):
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"])
class AdminApiTokenTests(TestCase):
"""A superuser sees a user's tokens on the user's page and deletes one
that may have leaked."""
def setUp(self):
self.user = get_user_model().objects.create_user("tok-user", password="pw")
self.admin = get_user_model().objects.create_superuser("tok-admin", "admin@example.com", "pw")
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ansible", "days": "90"})
self.token = re.search(r"wvc_[0-9a-f]{64}", response.content.decode()).group(0)
self.stored = ApiToken.objects.get(user=self.user)
def delete_url(self, user):
return reverse("accounts:account_api_token_delete", args=[user.id, self.stored.token_key])
def test_the_users_page_lists_the_tokens_without_them(self):
self.client.force_login(self.admin)
page = self.client.get(reverse("accounts:account", args=[self.user.id])).content.decode()
self.assertIn("ansible", page)
self.assertIn(self.stored.token_key, page)
self.assertIn(self.delete_url(self.user), page)
self.assertNotIn(self.token, page)
def test_a_superuser_deletes_a_users_token(self):
self.client.force_login(self.admin)
# the confirmation names whose token it is
self.assertIn("API token ansible of tok-user", self.client.get(self.delete_url(self.user)).content.decode())
self.assertRedirects(self.client.post(self.delete_url(self.user)),
reverse("accounts:account", args=[self.user.id]) + "#apitokens",
fetch_redirect_response=False)
self.assertFalse(ApiToken.objects.exists())
self.assertEqual(Logs.objects.order_by("id").values_list("message", "user").last(),
("API token ansible of tok-user: delete", "tok-admin"))
self.client.logout()
self.assertEqual(self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION=f"Token {self.token}").status_code, 401)
def test_only_a_superuser_deletes_another_users_token(self):
other = get_user_model().objects.create_user("tok-other", password="pw")
self.client.force_login(other)
self.assertEqual(self.client.post(self.delete_url(self.user)).status_code, 403)
# nor through the owner's own address: it names the caller's tokens only
self.assertEqual(self.client.post(reverse("accounts:api_token_delete", args=[self.stored.token_key])).status_code, 404)
self.client.force_login(self.admin)
self.assertEqual(self.client.post(self.delete_url(other)).status_code, 404)
self.assertTrue(ApiToken.objects.exists())

View file

@ -12,6 +12,8 @@ urlpatterns = [
path("logout/", LogoutView.as_view(template_name="logout.html"), name="logout"),
path("profile/", views.profile, name="profile"),
path("profile/<int:user_id>/", views.account, name="account"),
path("profile/<int:user_id>/api_token/<str:token_key>/delete/", views.api_token_delete,
name="account_api_token_delete"),
path("change_password/", views.change_password, name="change_password"),
path(
"user_instance/create/<int:user_id>/",

View file

@ -77,19 +77,30 @@ def api_token_create(request):
return render(request, "api_token_created.html", {"name": name, "key": key})
def api_token_delete(request, token_key):
token = get_object_or_404(ApiToken, token_key=token_key, user=request.user)
def api_token_delete(request, token_key, user_id=None):
"""Delete one of the user's own tokens or, given user_id, a superuser
deletes one of that user's."""
if user_id is None:
token = get_object_or_404(ApiToken, token_key=token_key, user=request.user)
subject = f"API token {token.name}"
back = reverse("accounts:profile")
elif request.user.is_superuser:
token = get_object_or_404(ApiToken, token_key=token_key, user_id=user_id)
subject = f"API token {token.name} of {token.user.username}"
back = reverse("accounts:account", args=[user_id])
else:
raise PermissionDenied
if request.method == "POST":
with audit(request, None, f"API token {token.name}", "delete"):
with audit(request, None, subject, "delete"):
token.delete()
messages.success(request, _("API token deleted"))
return redirect(reverse("accounts:profile") + "#apitokens")
return redirect(back + "#apitokens")
return render(
request,
"common/confirm_delete.html",
{
"object": token.name,
"object": subject,
"title": _("Delete API token"),
"consequence": _("Scripts and tools that use it can no longer reach the API."),
},
@ -148,6 +159,7 @@ def account(request, user_id):
"user_insts": user_insts,
"instances": instances,
"publickeys": publickeys,
"api_tokens": ApiToken.objects.filter(user_id=user_id),
"otp_enabled": settings.OTP_ENABLED,
},
)