Skip to content

Commit 80be683

Browse files
authored
Merge pull request #1483 from TOMToolkit/1473-reset-drf-api-token-feature
Show user's DRF API token on Profile page and provide API token regeneration on Profile edit page.
2 parents bcb15ac + 330915e commit 80be683

5 files changed

Lines changed: 146 additions & 2 deletions

File tree

tom_common/templates/tom_common/create_user.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@
2424
</button>
2525
{% endbuttons %}
2626
</form>
27+
{% if object %}
28+
{% include 'tom_common/partials/api_token.html' %}
29+
{% endif %}
2730
{% endblock %}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<div id="api-token-container" class="mt-3">
2+
<h5>API Token</h5>
3+
{% if drf_api_token %}
4+
<code>{{ drf_api_token }}</code>
5+
{% else %}
6+
<span class="text-muted">No token generated.</span>
7+
{% endif %}
8+
<form hx-post="{% url 'regenerate-api-token' pk=user_pk %}"
9+
hx-target="#api-token-container"
10+
hx-swap="outerHTML"
11+
hx-confirm="Regenerate API token? Your current API token will stop working immediately.">
12+
{% csrf_token %}
13+
<button type="submit" class="btn btn-warning btn-sm mt-2">
14+
Regenerate API Token
15+
</button>
16+
</form>
17+
</div>

tom_common/tests.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,66 @@ def test_user_profile(self):
232232
self.assertEqual(user.profile.affiliation, 'Test University')
233233

234234

