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" %} +
@@ -94,5 +99,15 @@
+
+ {% if api_tokens %} + {% include "api_token_table.html" with owner=user %} + {% else %} +

{% trans "No API tokens." %}

+ {% endif %} +
{% endblock content %} +{% block script %} +{% include "tab_from_hash.html" %} +{% endblock %} diff --git a/accounts/templates/api_token_table.html b/accounts/templates/api_token_table.html new file mode 100644 index 00000000..bfef7bfe --- /dev/null +++ b/accounts/templates/api_token_table.html @@ -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 #} +
+ + + + + + + + + + + + + {% for token in api_tokens %} + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Starts with" %}{% trans "Created" %}{% trans "Expires" %}{% trans "Last used" %}
{{ 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' %} + +
+
diff --git a/accounts/templates/profile.html b/accounts/templates/profile.html index 2d4396f7..93b28ed5 100644 --- a/accounts/templates/profile.html +++ b/accounts/templates/profile.html @@ -88,36 +88,7 @@

{% 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 %} -
- - - - - - - - - - - - - {% for token in api_tokens %} - - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Starts with" %}{% trans "Created" %}{% trans "Expires" %}{% trans "Last used" %}
{{ 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' %} - -
-
+ {% include "api_token_table.html" %} {% endif %}
@@ -139,11 +110,5 @@
{% 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, }, )