From be9196f1530e05eb9bcea05f20bb7dd1c4b5a478 Mon Sep 17 00:00:00 2001 From: Matthew Oldach Date: Thu, 28 May 2026 08:37:50 -0600 Subject: [PATCH] fix: expire authentication tokens Add configurable auth token expiration to TokenAuthentication. Tokens expire after 30 days by default, can be disabled with SIMPLE_LOGIN_TOKEN_EXPIRY = None, and integer settings are treated as seconds. --- simple_login/tests/__init__.py | 1 + simple_login/tests/settings.py | 18 +++++++++++++ simple_login/tests/test_authentication.py | 31 +++++++++++++++++++++++ simple_login/tests/urls.py | 1 + simple_login/utils/authentication.py | 27 ++++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 simple_login/tests/__init__.py create mode 100644 simple_login/tests/settings.py create mode 100644 simple_login/tests/test_authentication.py create mode 100644 simple_login/tests/urls.py diff --git a/simple_login/tests/__init__.py b/simple_login/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/simple_login/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/simple_login/tests/settings.py b/simple_login/tests/settings.py new file mode 100644 index 0000000..b0ed2c0 --- /dev/null +++ b/simple_login/tests/settings.py @@ -0,0 +1,18 @@ +SECRET_KEY = 'test-secret-key' + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'rest_framework.authtoken', + 'simple_login', +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +USE_TZ = True +ROOT_URLCONF = 'simple_login.tests.urls' diff --git a/simple_login/tests/test_authentication.py b/simple_login/tests/test_authentication.py new file mode 100644 index 0000000..a5135b5 --- /dev/null +++ b/simple_login/tests/test_authentication.py @@ -0,0 +1,31 @@ +from datetime import timedelta +from unittest import mock + +from django.test import SimpleTestCase, override_settings +from django.utils import timezone + +from simple_login.utils.authentication import TokenAuthentication + + +class TokenAuthenticationTests(SimpleTestCase): + @override_settings(SIMPLE_LOGIN_TOKEN_EXPIRY=60) + def test_integer_token_expiry_is_treated_as_seconds(self): + self.assertEqual(TokenAuthentication.get_token_expiry(), timedelta(seconds=60)) + + @override_settings(SIMPLE_LOGIN_TOKEN_EXPIRY=None) + def test_none_disables_token_expiry(self): + token = mock.Mock(created=timezone.now() - timedelta(days=365)) + + self.assertFalse(TokenAuthentication.has_token_expired(token)) + + @override_settings(SIMPLE_LOGIN_TOKEN_EXPIRY=timedelta(days=30)) + def test_old_token_is_expired(self): + token = mock.Mock(created=timezone.now() - timedelta(days=31)) + + self.assertTrue(TokenAuthentication.has_token_expired(token)) + + @override_settings(SIMPLE_LOGIN_TOKEN_EXPIRY=timedelta(days=30)) + def test_recent_token_is_valid(self): + token = mock.Mock(created=timezone.now() - timedelta(days=1)) + + self.assertFalse(TokenAuthentication.has_token_expired(token)) diff --git a/simple_login/tests/urls.py b/simple_login/tests/urls.py new file mode 100644 index 0000000..637600f --- /dev/null +++ b/simple_login/tests/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/simple_login/utils/authentication.py b/simple_login/utils/authentication.py index e382146..d8ea0e4 100644 --- a/simple_login/utils/authentication.py +++ b/simple_login/utils/authentication.py @@ -15,9 +15,36 @@ # along with this program. If not, see . # +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone +from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication as TokenAuth + from simple_login.models.api import Tokens class TokenAuthentication(TokenAuth): model = Tokens + + def authenticate_credentials(self, key): + user, token = super().authenticate_credentials(key) + if self.has_token_expired(token): + token.delete() + raise exceptions.AuthenticationFailed('Token has expired.') + return user, token + + @staticmethod + def get_token_expiry(): + expiry = getattr(settings, 'SIMPLE_LOGIN_TOKEN_EXPIRY', timedelta(days=30)) + if isinstance(expiry, int): + return timedelta(seconds=expiry) + return expiry + + @classmethod + def has_token_expired(cls, token): + expiry = cls.get_token_expiry() + if expiry is None: + return False + return token.created + expiry < timezone.now()