235+
class TestRegenerateAPIToken(TestCase):
236+
"""Tests for the RegenerateAPITokenView."""
237+
238+
def setUp(self):
239+
self.admin = User.objects.create_superuser(username='admin', password='admin', email='admin@example.com')
240+
self.user = User.objects.create_user(username='testuser', password='testpass', email='user@example.com')
241+
# Tokens are auto-created by the post_save signal in tom_common.signals
242+
243+
def test_regenerate_own_token(self):
244+
"""A logged-in user can regenerate their own API token."""
245+
self.client.force_login(self.user)
246+
old_token_key = self.user.auth_token.key
247+
248+
# token regeneration happens here
249+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
250+
251+
self.user.refresh_from_db()
252+
new_token_key = self.user.auth_token.key
253+
254+
self.assertNotEqual(old_token_key, new_token_key)
255+
self.assertRedirects(response, reverse('user-update', kwargs={'pk': self.user.pk}))
256+
257+
def test_non_superuser_cannot_regenerate_other_user_token(self):
258+
"""A non-superuser cannot regenerate another user's token."""
259+
self.client.force_login(self.user)
260+
old_token_key = self.admin.auth_token.key
261+
262+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.admin.pk}))
263+
264+
# Should redirect to the requesting user's own update page
265+
self.assertRedirects(response, reverse('user-update', kwargs={'pk': self.user.pk}))
266+
# Admin's token should be unchanged
267+
self.admin.refresh_from_db()
268+
self.assertEqual(old_token_key, self.admin.auth_token.key)
269+
270+
def test_superuser_can_regenerate_other_user_token(self):
271+
"""A superuser can regenerate another user's token."""
272+
self.client.force_login(self.admin)
273+
old_token_key = self.user.auth_token.key
274+
275+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
276+
277+
self.user.refresh_from_db()
278+
new_token_key = self.user.auth_token.key
279+
self.assertNotEqual(old_token_key, new_token_key)
280+
self.assertRedirects(response, reverse('user-update', kwargs={'pk': self.user.pk}))
281+
282+
def test_get_request_returns_405(self):
283+
"""GET requests should return 405 Method Not Allowed."""
284+
self.client.force_login(self.user)
285+
response = self.client.get(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
286+
self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED)
287+
288+
def test_unauthenticated_redirects_to_login(self):
289+
"""Unauthenticated requests should redirect to login."""
290+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
291+
self.assertRedirects(response, reverse('login') + '?next=' +
292+
reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
293+
294+
235295
class TestAuthScheme(TestCase):
236296
@override_settings(AUTH_STRATEGY='LOCKED')
237297
def test_user_cannot_access_view(self):

tom_common/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from tom_common.api_views import GroupViewSet
2828
from tom_common.views import UserListView, UserPasswordChangeView, UserCreateView, UserDeleteView, UserUpdateView
2929
from tom_common.views import CommentDeleteView, GroupCreateView, GroupUpdateView, GroupDeleteView, UserProfileView
30+
from tom_common.views import RegenerateAPITokenView
3031
from tom_common.views import robots_txt
3132

3233
from .api_router import collect_api_urls, SharedAPIRootRouter # DRF routers are setup in each INSTALL_APPS url.py
@@ -58,6 +59,7 @@
5859
path('users/create/', UserCreateView.as_view(), name='user-create'),
5960
path('users/<int:pk>/delete/', UserDeleteView.as_view(), name='user-delete'),
6061
path('users/<int:pk>/update/', UserUpdateView.as_view(), name='user-update'),
62+
path('users/<int:pk>/regenerate-token/', RegenerateAPITokenView.as_view(), name='regenerate-api-token'),
6163
path('users/profile/', UserProfileView.as_view(), name='user-profile'),
6264
path('groups/create/', GroupCreateView.as_view(), name='group-create'),
6365
path('groups/<int:pk>/update/', GroupUpdateView.as_view(), name='group-update'),

tom_common/views.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from django.views import View
23
from django.views.generic import TemplateView
34
from django.views.generic.edit import FormView, DeleteView
45
from django.views.generic.edit import UpdateView, CreateView
@@ -10,9 +11,12 @@
1011
from django.urls import reverse_lazy
1112
from django.contrib import messages
1213
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect
13-
from django.shortcuts import redirect
14+
from django.shortcuts import get_object_or_404, redirect
15+
from django.template.loader import render_to_string
1416
from django.contrib.auth import update_session_auth_hash
1517

18+
from rest_framework.authtoken.models import Token
19+
1620
from tom_common.models import UserSession
1721
from tom_common.forms import ChangeUserPasswordForm, CustomUserCreationForm, GroupForm
1822
from tom_common.mixins import SuperuserRequiredMixin
@@ -83,6 +87,54 @@ def dispatch(self, *args, **kwargs):
8387
return super().dispatch(*args, **kwargs)
8488

8589

90+
class RegenerateAPITokenView(LoginRequiredMixin, View):
91+
"""View that handles regeneration of a User's DRF API token. Requires login.
92+
93+
Deletes the existing token (if any) and creates a new one. For HTMX requests,
94+
returns the api_token partial with the new token. For non-HTMX requests,
95+
redirects to the user update page with a success message.
96+
"""
97+
# this is the partial template to render the API token
98+
partial_template_name = 'tom_common/partials/api_token.html'
99+
100+
def dispatch(self, *args, **kwargs):
101+
"""Ensure non-superusers can only regenerate their own token.
102+
103+
Checks authentication first (via LoginRequiredMixin), then checks
104+
that non-superusers are only operating on their own token.
105+
"""
106+
# the User must be authenticated
107+
if not self.request.user.is_authenticated:
108+
return self.handle_no_permission()
109+
110+
# don't let a non-super-user regenerate someone else's API Token,
111+
# instead, redirect them to their own user-update view.
112+
if not self.request.user.is_superuser and self.request.user.id != int(self.kwargs['pk']):
113+
return redirect('user-update', pk=self.request.user.id)
114+
return super().dispatch(*args, **kwargs)
115+
116+
def post(self, request, pk: int) -> HttpResponse:
117+
target_user = get_object_or_404(User, pk=pk)
118+
119+
# Delete existing token (safe even if none exists) and create a new one
120+
Token.objects.filter(user=target_user).delete()
121+
new_token = Token.objects.create(user=target_user)
122+
123+
# handle HTMX requests here
124+
if request.htmx:
125+
# Return just the partial for in-place replacement (avoid full page reload)
126+
html = render_to_string(
127+
self.partial_template_name,
128+
{'drf_api_token': new_token, 'user_pk': target_user.pk},
129+
request=request,
130+
)
131+
return HttpResponse(html)
132+
133+
# Non-HTMX fallback: redirect with a success message
134+
messages.success(request, 'API token regenerated.')
135+
return redirect('user-update', pk=target_user.pk)
136+
137+
86138
class UserProfileView(LoginRequiredMixin, TemplateView):
87139
"""
88140
View to handle creating a user profile page. Requires a login.
@@ -194,9 +246,19 @@ def get_success_url(self):
194246
return reverse_lazy('user-update', kwargs={'pk': self.request.user.id})
195247

196248
def get_context_data(self, **kwargs):
197-
"""Add current user to the context for all templates."""
249+
"""Add current user and API token to the context for all templates."""
198250
context = super().get_context_data(**kwargs)
251+
252+
# this is the User doing the updating. (could be super-user)
199253
context['current_user'] = self.request.user
254+
255+
# this is the User being updated (usually the same as the requesting User,
256+
# but not if a super-user is updating a different User).
257+
user_being_updated = self.object
258+
259+
# add context required to Regenerate the user's DRF API token
260+
context['drf_api_token'] = getattr(user_being_updated, 'auth_token', None)
261+
context['user_pk'] = user_being_updated.pk
200262
return context
201263

202264
def get_form(self, form_class=None):

0 commit comments

Comments
 (0)