diff --git a/accounts/templates/account.html b/accounts/templates/account.html
index 2e52c967..083fe494 100644
--- a/accounts/templates/account.html
+++ b/accounts/templates/account.html
@@ -34,6 +34,11 @@
{% trans "Public Keys" %}
+
{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as Authorization: Token <token>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}
{% if api_tokens %}
-
-
-
-
- | {% trans "Name" %} |
- {% trans "Starts with" %} |
- {% trans "Created" %} |
- {% trans "Expires" %} |
- {% trans "Last used" %} |
- |
-
-
-
- {% for token in api_tokens %}
-
- | {{ token.name }} |
- {{ 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' %}
-
- |
-
- {% endfor %}
-
-
-
+ {% include "api_token_table.html" %}
{% endif %}
{% endblock %}
{% block script %}
-
-{% endblock %}
\ No newline at end of file
+{% include "tab_from_hash.html" %}
+{% endblock %}
diff --git a/accounts/templates/tab_from_hash.html b/accounts/templates/tab_from_hash.html
new file mode 100644
index 00000000..b418e8a5
--- /dev/null
+++ b/accounts/templates/tab_from_hash.html
@@ -0,0 +1,7 @@
+
diff --git a/accounts/tests/test_api_tokens.py b/accounts/tests/test_api_tokens.py
index 4afe4cd0..0993f382 100644
--- a/accounts/tests/test_api_tokens.py
+++ b/accounts/tests/test_api_tokens.py
@@ -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())
diff --git a/accounts/urls.py b/accounts/urls.py
index fa948731..7c009f62 100644
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -12,6 +12,8 @@ urlpatterns = [
path("logout/", LogoutView.as_view(template_name="logout.html"), name="logout"),
path("profile/", views.profile, name="profile"),
path("profile//", views.account, name="account"),
+ path("profile//api_token//delete/", views.api_token_delete,
+ name="account_api_token_delete"),
path("change_password/", views.change_password, name="change_password"),
path(
"user_instance/create//",
diff --git a/accounts/views.py b/accounts/views.py
index 300b56e2..bf1f7d0b 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -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,
},
)