Skip to content

Commit 848eb7c

Browse files
committed
feat: add ability to revoke an API key
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 5f424f6 commit 848eb7c

7 files changed

Lines changed: 74 additions & 28 deletions

File tree

aboutcode/api_auth/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ def regenerate(cls, user):
9191
cls.objects.filter(user=user).delete()
9292
return cls.create_token(user)
9393

94+
@classmethod
95+
def revoke(cls, user):
96+
"""Delete any existing token instance for the user."""
97+
return cls.objects.filter(user=user).delete()
98+
9499

95100
class APITokenAuthentication(TokenAuthentication):
96101
"""

dejacode/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from dje.views import GenerateAPIKeyView
3838
from dje.views import GlobalSearchListView
3939
from dje.views import IntegrationsStatusView
40+
from dje.views import RevokeAPIKeyView
4041
from dje.views import UnreadNotificationsList
4142
from dje.views import api_docs_view
4243
from dje.views import home_view
@@ -91,6 +92,7 @@
9192
path("account/", include("django.contrib.auth.urls")),
9293
path("account/profile/", AccountProfileView.as_view(), name="account_profile"),
9394
path("account/generate_api_key/", GenerateAPIKeyView.as_view(), name="generate_api_key"),
95+
path("account/revoke_api_key/", RevokeAPIKeyView.as_view(), name="revoke_api_key"),
9496
path("logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
9597
path(
9698
"login/",

dje/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1841,6 +1841,10 @@ def regenerate_api_key(self):
18411841
"""Regenerate the user API key."""
18421842
return APIToken.regenerate(user=self)
18431843

1844+
def revoke_api_key(self):
1845+
"""Revoke the user API key."""
1846+
return APIToken.revoke(user=self)
1847+
18441848
def serialize_user_data(self):
18451849
fields = [
18461850
"email",

dje/templates/account/profile.html

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,36 +26,39 @@
2626

2727
<hr class="my-4">
2828
{% include 'includes/header_title.html' with pretitle='REST API' title='API key' %}
29-
<form class="mb-5" autocomplete="off">
30-
<div class="row">
31-
<div class="col-6">
32-
<div>
33-
<p>
34-
Your personal API key provides access to the <a href="{% url 'api-docs:docs-index' %}" target="_blank">REST API</a>.<br>
35-
<strong>Treat it like a password and keep it secure.</strong>
36-
</p>
37-
{% if request.user.api_token %}
38-
<div class="alert alert-primary m-0 mb-3" role="alert">
39-
Your API key <strong>{{ request.user.api_token.prefix }}...</strong>
40-
was generated on {{ request.user.api_token.created }}<br>
41-
For security reasons, the full key is only shown once at generation time.<br>
42-
If you lose it, you will need to regenerate a new one.
43-
</div>
44-
{% else %}
45-
<div class="alert alert-warning m-0 mb-3" role="alert">
46-
<strong>No API key created.</strong><br>
47-
Generate one using the button below to access the REST API.
48-
</div>
49-
{% endif %}
50-
</div>
51-
<div>
52-
<a href="#" class="btn btn-outline-dark" data-bs-toggle="modal" data-bs-target="#generate-api-key">
53-
Generate API key
54-
</a>
55-
</div>
29+
<div class="row">
30+
<div class="col-6">
31+
<div>
32+
<p>
33+
Your personal API key provides access to the <a href="{% url 'api-docs:docs-index' %}" target="_blank">REST API</a>.<br>
34+
<strong>Treat it like a password and keep it secure.</strong>
35+
</p>
36+
{% if request.user.api_token %}
37+
<div class="alert alert-primary m-0 mb-3" role="alert">
38+
Your API key <strong>{{ request.user.api_token.prefix }}...</strong>
39+
was generated on {{ request.user.api_token.created }}<br>
40+
For security reasons, the full key is only shown once at generation time.<br>
41+
If you lose it, you will need to regenerate a new one.
42+
</div>
43+
{% else %}
44+
<div class="alert alert-warning m-0 mb-3" role="alert">
45+
<strong>No API key created.</strong><br>
46+
Generate one using the button below to access the REST API.
47+
</div>
48+
{% endif %}
49+
</div>
50+
<div>
51+
<a href="#" class="btn btn-outline-dark" data-bs-toggle="modal" data-bs-target="#generate-api-key">
52+
Generate API key
53+
</a>
54+
{% if request.user.api_token %}
55+
<form action="{% url 'revoke_api_key' %}" id="revoke-api-key-form" class="d-inline" method="post">{% csrf_token %}
56+
<button type="submit" class="btn btn-outline-danger">Revoke API key</button>
57+
</form>
58+
{% endif %}
5659
</div>
5760
</div>
58-
</form>
61+
</div>
5962

6063
<div class="modal" tabindex="-1" role="dialog" id="generate-api-key">
6164
<div class="modal-dialog" role="document">

dje/tests/test_api_auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ def test_api_auth_api_token_model_regenerate(self):
5656
APIToken.regenerate(user=self.base_user)
5757
self.assertEqual(1, APIToken.objects.count())
5858

59+
def test_api_auth_api_token_model_revoke(self):
60+
self.assertEqual(0, APIToken.objects.count())
61+
APIToken.regenerate(user=self.base_user)
62+
self.assertEqual(1, APIToken.objects.count())
63+
APIToken.revoke(user=self.base_user)
64+
self.assertEqual(0, APIToken.objects.count())
65+
5966
def test_api_auth_api_token_authentication_get_model(self):
6067
self.assertEqual(APIToken, APITokenAuthentication().get_model())
6168

dje/tests/test_views.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.contrib.auth.models import Group
1313
from django.contrib.contenttypes.models import ContentType
1414
from django.core import mail
15+
from django.core.exceptions import ObjectDoesNotExist
1516
from django.core.paginator import Paginator
1617
from django.shortcuts import resolve_url
1718
from django.test import TestCase
@@ -309,6 +310,23 @@ def test_account_profile_view_regenerate_api_key(self):
309310
self.assertIn("Copy your API key now, it will not be shown again:", message)
310311
self.assertIn(new_token_prefix, message)
311312

313+
def test_account_profile_view_revoke_api_key(self):
314+
url = reverse("revoke_api_key")
315+
self.client.login(username=self.user.username, password="secret")
316+
317+
self.user.regenerate_api_key()
318+
initial_token_prefix = self.user.api_token.prefix
319+
self.assertEqual(8, len(initial_token_prefix))
320+
321+
response = self.client.post(url, follow=True)
322+
self.user.refresh_from_db()
323+
message = "DejacodeUser has no api_token"
324+
with self.assertRaisesMessage(ObjectDoesNotExist, message):
325+
self.user.api_token
326+
327+
message = list(response.context["messages"])[0].message
328+
self.assertEqual("API key revoked.", message)
329+
312330
@override_settings(REFERENCE_DATASPACE="Dataspace", TEMPLATE_DATASPACE=None)
313331
def test_clone_dataset_view(self):
314332
template_dataspace = Dataspace.objects.create(name="Template")

dje/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2091,6 +2091,13 @@ def post(self, request, *args, **kwargs):
20912091
return redirect(reverse_lazy("account_profile"))
20922092

20932093

2094+
class RevokeAPIKeyView(LoginRequiredMixin, View):
2095+
def post(self, request, *args, **kwargs):
2096+
request.user.revoke_api_key()
2097+
messages.success(request, "API key revoked.")
2098+
return redirect(reverse_lazy("account_profile"))
2099+
2100+
20942101
@login_required
20952102
def docs_models_view(request):
20962103
from dje.admin import dejacode_site

0 commit comments

Comments
 (0)