diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 7dbc38598e1..031be19da48 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -38,7 +38,7 @@ def authenticate(self, request): try: auth_value = auth[1].decode() except UnicodeError: - raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") + raise exceptions.AuthenticationFailed('Invalid authorization header: Token contains invalid characters') # Infer token version from presence or absence of prefix version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1 @@ -75,17 +75,21 @@ def authenticate(self, request): client_ip = get_client_ip(request) if client_ip is None: raise exceptions.AuthenticationFailed( - "Client IP address could not be determined for validation. Check that the HTTP server is " - "correctly configured to pass the required header(s)." + 'Client IP address could not be determined for validation. Check that the HTTP server is ' + 'correctly configured to pass the required header(s).' ) if not token.validate_client_ip(client_ip): raise exceptions.AuthenticationFailed( f"Source IP {client_ip} is not permitted to authenticate using this token." ) + # Enforce the Token is enabled + if not token.enabled: + raise exceptions.AuthenticationFailed('Token disabled') + # Enforce the Token's expiration time, if one has been set. if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") + raise exceptions.AuthenticationFailed('Token expired') # Update last used, but only once per minute at most. This reduces write load on the database if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 528d7e3f58e..5ed335dbf7d 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -66,6 +66,32 @@ def test_v2_token_invalid(self): self.assertEqual(response.status_code, 403) self.assertEqual(response.data['detail'], "Invalid v2 token") + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_enabled(self): + url = reverse('dcim-api:site-list') + + # Create v1 & v2 tokens + token1 = Token.objects.create(version=1, user=self.user, enabled=True) + token2 = Token.objects.create(version=2, user=self.user, enabled=True) + + # Request with an enabled token should succeed + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 200) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}') + self.assertEqual(response.status_code, 200) + + # Request with a disabled token should fail + token1.enabled = False + token1.save() + token2.enabled = False + token2.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data['detail'], 'Token disabled') + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}') + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data['detail'], 'Token disabled') + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_expiration(self): url = reverse('dcim-api:site-list') diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 86e96a6f3ab..7d8ee1a2f23 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -42,6 +42,10 @@

{% trans "Token" %}

{% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Enabled" %} + {% checkmark object.enabled %} + {% trans "Write enabled" %} {% checkmark object.write_enabled %} diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index fc0073c5bea..5a202dbfdd1 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -32,10 +32,10 @@ class Meta: model = Token fields = ( 'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires', - 'last_used', 'write_enabled', 'pepper_id', 'allowed_ips', 'token', + 'last_used', 'enabled', 'write_enabled', 'pepper_id', 'allowed_ips', 'token', ) read_only_fields = ('key',) - brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description') + brief_fields = ('id', 'url', 'display', 'version', 'key', 'enabled', 'write_enabled', 'description') def get_fields(self): fields = super().get_fields() @@ -79,7 +79,7 @@ class Meta: model = Token fields = ( 'id', 'url', 'display_url', 'display', 'version', 'user', 'key', 'created', 'expires', 'last_used', 'key', - 'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token', + 'enabled', 'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token', ) def validate(self, data): diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index c53166b5d3c..1bc1b6d860f 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -167,7 +167,8 @@ class TokenFilterSet(BaseFilterSet): class Meta: model = Token fields = ( - 'id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'created', 'expires', 'last_used', + 'id', 'version', 'key', 'pepper_id', 'enabled', 'write_enabled', + 'description', 'created', 'expires', 'last_used', ) def search(self, queryset, name, value): diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index 227711d9bc5..ac049cae643 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -99,6 +99,11 @@ class TokenBulkEditForm(BulkEditForm): queryset=Token.objects.all(), widget=forms.MultipleHiddenInput ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label=_('Enabled') + ) write_enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, @@ -122,7 +127,7 @@ class TokenBulkEditForm(BulkEditForm): model = Token fieldsets = ( - FieldSet('write_enabled', 'description', 'expires', 'allowed_ips'), + FieldSet('enabled', 'write_enabled', 'description', 'expires', 'allowed_ips'), ) nullable_fields = ( 'expires', 'description', 'allowed_ips', diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 776333c7bfa..16f2fd378b1 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -52,7 +52,7 @@ class TokenImportForm(CSVModelForm): class Meta: model = Token - fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',) + fields = ('user', 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description',) class OwnerGroupImportForm(CSVModelForm): diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index df5bc4da1ba..13502dc6581 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -114,7 +114,7 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): model = Token fieldsets = ( FieldSet('q', 'filter_id',), - FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), + FieldSet('version', 'user_id', 'enabled', 'write_enabled', 'expires', 'last_used', name=_('Token')), ) version = forms.ChoiceField( choices=add_blank_choice(TokenVersionChoices), @@ -125,6 +125,13 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('User') ) + enabled = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Enabled'), + ) write_enabled = forms.NullBooleanField( required=False, widget=forms.Select( diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index ee4bf838d5b..b9c2deed865 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -140,7 +140,7 @@ class UserTokenForm(forms.ModelForm): class Meta: model = Token fields = [ - 'version', 'token', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), @@ -177,7 +177,7 @@ class TokenForm(UserTokenForm): class Meta(UserTokenForm.Meta): fields = [ - 'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'user', 'enabled', 'write_enabled', 'expires', 'description', 'allowed_ips', ] def __init__(self, *args, **kwargs): diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index df45cf85de5..001da4b97bb 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -9,6 +9,13 @@ class Migration(migrations.Migration): ] operations = [ + # Add a new field to enable/disable tokens + migrations.AddField( + model_name='token', + name='enabled', + field=models.BooleanField(default=True), + ), + # Rename the original key field to "plaintext" migrations.RenameField( model_name='token', @@ -35,7 +42,7 @@ class Migration(migrations.Migration): ), ), - # Add version field to distinguish v1 and v2 tokens + # Add a version field to distinguish v1 and v2 tokens migrations.AddField( model_name='token', name='version', diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 8ea09417a94..f5b9f461c31 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -61,6 +61,11 @@ class Token(models.Model): blank=True, null=True ) + enabled = models.BooleanField( + verbose_name=_('enabled'), + default=True, + help_text=_('Disable to temporarily revoke this token without deleting it.'), + ) write_enabled = models.BooleanField( verbose_name=_('write enabled'), default=True, @@ -180,6 +185,22 @@ def token(self, value): self.key = self.key or self.generate_key() self.update_digest() + @property + def is_expired(self): + """ + Check whether the token has expired. + """ + if self.expires is None or timezone.now() < self.expires: + return False + return True + + @property + def is_active(self): + """ + Check whether the token is active (enabled and not expired). + """ + return self.enabled and not self.is_expired + def clean(self): super().clean() @@ -236,12 +257,6 @@ def update_digest(self): hashlib.sha256 ).hexdigest() - @property - def is_expired(self): - if self.expires is None or timezone.now() < self.expires: - return False - return True - def validate(self, token): """ Validate the given plaintext against the token. diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 29dea7f93d8..fd6e050b994 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -25,6 +25,9 @@ class TokenTable(NetBoxTable): verbose_name=_('token'), template_code=TOKEN, ) + enabled = columns.BooleanColumn( + verbose_name=_('Enabled') + ) write_enabled = columns.BooleanColumn( verbose_name=_('Write Enabled') ) @@ -49,10 +52,10 @@ class TokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', - 'last_used', 'allowed_ips', + 'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'enabled', 'write_enabled', 'created', + 'expires', 'last_used', 'allowed_ips', ) - default_columns = ('token', 'version', 'user', 'write_enabled', 'description', 'allowed_ips') + default_columns = ('token', 'version', 'user', 'enabled', 'write_enabled', 'description', 'allowed_ips') class UserTable(NetBoxTable): diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 0e1ccebf857..5eed904c8db 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -195,10 +195,10 @@ class TokenTest( APIViewTestCases.ListObjectsViewTestCase, APIViewTestCases.CreateObjectViewTestCase, APIViewTestCases.UpdateObjectViewTestCase, - APIViewTestCases.DeleteObjectViewTestCase + APIViewTestCases.DeleteObjectViewTestCase, ): model = Token - brief_fields = ['description', 'display', 'id', 'key', 'url', 'version', 'write_enabled'] + brief_fields = ['description', 'display', 'enabled', 'id', 'key', 'url', 'version', 'write_enabled'] bulk_update_data = { 'description': 'New description', } @@ -229,12 +229,16 @@ def setUpTestData(cls): cls.create_data = [ { 'user': users[0].pk, + 'enabled': True, }, { 'user': users[1].pk, + 'enabled': False, }, { 'user': users[2].pk, + 'enabled': True, + 'write_enabled': False, }, ] @@ -267,6 +271,8 @@ def test_provision_token_valid(self): self.assertEqual(response.data['expires'], data['expires']) token = Token.objects.get(user=user) self.assertEqual(token.key, response.data['key']) + self.assertEqual(token.enabled, response.data['enabled']) + self.assertEqual(token.write_enabled, response.data['write_enabled']) def test_provision_token_invalid(self): """ diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 745b001262c..3515675c8e1 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -285,6 +285,7 @@ def setUpTestData(cls): version=1, user=users[0], expires=future_date, + enabled=True, write_enabled=True, description='foobar1', ), @@ -292,12 +293,14 @@ def setUpTestData(cls): version=2, user=users[1], expires=future_date, + enabled=False, write_enabled=True, description='foobar2', ), Token( version=2, user=users[2], + enabled=True, expires=past_date, write_enabled=False, ), @@ -339,6 +342,12 @@ def test_expires(self): params = {'expires__lte': '2021-01-01T00:00:00'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_write_enabled(self): params = {'write_enabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index d37ad671111..367a82373bc 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -20,6 +20,32 @@ def setUpTestData(cls): """ cls.user = create_test_user('User 1') + def test_is_active(self): + """ + Test the is_active property. + """ + # Token with enabled status and no expiration date + token = Token(user=self.user, enabled=True, expires=None) + self.assertTrue(token.is_active) + + # Token with disabled status + token.enabled = False + self.assertFalse(token.is_active) + + # Token with enabled status and future expiration + future_date = timezone.now() + timedelta(days=1) + token = Token(user=self.user, enabled=True, expires=future_date) + self.assertTrue(token.is_active) + + # Token with past expiration + token.expires = timezone.now() - timedelta(days=1) + self.assertFalse(token.is_active) + + # Token with disabled status and past expiration + past_date = timezone.now() - timedelta(days=1) + token = Token(user=self.user, enabled=False, expires=past_date) + self.assertFalse(token.is_active) + def test_is_expired(self): """ Test the is_expired property. diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 1980299fdd8..0536f0a0761 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -236,13 +236,14 @@ def setUpTestData(cls): 'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5', 'user': users[0].pk, 'description': 'Test token', + 'enabled': True, } cls.csv_data = ( - "token,user,description", - f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token", - f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token", - f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token", + "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", ) cls.csv_update_data = (