Ver Fonte

Closes #22062: Display API token ID & plaintext one time immediately upon creation (#22064)

Jeremy Stretch há 2 semanas atrás
pai
commit
8830519da2

+ 10 - 0
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
 
 

+ 13 - 0
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. #}
+  <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
+  {% if object.pk %}
+    <button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
+  {% else %}
+    <button type="submit" name="_create" class="btn btn-primary">{% trans "Create" %}</button>
+  {% endif %}
+{% endblock buttons %}

+ 24 - 0
netbox/templates/users/inc/new_token_banner.html

@@ -0,0 +1,24 @@
+{% load i18n %}
+<div class="alert alert-success bg-success-subtle mb-3" role="alert">
+  <div class="d-flex">
+    <div>
+      <i class="mdi mdi-check-bold pe-2"></i>
+    </div>
+    <div class="flex-grow-1">
+      <h4 class="alert-title">{% trans "Save this token now. It cannot be retrieved later." %}</h4>
+      <div class="text-secondary mb-2">
+        {% blocktrans %}
+          The full HTTP Authorization header value is shown below. NetBox does not store the token plaintext,
+          so this is your only chance to copy it. It will be lost when navigating away from this page.
+        {% endblocktrans %}
+      </div>
+      <div class="d-flex align-items-center gap-2">
+        <code id="new-token-auth-string" class="flex-grow-1 p-2 bg-body border rounded font-monospace text-break">{{ token_auth_string }}</code>
+        <a class="btn btn-primary copy-content" data-clipboard-target="#new-token-auth-string" title="{% trans "Copy to clipboard" %}">
+          <i class="mdi mdi-content-copy"></i>
+          {% trans "Copy" %}
+        </a>
+      </div>
+    </div>
+  </div>
+</div>

+ 7 - 0
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 %}

+ 11 - 0
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. #}
+  <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
+  {% if object.pk %}
+    <button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
+  {% else %}
+    <button type="submit" name="_create" class="btn btn-primary">{% trans "Create" %}</button>
+  {% endif %}
+{% endblock buttons %}

+ 13 - 22
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. <strong>Be sure to record your token</strong> 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):

+ 79 - 2
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
 

+ 14 - 0
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):