Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions netbox/netbox/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions netbox/netbox/tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 4 additions & 0 deletions netbox/templates/users/token.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ <h2 class="card-header">{% trans "Token" %}</h2>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Write enabled" %}</th>
<td>{% checkmark object.write_enabled %}</td>
Expand Down
6 changes: 3 additions & 3 deletions netbox/users/api/serializers_/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion netbox/users/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 6 additions & 1 deletion netbox/users/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion netbox/users/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion netbox/users/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions netbox/users/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion netbox/users/migrations/0014_users_token_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
27 changes: 21 additions & 6 deletions netbox/users/models/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions netbox/users/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
Expand All @@ -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):
Expand Down
10 changes: 8 additions & 2 deletions netbox/users/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down Expand Up @@ -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,
},
]

Expand Down Expand Up @@ -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):
"""
Expand Down
9 changes: 9 additions & 0 deletions netbox/users/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,19 +285,22 @@ def setUpTestData(cls):
version=1,
user=users[0],
expires=future_date,
enabled=True,
write_enabled=True,
description='foobar1',
),
Token(
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,
),
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions netbox/users/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading