From 0ee59d7371e93a98bcf1c691e0499bf4275fe651 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 13 May 2026 11:38:17 -0400 Subject: [PATCH] Closes #22081: Include plaintext when creating v2 tokens via REST API (#22160) --- netbox/users/api/serializers_/tokens.py | 18 ++++++++++ netbox/users/tests/test_api.py | 47 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index 60f094239..ce467e1a6 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -57,6 +57,24 @@ class TokenSerializer(ValidatedModelSerializer): return super().validate(data) + def create(self, validated_data): + instance = super().create(validated_data) + # The plaintext token is only available in memory after save(); v2 tokens persist only an + # HMAC digest, so it can't be recovered later. Stash it on the request so to_representation() + # can return it even after the viewset re-fetches the instance from the database. + if request := self.context.get('request'): + if not hasattr(request, '_token_plaintexts'): + request._token_plaintexts = {} + request._token_plaintexts[instance.pk] = instance.token + return instance + + def to_representation(self, instance): + data = super().to_representation(instance) + if not data.get('token') and (request := self.context.get('request')): + if plaintext := getattr(request, '_token_plaintexts', {}).get(instance.pk): + data['token'] = plaintext + return data + class TokenProvisionSerializer(TokenSerializer): user = UserSerializer( diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index e05e7f33b..a50ce74bc 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -310,6 +310,53 @@ class TokenTestCase( response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) + def test_create_token_returns_plaintext(self): + """ + Creating a Token via the REST API must return the usable plaintext value in the response. + For v2 tokens this value cannot be recovered later because the database stores only an + HMAC digest. + """ + self.add_permissions('users.add_token') + user = User.objects.create_user(username='token_plaintext_user') + url = reverse('users-api:token-list') + + response = self.client.post(url, {'user': user.pk}, format='json', **self.header) + self.assertEqual(response.status_code, 201) + self.assertIsNotNone(response.data['token']) + self.assertEqual(len(response.data['token']), TOKEN_DEFAULT_LENGTH) + + # The returned plaintext must authenticate against the stored token + token = Token.objects.get(pk=response.data['id']) + self.assertTrue(token.validate(response.data['token'])) + + def test_bulk_create_tokens_returns_plaintexts(self): + """ + Bulk-creating Tokens via the REST API must return the plaintext value for each created + Token in the response. + """ + self.add_permissions('users.add_token') + users = [ + User.objects.create_user(username='token_bulk_user1'), + User.objects.create_user(username='token_bulk_user2'), + ] + data = [{'user': u.pk} for u in users] + url = reverse('users-api:token-list') + + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data), len(data)) + + plaintexts = set() + for obj in response.data: + self.assertIsNotNone(obj['token']) + self.assertEqual(len(obj['token']), TOKEN_DEFAULT_LENGTH) + plaintexts.add(obj['token']) + token = Token.objects.get(pk=obj['id']) + self.assertTrue(token.validate(obj['token'])) + + # Each token should be unique + self.assertEqual(len(plaintexts), len(data)) + def test_reassign_token(self): """ Check that a Token cannot be reassigned to another User.