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

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