Skip to content

Commit 1a993be

Browse files
authored
Merge branch 'dev' into 1459-login-problems-intermittent
2 parents 9c33cd5 + 80be683 commit 1a993be

5 files changed

Lines changed: 147 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
@@ -20,4 +20,7 @@
2020
</button>
2121
{% endbuttons %}
2222
</form>
23+
{% if object %}
24+
{% include 'tom_common/partials/api_token.html' %}
25+
{% endif %}
2326
{% 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
@@ -226,6 +226,66 @@ def test_user_profile(self):
226226
self.assertEqual(user.profile.affiliation, 'Test University')
227227

228228

229+
class TestRegenerateAPIToken(TestCase):
230+
"""Tests for the RegenerateAPITokenView."""
231+
232+
def setUp(self):
233+
self.admin = User.objects.create_superuser(username='admin', password='admin', email='admin@example.com')
234+
self.user = User.objects.create_user(username='testuser', password='testpass', email='user@example.com')
235+
# Tokens are auto-created by the post_save signal in tom_common.signals
236+
237+
def test_regenerate_own_token(self):
238+
"""A logged-in user can regenerate their own API token."""
239+
self.client.force_login(self.user)
240+
old_token_key = self.user.auth_token.key
241+
242+
# token regeneration happens here
243+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
244+
245+
self.user.refresh_from_db()
246+
new_token_key = self.user.auth_token.key
247+
248+
self.assertNotEqual(old_token_key, new_token_key)
249+
self.assertRedirects(response, reverse('user-update', kwargs={'pk': self.user.pk}))
250+
251+
def test_non_superuser_cannot_regenerate_other_user_token(self):
252+
"""A non-superuser cannot regenerate another user's token."""
253+
self.client.force_login(self.user)
254+
old_token_key = self.admin.auth_token.key
255+
256+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.admin.pk}))
257+
258+
# Should redirect to the requesting user's own update page
259+
self.assertRedirects(response, reverse('user-update', kwargs={'pk': self.user.pk}))
260+
# Admin's token should be unchanged
261+
self.admin.refresh_from_db()
262+
self.assertEqual(old_token_key, self.admin.auth_token.key)
263+
264+
def test_superuser_can_regenerate_other_user_token(self):
265+
"""A superuser can regenerate another user's token."""
266+
self.client.force_login(self.admin)
267+
old_token_key = self.user.auth_token.key
268+
269+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
270+
271+
self.user.refresh_from_db()
272+
new_token_key = self.user.auth_token.key
273+
self.assertNotEqual(old_token_key, new_token_key)
274+
self.assertRedirects(response, reverse('user-update', kwargs={'pk': self.user.pk}))
275+
276+
def test_get_request_returns_405(self):
277+
"""GET requests should return 405 Method Not Allowed."""
278+
self.client.force_login(self.user)
279+
response = self.client.get(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
280+
self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED)
281+
282+
def test_unauthenticated_redirects_to_login(self):
283+
"""Unauthenticated requests should redirect to login."""
284+
response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
285+
self.assertRedirects(response, reverse('login') + '?next=' +
286+
reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
287+
288+
229289
class TestAuthScheme(TestCase):
230290
@override_settings(AUTH_STRATEGY='LOCKED')
231291
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: 65 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,13 @@
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+
20+
from tom_common.models import UserSession
1621
from tom_common.forms import ChangeUserPasswordForm, CustomUserCreationForm, GroupForm
1722
from tom_common.mixins import SuperuserRequiredMixin
1823

@@ -82,6 +87,54 @@ def dispatch(self, *args, **kwargs):
8287
return super().dispatch(*args, **kwargs)
8388

8489

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+
85138
class UserProfileView(LoginRequiredMixin, TemplateView):
86139
"""
87140
View to handle creating a user profile page. Requires a login.
@@ -193,9 +246,19 @@ def get_success_url(self):
193246
return reverse_lazy('user-update', kwargs={'pk': self.request.user.id})
194247

195248
def get_context_data(self, **kwargs):
196-
"""Add current user to the context for all templates."""
249+
"""Add current user and API token to the context for all templates."""
197250
context = super().get_context_data(**kwargs)
251+
252+
# this is the User doing the updating. (could be super-user)
198253
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
199262
return context
200263

201264
def get_form(self, form_class=None):

0 commit comments

Comments
 (0)