diff --git a/netbox/account/views.py b/netbox/account/views.py
index 98c81642f..1c3d8cade 100644
--- a/netbox/account/views.py
+++ b/netbox/account/views.py
@@ -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
diff --git a/netbox/templates/account/usertoken_edit.html b/netbox/templates/account/usertoken_edit.html
new file mode 100644
index 000000000..7f88d4f0c
--- /dev/null
+++ b/netbox/templates/account/usertoken_edit.html
@@ -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. #}
+ {% trans "Cancel" %}
+ {% if object.pk %}
+ {% trans "Save" %}
+ {% else %}
+ {% trans "Create" %}
+ {% endif %}
+{% endblock buttons %}
diff --git a/netbox/templates/users/inc/new_token_banner.html b/netbox/templates/users/inc/new_token_banner.html
new file mode 100644
index 000000000..47eacf4d9
--- /dev/null
+++ b/netbox/templates/users/inc/new_token_banner.html
@@ -0,0 +1,24 @@
+{% load i18n %}
+
+
+
+
+
+
+
{% trans "Save this token now. It cannot be retrieved later." %}
+
+ {% 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 %}
+
+
+
+
+
diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html
index 06e79dc84..f0da54143 100644
--- a/netbox/templates/users/token.html
+++ b/netbox/templates/users/token.html
@@ -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 %}
diff --git a/netbox/templates/users/token_edit.html b/netbox/templates/users/token_edit.html
index 49e521c55..6ca2fe860 100644
--- a/netbox/templates/users/token_edit.html
+++ b/netbox/templates/users/token_edit.html
@@ -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. #}
+ {% trans "Cancel" %}
+ {% if object.pk %}
+ {% trans "Save" %}
+ {% else %}
+ {% trans "Create" %}
+ {% endif %}
+{% endblock buttons %}
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py
index 808bac6cb..c5ee10ae4 100644
--- a/netbox/users/forms/model_forms.py
+++ b/netbox/users/forms/model_forms.py
@@ -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. Be sure to record your token 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):
diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py
index 0536f0a07..d37aab048 100644
--- a/netbox/users/tests/test_views.py
+++ b/netbox/users/tests/test_views.py
@@ -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
diff --git a/netbox/users/views.py b/netbox/users/views.py
index 928775101..c5b6117a4 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -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):