This commit is contained in:
parent
92c5aff713
commit
8830519da2
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue