Closes #22409: Disallow chosen-plaintext API tokens
This commit is contained in:
parent
5b6d7887f2
commit
814050a3c9
|
|
@ -781,7 +781,7 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
|
|||
|
||||
### Tokens
|
||||
|
||||
A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
|
||||
A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically generate a random token value. This value is always generated by the server and cannot be specified by the client; any `token` value included in a creation request is ignored.
|
||||
|
||||
!!! note "Tokens cannot be retrieved once created"
|
||||
Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ __all__ = (
|
|||
|
||||
class TokenSerializer(ValidatedModelSerializer):
|
||||
token = serializers.CharField(
|
||||
required=False,
|
||||
default=Token.generate,
|
||||
read_only=True,
|
||||
)
|
||||
user = UserSerializer(
|
||||
nested=True
|
||||
|
|
|
|||
|
|
@ -44,15 +44,10 @@ class TokenImportForm(CSVModelForm):
|
|||
required=False,
|
||||
help_text=_("Specify version 1 or 2 (v2 will be used by default)")
|
||||
)
|
||||
token = forms.CharField(
|
||||
label=_('Token'),
|
||||
required=False,
|
||||
help_text=_("If no token is provided, one will be generated automatically.")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Token
|
||||
fields = ('user', 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description',)
|
||||
fields = ('user', 'version', 'enabled', 'write_enabled', 'expires', 'description',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -221,6 +221,13 @@ class Token(models.Model):
|
|||
raise ValidationError(_("Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined."))
|
||||
|
||||
if self._state.adding:
|
||||
# Ensure a randomly-generated plaintext is always assigned to new tokens. A client-supplied value is
|
||||
# never accepted via the REST API (the serializer's `token` field is read-only); generating it here
|
||||
# guarantees the version-dependent key/digest fields are populated before constraint validation
|
||||
# (full_clean) runs.
|
||||
if self.token is None:
|
||||
self.token = self.generate()
|
||||
|
||||
if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS:
|
||||
raise ValidationError(_(
|
||||
"Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS."
|
||||
|
|
|
|||
|
|
@ -357,6 +357,43 @@ class TokenTestCase(
|
|||
# Each token should be unique
|
||||
self.assertEqual(len(plaintexts), len(data))
|
||||
|
||||
def test_create_token_ignores_client_supplied_plaintext(self):
|
||||
"""
|
||||
A client must not be able to choose a Token's plaintext value. Any `token` value supplied in a
|
||||
create request must be ignored in favor of a randomly-generated value (for both v1 and v2 tokens).
|
||||
"""
|
||||
self.add_permissions('users.add_token')
|
||||
url = reverse('users-api:token-list')
|
||||
chosen_plaintext = 'a' * TOKEN_DEFAULT_LENGTH
|
||||
|
||||
for version in (1, 2):
|
||||
user = User.objects.create_user(username=f'chosen_plaintext_user_v{version}')
|
||||
data = {
|
||||
'user': user.pk,
|
||||
'version': version,
|
||||
'token': chosen_plaintext,
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
# The returned plaintext must not match the client-supplied value
|
||||
returned = response.data['token']
|
||||
self.assertIsNotNone(returned)
|
||||
self.assertEqual(len(returned), TOKEN_DEFAULT_LENGTH)
|
||||
self.assertNotEqual(returned, chosen_plaintext)
|
||||
|
||||
# The stored secret must be the randomly-generated value, not the client-supplied one
|
||||
token = Token.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(token.version, version)
|
||||
if version == 1:
|
||||
# v1 tokens are authenticated by direct plaintext lookup
|
||||
self.assertEqual(token.plaintext, returned)
|
||||
self.assertNotEqual(token.plaintext, chosen_plaintext)
|
||||
else:
|
||||
# v2 tokens are authenticated by validating the plaintext against the stored HMAC digest
|
||||
self.assertTrue(token.validate(returned))
|
||||
self.assertFalse(token.validate(chosen_plaintext))
|
||||
|
||||
def test_reassign_token(self):
|
||||
"""
|
||||
Check that a Token cannot be reassigned to another User.
|
||||
|
|
|
|||
|
|
@ -245,10 +245,10 @@ class TokenTestCase(
|
|||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"token,user,description,enabled,write_enabled",
|
||||
f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token,true,true",
|
||||
f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token,true,false",
|
||||
f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token,false,true",
|
||||
"user,description,enabled,write_enabled",
|
||||
f"{users[0].pk},Test token,true,true",
|
||||
f"{users[1].pk},Test token,true,false",
|
||||
f"{users[1].pk},Test token,false,true",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
|
|
|||
Loading…
Reference in New Issue