From 8e4b3f9d911c5e6c1cd4317ba74c8f2d0b901bb7 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 24 Nov 2025 19:39:50 +0100 Subject: [PATCH] feat(users): Validate token expiration date on creation Add model-level validation to prevent creating tokens with a past expiration date. Updates to existing tokens are still allowed, ensuring flexibility for expired token modifications. Includes test cases to verify this behavior. Fixes #20823 --- netbox/users/models/tokens.py | 20 +++++++++ netbox/users/tests/test_models.py | 68 ++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 3c1284bc9ce..72abb6b5e26 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,8 +1,10 @@ import binascii import os +import zoneinfo from django.conf import settings from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.urls import reverse @@ -86,6 +88,24 @@ def get_absolute_url(self): def partial(self): return f'**********************************{self.key[-6:]}' if self.key else '' + def clean(self): + super().clean() + + # Prevent creating a token with a past expiration date + # while allowing updates to existing tokens. + if self.pk is None and self.is_expired: + current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE) + now = timezone.now().astimezone(current_tz) + current_time_str = f'{now.date().isoformat()} {now.time().isoformat(timespec="seconds")}' + + # Translators: {current_time} is the current server date and time in ISO format, + # {timezone} is the configured server time zone (for example, "UTC" or "Europe/Berlin"). + message = _('Expiration time must be in the future. ' + 'Current server time is {current_time} ({timezone}).' + ).format(current_time=current_time_str, timezone=current_tz.key) + + raise ValidationError({'expires': message}) + def save(self, *args, **kwargs): if not self.key: self.key = self.generate_key() diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 7bb6f31af74..d37ad671111 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,6 +1,72 @@ +from datetime import timedelta + +from django.core.exceptions import ValidationError from django.test import TestCase +from django.utils import timezone + +from users.models import User, Token +from utilities.testing import create_test_user + -from users.models import User +class TokenTest(TestCase): + """ + Test class for testing the functionality of the Token model. + """ + + @classmethod + def setUpTestData(cls): + """ + Set up test data for the Token model. + """ + cls.user = create_test_user('User 1') + + def test_is_expired(self): + """ + Test the is_expired property. + """ + # Token with no expiration + token = Token(user=self.user, expires=None) + self.assertFalse(token.is_expired) + + # Token with future expiration + token.expires = timezone.now() + timedelta(days=1) + self.assertFalse(token.is_expired) + + # Token with past expiration + token.expires = timezone.now() - timedelta(days=1) + self.assertTrue(token.is_expired) + + def test_cannot_create_token_with_past_expiration(self): + """ + Test that creating a token with an expiration date in the past raises a ValidationError. + """ + past_date = timezone.now() - timedelta(days=1) + token = Token(user=self.user, expires=past_date) + + with self.assertRaises(ValidationError) as cm: + token.clean() + self.assertIn('expires', cm.exception.error_dict) + + def test_can_update_existing_expired_token(self): + """ + Test that updating an already expired token does NOT raise a ValidationError. + """ + # Create a valid token first with an expiration date in the past + # bypasses the clean() method + token = Token.objects.create(user=self.user) + token.expires = timezone.now() - timedelta(days=1) + token.save() + + # Try to update the description + token.description = 'New Description' + try: + token.clean() + token.save() + except ValidationError: + self.fail('Updating an expired token should not raise ValidationError') + + token.refresh_from_db() + self.assertEqual(token.description, 'New Description') class UserConfigTest(TestCase):