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 "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 = (