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:
parent
e7361561aa
commit
85b5c769e9
7 changed files with 124 additions and 43 deletions
|
|
@ -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 %}
|
||||
|
|
|
|||
33
accounts/templates/api_token_table.html
Normal file
33
accounts/templates/api_token_table.html
Normal 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>
|
||||
|
|
@ -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 <token></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 %}
|
||||
|
|
|
|||
7
accounts/templates/tab_from_hash.html
Normal file
7
accounts/templates/tab_from_hash.html
Normal 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>
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>/",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue