1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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>
This commit is contained in:
Arthur Hanson
2022-11-02 09:45:00 -07:00
committed by GitHub
parent 484efdaf75
commit 816fedb78d
12 changed files with 116 additions and 12 deletions

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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 = [

View File

@ -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:

View File

@ -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 %}

View File

@ -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(),

View File

@ -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)

View File

@ -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']

View File

@ -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:

View File

@ -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',
)

View File

@ -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
})