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

This commit is contained in:
Jeremy Stretch 2026-05-01 10:04:35 -04:00 committed by GitHub
parent 92c5aff713
commit 8830519da2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 171 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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