From 8830519da2018eb02a33077e8910241d69be4119 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 May 2026 10:04:35 -0400 Subject: [PATCH] Closes #22062: Display API token ID & plaintext one time immediately upon creation (#22064) --- netbox/account/views.py | 10 +++ netbox/templates/account/usertoken_edit.html | 13 +++ .../templates/users/inc/new_token_banner.html | 24 ++++++ netbox/templates/users/token.html | 7 ++ netbox/templates/users/token_edit.html | 11 +++ netbox/users/forms/model_forms.py | 35 +++----- netbox/users/tests/test_views.py | 81 ++++++++++++++++++- netbox/users/views.py | 14 ++++ 8 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 netbox/templates/account/usertoken_edit.html create mode 100644 netbox/templates/users/inc/new_token_banner.html diff --git a/netbox/account/views.py b/netbox/account/views.py index 98c81642f..1c3d8cade 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -356,9 +356,16 @@ class UserTokenView(LoginRequiredMixin, View): def get(self, request, pk): token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) + # Pop a one-time plaintext value (set by UserTokenEditView.post_save when a token is first created) and + # assemble the full HTTP authorization string for display. The plaintext is never persisted; popping + # ensures the banner only renders once. + plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None) + token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None + return render(request, 'account/token.html', { 'object': token, 'layout': self.layout, + 'token_auth_string': token_auth_string, }) @@ -366,11 +373,14 @@ class UserTokenView(LoginRequiredMixin, View): class UserTokenEditView(generic.ObjectEditView): queryset = UserToken.objects.all() form = forms.UserTokenForm + template_name = 'account/usertoken_edit.html' default_return_url = 'account:usertoken_list' def alter_object(self, obj, request, url_args, url_kwargs): if not obj.pk: obj.user = request.user + # Attach the request so that UserTokenForm.save() can stash the newly-generated plaintext on the session. + obj._request = request return obj diff --git a/netbox/templates/account/usertoken_edit.html b/netbox/templates/account/usertoken_edit.html new file mode 100644 index 000000000..7f88d4f0c --- /dev/null +++ b/netbox/templates/account/usertoken_edit.html @@ -0,0 +1,13 @@ +{% extends 'generic/object_edit.html' %} +{% load i18n %} + +{% block buttons %} + {# Omit the "Create & Add Another" button: that flow would redirect away from the detail page before the #} + {# one-time plaintext can be displayed, leaving the new token unrecoverable. #} + {% trans "Cancel" %} + {% if object.pk %} + + {% else %} + + {% endif %} +{% endblock buttons %} diff --git a/netbox/templates/users/inc/new_token_banner.html b/netbox/templates/users/inc/new_token_banner.html new file mode 100644 index 000000000..47eacf4d9 --- /dev/null +++ b/netbox/templates/users/inc/new_token_banner.html @@ -0,0 +1,24 @@ +{% load i18n %} + diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 06e79dc84..f0da54143 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -2,3 +2,10 @@ {% load i18n %} {% block title %}{% trans "Token" %} {{ object }}{% endblock %} + +{% block alerts %} + {{ block.super }} + {% if token_auth_string %} + {% include 'users/inc/new_token_banner.html' %} + {% endif %} +{% endblock alerts %} diff --git a/netbox/templates/users/token_edit.html b/netbox/templates/users/token_edit.html index 49e521c55..6ca2fe860 100644 --- a/netbox/templates/users/token_edit.html +++ b/netbox/templates/users/token_edit.html @@ -7,3 +7,14 @@ {% endif %} {{ block.super }} {% endblock %} + +{% block buttons %} + {# Omit the "Create & Add Another" button: that flow would redirect away from the detail page before the #} + {# one-time plaintext can be displayed, leaving the new token unrecoverable. #} + {% trans "Cancel" %} + {% if object.pk %} + + {% else %} + + {% endif %} +{% endblock buttons %} diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 808bac6cb..c5ee10ae4 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -124,16 +124,6 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): class UserTokenForm(forms.ModelForm): - token = forms.CharField( - label=_('Token'), - help_text=_( - 'Tokens must be at least 40 characters in length. Be sure to record your token prior to ' - 'submitting this form, as it will no longer be accessible once the token has been created.' - ), - widget=forms.TextInput( - attrs={'data-clipboard': 'true'} - ) - ) allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(validators=[prefix_validator]), required=False, @@ -147,7 +137,7 @@ class UserTokenForm(forms.ModelForm): class Meta: model = Token fields = [ - 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), @@ -161,19 +151,20 @@ class UserTokenForm(forms.ModelForm): self.fields['version'].disabled = True self.fields['user'].disabled = True - # Omit the key field when editing an existing Token - del self.fields['token'] - - # Generate an initial random key if none has been specified - elif self.instance._state.adding and not self.initial.get('token'): + elif self.instance._state.adding: self.initial['version'] = TokenVersionChoices.V2 - self.initial['token'] = Token.generate() def save(self, commit=True): - if self.instance._state.adding and self.cleaned_data.get('token'): - self.instance.token = self.cleaned_data['token'] - - return super().save(commit=commit) + creating = self.instance.pk is None + instance = super().save(commit=commit) + # On creation, stash the auto-generated plaintext on the session so that the detail view can render the + # full HTTP authorization string exactly once. The plaintext is never persisted to the database; the + # request is delivered to the form via the edit view's alter_object hook. + if creating and instance._token and instance.pk is not None: + request = getattr(instance, '_request', None) + if request is not None: + request.session[f'_token_plaintext_{instance.pk}'] = instance._token + return instance class TokenForm(UserTokenForm): @@ -184,7 +175,7 @@ class TokenForm(UserTokenForm): class Meta(UserTokenForm.Meta): fields = [ - 'version', 'token', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', ] def __init__(self, *args, **kwargs): diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 0536f0a07..d37aab048 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -1,6 +1,10 @@ +from django.test import override_settings +from django.urls import reverse + from core.models import ObjectType +from users.constants import TOKEN_PREFIX from users.models import * -from utilities.testing import ViewTestCases, create_test_user +from utilities.testing import TestCase, ViewTestCases, create_test_user class UserTestCase( @@ -233,7 +237,6 @@ class TokenTestCase( cls.form_data = { 'version': 2, - 'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5', 'user': users[0].pk, 'description': 'Test token', 'enabled': True, @@ -258,6 +261,80 @@ class TokenTestCase( } +class TokenOneTimeAuthStringTestCase(TestCase): + """ + Verify that the plaintext value of a newly-created Token is surfaced exactly once via the detail view, and + that it is never persisted in the database. + """ + user_permissions = ('users.add_token', 'users.view_token', 'users.view_user') + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_create_stashes_plaintext_and_detail_view_renders_it_once(self): + target_user = create_test_user('token_owner') + + # Create a Token via the admin add view + response = self.client.post(reverse('users:token_add'), data={ + 'version': 2, + 'user': target_user.pk, + 'description': 'one-time-display test', + 'enabled': 'on', + 'write_enabled': 'on', + }) + self.assertEqual(response.status_code, 302) + + token = Token.objects.get(description='one-time-display test') + # Plaintext must NEVER be persisted for v2 tokens + self.assertIsNone(token.plaintext) + self.assertIsNotNone(token.hmac_digest) + self.assertIsNotNone(token.key) + + # Plaintext should be stashed on the session, keyed by token PK + session_key = f'_token_plaintext_{token.pk}' + self.assertIn(session_key, self.client.session) + plaintext = self.client.session[session_key] + self.assertEqual(len(plaintext), 40) + # Plaintext must validate against the stored digest + self.assertTrue(token.validate(plaintext)) + + # First GET on the detail view: full auth string should appear and be popped from the session + response = self.client.get(token.get_absolute_url()) + self.assertEqual(response.status_code, 200) + expected_auth_string = f'Bearer {TOKEN_PREFIX}{token.key}.{plaintext}' + self.assertContains(response, expected_auth_string) + self.assertNotIn(session_key, self.client.session) + + # Second GET: the banner must no longer render + response = self.client.get(token.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, expected_auth_string) + # Specifically, the banner element must be gone + self.assertNotContains(response, 'id="new-token-auth-string"') + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_form_ignores_user_supplied_token_field(self): + """ + Submitting a 'token' POST parameter should be silently ignored: the model auto-generates plaintext on save. + """ + target_user = create_test_user('token_owner_2') + + response = self.client.post(reverse('users:token_add'), data={ + 'version': 2, + 'user': target_user.pk, + 'description': 'ignored-plaintext test', + 'token': 'attacker_supplied_plaintext_value_xxxxxxx', + 'enabled': 'on', + 'write_enabled': 'on', + }) + self.assertEqual(response.status_code, 302) + + token = Token.objects.get(description='ignored-plaintext test') + # The supplied plaintext must NOT have been used + self.assertFalse(token.validate('attacker_supplied_plaintext_value_xxxxxxx')) + # Whatever was auto-generated must validate + plaintext = self.client.session[f'_token_plaintext_{token.pk}'] + self.assertTrue(token.validate(plaintext)) + + class OwnerGroupTestCase(ViewTestCases.AdminModelViewTestCase): model = OwnerGroup diff --git a/netbox/users/views.py b/netbox/users/views.py index 928775101..c5b6117a4 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -47,6 +47,15 @@ class TokenView(generic.ObjectView): ], ) + def get_extra_context(self, request, instance): + # Pop a one-time plaintext value (set by TokenEditView.post_save when a token is first created) and assemble + # the full HTTP authorization string for display. The plaintext is never persisted; popping ensures the + # banner only renders once. + plaintext = request.session.pop(f'_token_plaintext_{instance.pk}', None) + if plaintext: + return {'token_auth_string': f'{instance.get_auth_header_prefix()}{plaintext}'} + return {} + @register_model_view(Token, 'add', detail=False) @register_model_view(Token, 'edit') @@ -55,6 +64,11 @@ class TokenEditView(generic.ObjectEditView): form = forms.TokenForm template_name = 'users/token_edit.html' + def alter_object(self, obj, request, url_args, url_kwargs): + # Attach the request so that UserTokenForm.save() can stash the newly-generated plaintext on the session. + obj._request = request + return obj + @register_model_view(Token, 'delete') class TokenDeleteView(generic.ObjectDeleteView):