Parcourir la source

8853 Prevent the retrieval of API tokens after creation (#10645)

* 8853 hide api token

* 8853 hide key on edit

* 8853 add key display

* 8853 cleanup html

* 8853 make token view accessible only once on POST

* Clean up display of tokens in views

* Honor ALLOW_TOKEN_RETRIEVAL in API serializer

* Add docs & tweak default setting

* Include token key when provisioning with user credentials

Co-authored-by: jeremystretch <jstretch@ns1.com>
Arthur Hanson il y a 3 ans
Parent
commit
816fedb78d

+ 8 - 0
docs/configuration/security.md

@@ -1,5 +1,13 @@
 # Security & Authentication Parameters
 
+## ALLOW_TOKEN_RETRIEVAL
+
+Default: True
+
+If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
+
+---
+
 ## ALLOWED_URL_SCHEMES
 
 !!! tip "Dynamic Configuration Parameter"

+ 3 - 0
docs/integrations/rest-api.md

@@ -579,6 +579,9 @@ By default, a token can be used to perform all actions via the API that a user w
 
 Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
 
+!!! warning "Restricting Token Retrieval"
+    The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
+
 #### Client IP Restriction
 
 !!! note

+ 1 - 0
docs/release-notes/version-3.4.md

@@ -28,6 +28,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 
 * [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
 * [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types
+* [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens
 * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
 * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
 * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types

+ 3 - 0
netbox/netbox/configuration_example.py

@@ -72,6 +72,9 @@ ADMINS = [
     # ('John Doe', 'jdoe@example.com'),
 ]
 
+# Permit the retrieval of API tokens after their creation.
+ALLOW_TOKEN_RETRIEVAL = False
+
 # Enable any desired validators for local account passwords below. For a list of included validators, please see the
 # Django documentation at https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation.
 AUTH_PASSWORD_VALIDATORS = [

+ 1 - 0
netbox/netbox/settings.py

@@ -71,6 +71,7 @@ DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
 
 # Set static config parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
+ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True)
 AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [])
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:

+ 60 - 0
netbox/templates/users/api_token.html

@@ -0,0 +1,60 @@
+{% extends 'generic/object.html' %}
+{% load form_helpers %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-12">
+      {% if not settings.ALLOW_TOKEN_RETRIEVAL %}
+        <div class="alert alert-danger" role="alert">
+          <i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
+        </div>
+      {% endif %}
+      <div class="card">
+        <h5 class="card-header">Token</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Key</th>
+              <td>
+                <div class="float-end">
+                  <a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">
+                    <i class="mdi mdi-content-copy"></i>
+                  </a>
+                </div>
+                <div id="token_id">{{ key }}</div>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">User</th>
+              <td>{{ object.user }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Created</th>
+              <td>{{ object.created|annotated_date }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Expires</th>
+              <td>
+                {% if object.expires %}
+                  {{ object.expires|annotated_date }}
+                {% else %}
+                  <span>Never</span>
+                {% endif %}
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      <div class="col col-md-12 text-center">
+        <a href="{% url 'users:token_add' %}" class="btn btn-outline-primary">Add Another</a>
+        <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 8 - 1
netbox/users/api/serializers.py

@@ -1,3 +1,4 @@
+from django.conf import settings
 from django.contrib.auth.models import Group, User
 from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
@@ -63,7 +64,13 @@ class GroupSerializer(ValidatedModelSerializer):
 
 class TokenSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
-    key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
+    key = serializers.CharField(
+        min_length=40,
+        max_length=40,
+        allow_blank=True,
+        required=False,
+        write_only=not settings.ALLOW_TOKEN_RETRIEVAL
+    )
     user = NestedUserSerializer()
     allowed_ips = serializers.ListField(
         child=IPNetworkSerializer(),

+ 2 - 0
netbox/users/api/views.py

@@ -88,6 +88,8 @@ class TokenProvisionView(APIView):
         token = Token(user=user)
         token.save()
         data = serializers.TokenSerializer(token, context={'request': request}).data
+        # Manually append the token key, which is normally write-only
+        data['key'] = token.key
 
         return Response(data, status=HTTP_201_CREATED)
 

+ 8 - 0
netbox/users/forms.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.conf import settings
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
 from django.contrib.postgres.forms import SimpleArrayField
 from django.utils.html import mark_safe
@@ -117,3 +118,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
         widgets = {
             'expires': DateTimePicker(),
         }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Omit the key field if token retrieval is not permitted
+        if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
+            del self.fields['key']

+ 6 - 5
netbox/users/models.py

@@ -1,6 +1,7 @@
 import binascii
 import os
 
+from django.conf import settings
 from django.contrib.auth.models import Group, User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
@@ -230,12 +231,12 @@ class Token(models.Model):
                   'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
     )
 
-    class Meta:
-        pass
-
     def __str__(self):
-        # Only display the last 24 bits of the token to avoid accidental exposure.
-        return f"{self.key[-6:]} ({self.user})"
+        return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
+
+    @property
+    def partial(self):
+        return f'**********************************{self.key[-6:]}' if self.key else ''
 
     def save(self, *args, **kwargs):
         if not self.key:

+ 7 - 5
netbox/users/tables.py

@@ -6,14 +6,16 @@ __all__ = (
 )
 
 
-TOKEN = """<samp><span id="token_{{ record.pk }}">{{ value }}</span></samp>"""
+TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
 
 ALLOWED_IPS = """{{ value|join:", " }}"""
 
 COPY_BUTTON = """
-<a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_{{ record.pk }}" title="Copy to clipboard">
-  <i class="mdi mdi-content-copy"></i>
-</a>
+{% if settings.ALLOW_TOKEN_RETRIEVAL %}
+  <a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_{{ record.pk }}" title="Copy to clipboard">
+    <i class="mdi mdi-content-copy"></i>
+  </a>
+{% endif %}
 """
 
 
@@ -38,5 +40,5 @@ class TokenTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Token
         fields = (
-            'pk', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'description',
+            'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
         )

+ 9 - 1
netbox/users/views.py

@@ -273,6 +273,7 @@ class TokenEditView(LoginRequiredMixin, View):
             form = TokenForm(request.POST)
 
         if form.is_valid():
+
             token = form.save(commit=False)
             token.user = request.user
             token.save()
@@ -280,7 +281,13 @@ class TokenEditView(LoginRequiredMixin, View):
             msg = f"Modified token {token}" if pk else f"Created token {token}"
             messages.success(request, msg)
 
-            if '_addanother' in request.POST:
+            if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
+                return render(request, 'users/api_token.html', {
+                    'object': token,
+                    'key': token.key,
+                    'return_url': reverse('users:token_list'),
+                })
+            elif '_addanother' in request.POST:
                 return redirect(request.path)
             else:
                 return redirect('users:token_list')
@@ -289,6 +296,7 @@ class TokenEditView(LoginRequiredMixin, View):
             'object': token,
             'form': form,
             'return_url': reverse('users:token_list'),
+            'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL
         })