Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-django-otp for
openSUSE:Factory checked in at 2026-02-17 18:14:02
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-django-otp (Old)
and /work/SRC/openSUSE:Factory/.python-django-otp.new.1977 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-django-otp"
Tue Feb 17 18:14:02 2026 rev:5 rq:1333421 version:1.7.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-django-otp/python-django-otp.changes
2025-11-06 18:18:29.800866935 +0100
+++
/work/SRC/openSUSE:Factory/.python-django-otp.new.1977/python-django-otp.changes
2026-02-17 18:14:08.248908482 +0100
@@ -1,0 +2,7 @@
+Mon Feb 16 17:27:46 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 1.7.0:
+ * #185: Make OTPMiddleware async capable
+ * #182: Correct missing Spanish translations
+
+-------------------------------------------------------------------
Old:
----
django_otp-1.6.3.tar.gz
New:
----
django_otp-1.7.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-django-otp.spec ++++++
--- /var/tmp/diff_new_pack.tDgyr8/_old 2026-02-17 18:14:09.368955151 +0100
+++ /var/tmp/diff_new_pack.tDgyr8/_new 2026-02-17 18:14:09.372955317 +0100
@@ -1,7 +1,7 @@
#
# spec file for package python-django-otp
#
-# Copyright (c) 2025 SUSE LLC and contributors
+# Copyright (c) 2026 SUSE LLC and contributors
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
@@ -17,7 +17,7 @@
Name: python-django-otp
-Version: 1.6.3
+Version: 1.7.0
Release: 0
Summary: Add two-factor authentication to Django using one-time
passwords
License: Unlicense
++++++ django_otp-1.6.3.tar.gz -> django_otp-1.7.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/CHANGES.rst
new/django_otp-1.7.0/CHANGES.rst
--- old/django_otp-1.6.3/CHANGES.rst 2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/CHANGES.rst 2020-02-02 01:00:00.000000000 +0100
@@ -1,3 +1,13 @@
+v1.7.0 - January 07, 2026 - Async support
+--------------------------------------------------------------------------------
+
+- `#185`_: Make OTPMiddleware async capable
+
+Thanks to Aljosha Papsch.
+
+.. _#185: https://github.com/django-otp/django-otp/pull/185
+
+
v1.6.3 - October 25, 2025 - Spanish update
--------------------------------------------------------------------------------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/PKG-INFO
new/django_otp-1.7.0/PKG-INFO
--- old/django_otp-1.6.3/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: django-otp
-Version: 1.6.3
+Version: 1.7.0
Summary: A pluggable framework for adding two-factor authentication to Django
using one-time passwords.
Project-URL: Homepage, https://github.com/django-otp/django-otp
Project-URL: Documentation, https://django-otp-official.readthedocs.io/
@@ -9,16 +9,13 @@
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Django
-Classifier: Framework :: Django :: 4.2
-Classifier: Framework :: Django :: 5.1
-Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
-Requires-Python: >=3.7
+Requires-Python: >=3.8
Requires-Dist: django>=4.2
Provides-Extra: qrcode
Requires-Dist: qrcode; extra == 'qrcode'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/docs/source/conf.py
new/django_otp-1.7.0/docs/source/conf.py
--- old/django_otp-1.6.3/docs/source/conf.py 2020-02-02 01:00:00.000000000
+0100
+++ new/django_otp-1.7.0/docs/source/conf.py 2020-02-02 01:00:00.000000000
+0100
@@ -89,7 +89,7 @@
# built documents.
#
# The full version, including alpha/beta/rc tags.
-release = '1.6.3'
+release = '1.7.0'
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/docs/source/overview.rst
new/django_otp-1.7.0/docs/source/overview.rst
--- old/django_otp-1.6.3/docs/source/overview.rst 2020-02-02
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/docs/source/overview.rst 2020-02-02
01:00:00.000000000 +0100
@@ -162,6 +162,9 @@
counterpart to ``user.is_authenticated()``. It is not possible for a user to be
verified without also being authenticated. [#agents]_
+The middleware is async capable. Using ``await request.auser()`` returns the
+user model augmented with the same properties as when using ``request.user``.
+
Plugins and Devices
-------------------
@@ -522,6 +525,15 @@
- `django-otp-twilio`_ supports delivering tokens via Twilio's SMS service.
+Asynchronous Support
+--------------------
+
+:class:`django_otp.middleware.OTPMiddleware` is async capable, ensuring there
+is no context switch caused by this middleware in ASGI servers. Both
+``request.user`` and ``request.auser()`` return the user model augmented with
+additional properties.
+
+
Settings
--------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/pyproject.toml
new/django_otp-1.7.0/pyproject.toml
--- old/django_otp-1.6.3/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -1,9 +1,9 @@
[project]
name = "django-otp"
-version = "1.6.3"
+version = "1.7.0"
description = "A pluggable framework for adding two-factor authentication to
Django using one-time passwords."
readme = "README.rst"
-requires-python = ">=3.7"
+requires-python = ">=3.8"
license = "Unlicense"
authors = [
{ name = "Peter Sagerson", email = "[email protected]" },
@@ -11,9 +11,6 @@
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Django",
- "Framework :: Django :: 4.2",
- "Framework :: Django :: 5.1",
- "Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"License :: OSI Approved :: The Unlicense (Unlicense)",
"Programming Language :: Python :: 3",
@@ -91,8 +88,8 @@
[tool.hatch.envs.test.overrides]
matrix.django.dependencies = [
{ value = "django ~= 4.2.0", if = ["4.2"] },
- { value = "django ~= 5.1.0", if = ["5.1"] },
{ value = "django ~= 5.2.0", if = ["5.2"] },
+ { value = "django ~= 6.0.0", if = ["6.0"] },
]
matrix.mode.scripts = [
{ key = "run", value = "lint", if = ["lint"] },
@@ -110,11 +107,11 @@
[[tool.hatch.envs.test.matrix]]
python = ["3.11"]
-django = ["5.1"]
+django = ["5.2"]
[[tool.hatch.envs.test.matrix]]
python = ["3.13"]
-django = ["5.2"]
+django = ["6.0"]
[[tool.hatch.envs.test.matrix]]
mode = ["coverage"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/middleware.py
new/django_otp-1.7.0/src/django_otp/middleware.py
--- old/django_otp-1.6.3/src/django_otp/middleware.py 2020-02-02
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/middleware.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,5 +1,7 @@
import functools
+from asgiref.sync import iscoroutinefunction, markcoroutinefunction
+
from django.utils.functional import SimpleLazyObject
from django_otp import DEVICE_ID_SESSION_KEY
@@ -20,27 +22,76 @@
object that has verified the user, or ``None`` if the user has not been
verified. As a convenience, this also installs ``user.is_verified()``,
which returns ``True`` if ``user.otp_device`` is not ``None``.
+
+ This middleware is async capable. It wraps ``request.auser()`` similarly
+ to ``request.user`` as described above.
"""
- def __init__(self, get_response=None):
+ sync_capable = True
+ async_capable = True
+
+ def __init__(self, get_response):
self.get_response = get_response
+ self._is_async = iscoroutinefunction(get_response)
+ if self._is_async:
+ markcoroutinefunction(self)
def __call__(self, request):
- user = getattr(request, 'user', None)
+ if self._is_async:
+ return self.__acall__(request)
+
+ self._install_lazy_accessors(request)
+ return self.get_response(request)
+
+ async def __acall__(self, request):
+ self._install_lazy_accessors(request)
+ return await self.get_response(request)
+
+ def _install_lazy_accessors(self, request):
+ user = getattr(request, "user", None)
if user is not None:
request.user = SimpleLazyObject(
- functools.partial(self._verify_user, request, user)
+ functools.partial(self._verify_user_sync, request, user)
)
- return self.get_response(request)
+ auser = getattr(request, "auser", None)
+ if auser is not None:
+ request.auser = functools.partial(
+ self._verify_user_async_via_auser, request, auser
+ )
- def _verify_user(self, request, user):
- """
- Sets OTP-related fields on an authenticated user.
- """
+ @staticmethod
+ def _init_user_fields(user):
user.otp_device = None
user.is_verified = functools.partial(is_verified, user)
+ @staticmethod
+ def _normalize_persistent_id(persistent_id: str) -> str:
+ # Convert legacy persistent_id values (these used to be full import
+ # paths). This won't work for apps with models in sub-modules, but that
+ # should be pretty rare. And the worst that happens is the user has to
+ # log in again.
+ if persistent_id.count(".") > 1:
+ parts = persistent_id.split(".")
+ return ".".join((parts[-3], parts[-1]))
+ return persistent_id
+
+ @staticmethod
+ def _finalize_device(request, user, device):
+ """
+ Enforce device-user binding and keep session state consistent.
+ """
+ if (device is not None) and (device.user_id != user.pk):
+ device = None
+
+ if (device is None) and (DEVICE_ID_SESSION_KEY in request.session):
+ del request.session[DEVICE_ID_SESSION_KEY]
+
+ return device
+
+ def _verify_user_sync(self, request, user):
+ self._init_user_fields(user)
+
if user.is_authenticated:
persistent_id = request.session.get(DEVICE_ID_SESSION_KEY)
device = (
@@ -48,26 +99,29 @@
if persistent_id
else None
)
+ user.otp_device = self._finalize_device(request, user, device)
- if (device is not None) and (device.user_id != user.pk):
- device = None
+ return user
- if (device is None) and (DEVICE_ID_SESSION_KEY in request.session):
- del request.session[DEVICE_ID_SESSION_KEY]
+ def _device_from_persistent_id(self, persistent_id: str):
+ persistent_id = self._normalize_persistent_id(persistent_id)
+ return Device.from_persistent_id(persistent_id)
+
+ async def _verify_user_async_via_auser(self, request, auser):
+ user = await auser()
+ self._init_user_fields(user)
- user.otp_device = device
+ if user.is_authenticated:
+ persistent_id = request.session.get(DEVICE_ID_SESSION_KEY)
+ device = (
+ await self._adevice_from_persistent_id(persistent_id)
+ if persistent_id
+ else None
+ )
+ user.otp_device = self._finalize_device(request, user, device)
return user
- def _device_from_persistent_id(self, persistent_id):
- # Convert legacy persistent_id values (these used to be full import
- # paths). This won't work for apps with models in sub-modules, but that
- # should be pretty rare. And the worst that happens is the user has to
- # log in again.
- if persistent_id.count('.') > 1:
- parts = persistent_id.split('.')
- persistent_id = '.'.join((parts[-3], parts[-1]))
-
- device = Device.from_persistent_id(persistent_id)
-
- return device
+ async def _adevice_from_persistent_id(self, persistent_id: str):
+ persistent_id = self._normalize_persistent_id(persistent_id)
+ return await Device.afrom_persistent_id(persistent_id)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/models.py
new/django_otp-1.7.0/src/django_otp/models.py
--- old/django_otp-1.6.3/src/django_otp/models.py 2020-02-02
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/models.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,3 +1,4 @@
+from contextlib import suppress
from datetime import timedelta
import enum
@@ -133,22 +134,28 @@
this must be called inside a transaction.
"""
- device = None
+ with suppress(ValueError, LookupError):
+ return cls._filter_persistent_id(persistent_id, for_verify).first()
+ return None
- try:
- model_label, device_id = persistent_id.rsplit('/', 1)
- app_label, model_name = model_label.split('.')
-
- device_cls = apps.get_model(app_label, model_name)
- if issubclass(device_cls, Device):
- device_set = device_cls.objects.filter(id=int(device_id))
- if for_verify:
- device_set = device_set.select_for_update()
- device = device_set.first()
- except (ValueError, LookupError):
- pass
+ @classmethod
+ async def afrom_persistent_id(cls, persistent_id, for_verify=False):
+ with suppress(ValueError, LookupError):
+ return await cls._filter_persistent_id(persistent_id,
for_verify).afirst()
+ return None
- return device
+ @classmethod
+ def _filter_persistent_id(cls, persistent_id, for_verify=False):
+ model_label, device_id = persistent_id.rsplit("/", 1)
+ app_label, model_name = model_label.split(".")
+
+ device_cls = apps.get_model(app_label, model_name)
+ if issubclass(device_cls, Device):
+ device_set = device_cls.objects.filter(id=int(device_id))
+ if for_verify:
+ device_set = device_set.select_for_update()
+ return device_set
+ return None
def is_interactive(self):
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_otp-1.6.3/src/django_otp/plugins/otp_email/tests.py
new/django_otp-1.7.0/src/django_otp/plugins/otp_email/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_email/tests.py
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_email/tests.py
2020-02-02 01:00:00.000000000 +0100
@@ -7,7 +7,7 @@
from django.test.utils import override_settings
from django_otp.forms import OTPAuthenticationForm
-from django_otp.tests import (
+from django_otp.test_utils import (
CooldownTestMixin,
TestCase,
ThrottlingTestMixin,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_otp-1.6.3/src/django_otp/plugins/otp_hotp/tests.py
new/django_otp-1.7.0/src/django_otp/plugins/otp_hotp/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_hotp/tests.py
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_hotp/tests.py
2020-02-02 01:00:00.000000000 +0100
@@ -13,7 +13,7 @@
from django.urls import reverse
from django_otp.forms import OTPAuthenticationForm
-from django_otp.tests import TestCase, ThrottlingTestMixin, TimestampTestMixin
+from django_otp.test_utils import TestCase, ThrottlingTestMixin,
TimestampTestMixin
from .admin import HOTPDeviceAdmin
from .models import HOTPDevice
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_otp-1.6.3/src/django_otp/plugins/otp_static/tests.py
new/django_otp-1.7.0/src/django_otp/plugins/otp_static/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_static/tests.py
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_static/tests.py
2020-02-02 01:00:00.000000000 +0100
@@ -6,7 +6,7 @@
from django.test.utils import override_settings
from django_otp.forms import OTPAuthenticationForm
-from django_otp.tests import TestCase, ThrottlingTestMixin, TimestampTestMixin
+from django_otp.test_utils import TestCase, ThrottlingTestMixin,
TimestampTestMixin
from .admin import StaticDeviceAdmin, StaticTokenInline
from .lib import add_static_token
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/django_otp-1.6.3/src/django_otp/plugins/otp_totp/tests.py
new/django_otp-1.7.0/src/django_otp/plugins/otp_totp/tests.py
--- old/django_otp-1.6.3/src/django_otp/plugins/otp_totp/tests.py
2020-02-02 01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/plugins/otp_totp/tests.py
2020-02-02 01:00:00.000000000 +0100
@@ -10,7 +10,7 @@
from django.test.utils import override_settings
from django.urls import reverse
-from django_otp.tests import TestCase, ThrottlingTestMixin, TimestampTestMixin
+from django_otp.test_utils import TestCase, ThrottlingTestMixin,
TimestampTestMixin
from .admin import TOTPDeviceAdmin
from .models import TOTPDevice
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/test_utils.py
new/django_otp-1.7.0/src/django_otp/test_utils.py
--- old/django_otp-1.6.3/src/django_otp/test_utils.py 1970-01-01
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/test_utils.py 2020-02-02
01:00:00.000000000 +0100
@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from freezegun import freeze_time
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase as DjangoTestCase
+from django.test import TransactionTestCase as DjangoTransactionTestCase
+from django.utils import timezone
+
+from django_otp.models import GenerateNotAllowed, VerifyNotAllowed
+
+
+class OTPTestCaseMixin:
+ """
+ Utilities for dealing with custom user models.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.User = get_user_model()
+ cls.USERNAME_FIELD = cls.User.USERNAME_FIELD
+
+ def create_user(self, username, password, **kwargs):
+ """
+ Try to create a user, honoring the custom user model, if any.
+
+ This may raise an exception if the user model is too exotic for our
+ purposes.
+ """
+ return self.User.objects.create_user(username, password=password,
**kwargs)
+
+
+class TestCase(OTPTestCaseMixin, DjangoTestCase):
+ pass
+
+
+class TransactionTestCase(OTPTestCaseMixin, DjangoTransactionTestCase):
+ pass
+
+
+class TimestampTestMixin:
+ """
+ Generic tests for :class:`~django_otp.models.TimestampMixin`.
+
+ Implementing tests must initialize `self.device` with the model instance to
+ test and provide `valid_token` and `invalid_token` methods for verifying
+ token behavior.
+
+ Includes tests to:
+
+ - Check automatic setting of `created_at` upon object creation.
+ - Validate that `last_used_at` is initially None and updated only after
+ successful token verification.
+ - Ensure `set_last_used_timestamp` behaves correctly, respecting the
+ `commit` parameter.
+
+ """
+
+ def setUp(self):
+ self.device = None
+
+ def valid_token(self):
+ """Returns a valid token to pass to our device under test."""
+ raise NotImplementedError()
+
+ def invalid_token(self):
+ """Returns an invalid token to pass to our device under test."""
+ raise NotImplementedError()
+
+ #
+ # Tests
+ #
+
+ def test_created_at_set_on_creation(self):
+ """Verify that the `created_at` field is automatically set upon
creation."""
+ self.assertIsNotNone(
+ self.device.created_at, "created_at should be automatically set."
+ )
+
+ def test_last_used_at_initially_none(self):
+ """Ensure `last_used_at` is None upon initial creation."""
+ self.assertIsNone(
+ self.device.last_used_at, "last_used_at should be None initially."
+ )
+
+ def test_set_last_used_timestamp_updates_field(self):
+ """Check if `set_last_used_timestamp` correctly updates the
`last_used_at` field."""
+ self.device.set_last_used_timestamp(commit=True)
+ self.device.refresh_from_db() # Assuming it's a persisted model
+
+ self.assertIsNotNone(
+ self.device.last_used_at, "last_used_at should be updated."
+ )
+
+ def test_set_last_used_timestamp_without_commit(self):
+ """
+ Ensure `set_last_used_timestamp` updates `last_used_at` without
persisting
+ when commit=False.
+ """
+ original_last_used_at = self.device.last_used_at
+ self.device.set_last_used_timestamp(commit=False)
+ # Check in-memory update without saving
+ self.assertNotEqual(
+ self.device.last_used_at,
+ original_last_used_at,
+ "last_used_at should be updated in memory without commit.",
+ )
+
+ # Refresh from db to confirm it wasn't committed
+ self.device.refresh_from_db()
+ self.assertEqual(
+ self.device.last_used_at,
+ original_last_used_at,
+ "last_used_at should not be updated in db without commit.",
+ )
+
+ def test_verify_token_successful_updates_last_used_at(self):
+ """
+ Verifying with a valid token updates 'last_used_at'.
+ """
+ valid_token = self.valid_token() # Method to generate a valid token
+ initial_last_used_at = self.device.last_used_at
+ verified = self.device.verify_token(valid_token)
+
+ self.assertTrue(verified, "Token should be verified successfully.")
+ self.device.refresh_from_db()
+ self.assertNotEqual(
+ self.device.last_used_at,
+ initial_last_used_at,
+ "'last_used_at' should be updated on successful verification.",
+ )
+
+ def test_verify_token_failed_does_not_update_last_used_at(self):
+ """
+ Verifying with an invalid token does not update 'last_used_at'.
+ """
+ invalid_token = self.invalid_token() # Method to generate an invalid
token
+ initial_last_used_at = self.device.last_used_at
+ verified = self.device.verify_token(invalid_token)
+
+ self.assertFalse(verified, "Token should not be verified.")
+ self.device.refresh_from_db()
+ self.assertEqual(
+ self.device.last_used_at,
+ initial_last_used_at,
+ "'last_used_at' should not be updated on failed verification.",
+ )
+
+
+class ThrottlingTestMixin:
+ """
+ Generic tests for throttled devices.
+
+ Any concrete device implementation that uses throttling should define a
+ TestCase subclass that includes this as a base class. This will help verify
+ a correct integration of ThrottlingMixin.
+
+ Subclasses are responsible for populating self.device with a device to test
+ as well as implementing methods to generate tokens to test with.
+
+ """
+
+ def setUp(self):
+ self.device = None
+
+ def valid_token(self):
+ """Returns a valid token to pass to our device under test."""
+ raise NotImplementedError()
+
+ def invalid_token(self):
+ """Returns an invalid token to pass to our device under test."""
+ raise NotImplementedError()
+
+ #
+ # Tests
+ #
+
+ def test_delay_imposed_after_fail(self):
+ verified1 = self.device.verify_token(self.invalid_token())
+ self.assertFalse(verified1)
+ verified2 = self.device.verify_token(self.valid_token())
+ self.assertFalse(verified2)
+
+ def test_delay_after_fail_expires(self):
+ verified1 = self.device.verify_token(self.invalid_token())
+ self.assertFalse(verified1)
+ with freeze_time() as frozen_time:
+ # With default settings initial delay is 1 second
+ frozen_time.tick(delta=timedelta(seconds=1.1))
+ verified2 = self.device.verify_token(self.valid_token())
+ self.assertTrue(verified2)
+
+ def test_throttling_failure_count(self):
+ self.assertEqual(self.device.throttling_failure_count, 0)
+ for _ in range(0, 5):
+ self.device.verify_token(self.invalid_token())
+ # Only the first attempt will increase throttling_failure_count,
+ # the others will all be within 1 second of first
+ # and therefore not count as attempts.
+ self.assertEqual(self.device.throttling_failure_count, 1)
+
+ def test_verify_is_allowed(self):
+ # Initially should be allowed
+ verify_is_allowed1, data1 = self.device.verify_is_allowed()
+ self.assertEqual(verify_is_allowed1, True)
+ self.assertEqual(data1, None)
+
+ # After failure, verify is not allowed
+ with freeze_time():
+ self.device.verify_token(self.invalid_token())
+ verify_is_allowed2, data2 = self.device.verify_is_allowed()
+ self.assertEqual(verify_is_allowed2, False)
+ self.assertEqual(
+ data2,
+ {
+ 'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS,
+ 'failure_count': 1,
+ 'locked_until': timezone.now() +
timezone.timedelta(seconds=1),
+ },
+ )
+
+ # After a successful attempt, should be allowed again
+ with freeze_time() as frozen_time:
+ frozen_time.tick(delta=timedelta(seconds=1.1))
+ self.device.verify_token(self.valid_token())
+
+ verify_is_allowed3, data3 = self.device.verify_is_allowed()
+ self.assertEqual(verify_is_allowed3, True)
+ self.assertEqual(data3, None)
+
+
+class CooldownTestMixin:
+ def setUp(self):
+ self.device = None
+
+ def valid_token(self):
+ """Returns a valid token to pass to our device under test."""
+ raise NotImplementedError()
+
+ def invalid_token(self):
+ """Returns an invalid token to pass to our device under test."""
+ raise NotImplementedError()
+
+ #
+ # Tests
+ #
+
+ def test_generate_is_allowed_on_first_try(self):
+ """Token generation should be allowed on first try."""
+ allowed, _ = self.device.generate_is_allowed()
+ self.assertTrue(allowed)
+
+ def test_cooldown_imposed_after_successful_generation(self):
+ """
+ Token generation before cooldown should not be allowed
+ and the relevant reason should be returned.
+ """
+ with freeze_time():
+ self.device.generate_challenge()
+ self.device.refresh_from_db()
+ allowed, details = self.device.generate_is_allowed()
+
+ self.assertFalse(allowed)
+ self.assertEqual(
+ details['reason'], GenerateNotAllowed.COOLDOWN_DURATION_PENDING
+ )
+
+ def test_cooldown_expire_time(self):
+ """
+ When token generation is not allowed, the cooldown expire time
+ should be returned.
+ """
+ with freeze_time():
+ self.device.generate_challenge()
+ self.device.refresh_from_db()
+ _, details = self.device.generate_is_allowed()
+ self.assertEqual(
+ details['next_generation_at'], timezone.now() +
timedelta(seconds=10)
+ )
+
+ def test_cooldown_reset(self):
+ """Cooldown can be reset and allow token generation again before the
initial period expires."""
+ with freeze_time():
+ self.device.generate_is_allowed()
+ self.device.refresh_from_db()
+ self.device.cooldown_reset()
+ self.device.refresh_from_db()
+ allowed, _ = self.device.generate_is_allowed()
+ self.assertTrue(allowed)
+
+ def test_valid_token_verification_resets_cooldown(self):
+ """When the token is verified, the cooldown period is reset."""
+ with freeze_time():
+ self.device.generate_challenge()
+ self.device.refresh_from_db()
+ verified = self.device.verify_token(self.valid_token())
+ self.assertTrue(verified)
+ self.device.refresh_from_db()
+ allowed, _ = self.device.generate_is_allowed()
+ self.assertTrue(allowed)
+
+ def test_invalid_token_verification_does_not_reset_cooldown(self):
+ """When the token is not verified, the cooldown period is not reset."""
+ with freeze_time():
+ self.device.generate_challenge()
+ self.device.refresh_from_db()
+ verified = self.device.verify_token(self.invalid_token())
+ self.assertFalse(verified)
+ self.device.refresh_from_db()
+ allowed, _ = self.device.generate_is_allowed()
+ self.assertFalse(allowed)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/django_otp-1.6.3/src/django_otp/tests.py
new/django_otp-1.7.0/src/django_otp/tests.py
--- old/django_otp-1.6.3/src/django_otp/tests.py 2020-02-02
01:00:00.000000000 +0100
+++ new/django_otp-1.7.0/src/django_otp/tests.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,24 +1,16 @@
-from datetime import timedelta
from doctest import DocTestSuite
from io import StringIO
import pickle
from threading import Thread
import unittest
-from freezegun import freeze_time
-
-from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import IntegrityError, connection
-from django.test import RequestFactory
-from django.test import TestCase as DjangoTestCase
-from django.test import TransactionTestCase as DjangoTransactionTestCase
-from django.test import skipUnlessDBFeature
+from django.test import AsyncRequestFactory, RequestFactory,
skipUnlessDBFeature
from django.test.utils import override_settings
from django.urls import reverse
-from django.utils import timezone
from django_otp import (
DEVICE_ID_SESSION_KEY,
@@ -31,9 +23,10 @@
)
from django_otp.forms import OTPTokenForm, otp_verification_failed
from django_otp.middleware import OTPMiddleware
-from django_otp.models import GenerateNotAllowed, VerifyNotAllowed
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
+from .test_utils import TestCase, TransactionTestCase
+
def load_tests(loader, tests, pattern):
suite = unittest.TestSuite()
@@ -53,309 +46,6 @@
connection.close()
-class OTPTestCaseMixin:
- """
- Utilities for dealing with custom user models.
- """
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
-
- cls.User = get_user_model()
- cls.USERNAME_FIELD = cls.User.USERNAME_FIELD
-
- def create_user(self, username, password, **kwargs):
- """
- Try to create a user, honoring the custom user model, if any.
-
- This may raise an exception if the user model is too exotic for our
- purposes.
- """
- return self.User.objects.create_user(username, password=password,
**kwargs)
-
-
-class TestCase(OTPTestCaseMixin, DjangoTestCase):
- pass
-
-
-class TransactionTestCase(OTPTestCaseMixin, DjangoTransactionTestCase):
- pass
-
-
-class TimestampTestMixin:
- """
- Generic tests for :class:`~django_otp.models.TimestampMixin`.
-
- Implementing tests must initialize `self.device` with the model instance to
- test and provide `valid_token` and `invalid_token` methods for verifying
- token behavior.
-
- Includes tests to:
-
- - Check automatic setting of `created_at` upon object creation.
- - Validate that `last_used_at` is initially None and updated only after
- successful token verification.
- - Ensure `set_last_used_timestamp` behaves correctly, respecting the
- `commit` parameter.
-
- """
-
- def setUp(self):
- self.device = None
-
- def valid_token(self):
- """Returns a valid token to pass to our device under test."""
- raise NotImplementedError()
-
- def invalid_token(self):
- """Returns an invalid token to pass to our device under test."""
- raise NotImplementedError()
-
- #
- # Tests
- #
-
- def test_created_at_set_on_creation(self):
- """Verify that the `created_at` field is automatically set upon
creation."""
- self.assertIsNotNone(
- self.device.created_at, "created_at should be automatically set."
- )
-
- def test_last_used_at_initially_none(self):
- """Ensure `last_used_at` is None upon initial creation."""
- self.assertIsNone(
- self.device.last_used_at, "last_used_at should be None initially."
- )
-
- def test_set_last_used_timestamp_updates_field(self):
- """Check if `set_last_used_timestamp` correctly updates the
`last_used_at` field."""
- self.device.set_last_used_timestamp(commit=True)
- self.device.refresh_from_db() # Assuming it's a persisted model
-
- self.assertIsNotNone(
- self.device.last_used_at, "last_used_at should be updated."
- )
-
- def test_set_last_used_timestamp_without_commit(self):
- """
- Ensure `set_last_used_timestamp` updates `last_used_at` without
persisting
- when commit=False.
- """
- original_last_used_at = self.device.last_used_at
- self.device.set_last_used_timestamp(commit=False)
- # Check in-memory update without saving
- self.assertNotEqual(
- self.device.last_used_at,
- original_last_used_at,
- "last_used_at should be updated in memory without commit.",
- )
-
- # Refresh from db to confirm it wasn't committed
- self.device.refresh_from_db()
- self.assertEqual(
- self.device.last_used_at,
- original_last_used_at,
- "last_used_at should not be updated in db without commit.",
- )
-
- def test_verify_token_successful_updates_last_used_at(self):
- """
- Verifying with a valid token updates 'last_used_at'.
- """
- valid_token = self.valid_token() # Method to generate a valid token
- initial_last_used_at = self.device.last_used_at
- verified = self.device.verify_token(valid_token)
-
- self.assertTrue(verified, "Token should be verified successfully.")
- self.device.refresh_from_db()
- self.assertNotEqual(
- self.device.last_used_at,
- initial_last_used_at,
- "'last_used_at' should be updated on successful verification.",
- )
-
- def test_verify_token_failed_does_not_update_last_used_at(self):
- """
- Verifying with an invalid token does not update 'last_used_at'.
- """
- invalid_token = self.invalid_token() # Method to generate an invalid
token
- initial_last_used_at = self.device.last_used_at
- verified = self.device.verify_token(invalid_token)
-
- self.assertFalse(verified, "Token should not be verified.")
- self.device.refresh_from_db()
- self.assertEqual(
- self.device.last_used_at,
- initial_last_used_at,
- "'last_used_at' should not be updated on failed verification.",
- )
-
-
-class ThrottlingTestMixin:
- """
- Generic tests for throttled devices.
-
- Any concrete device implementation that uses throttling should define a
- TestCase subclass that includes this as a base class. This will help verify
- a correct integration of ThrottlingMixin.
-
- Subclasses are responsible for populating self.device with a device to test
- as well as implementing methods to generate tokens to test with.
-
- """
-
- def setUp(self):
- self.device = None
-
- def valid_token(self):
- """Returns a valid token to pass to our device under test."""
- raise NotImplementedError()
-
- def invalid_token(self):
- """Returns an invalid token to pass to our device under test."""
- raise NotImplementedError()
-
- #
- # Tests
- #
-
- def test_delay_imposed_after_fail(self):
- verified1 = self.device.verify_token(self.invalid_token())
- self.assertFalse(verified1)
- verified2 = self.device.verify_token(self.valid_token())
- self.assertFalse(verified2)
-
- def test_delay_after_fail_expires(self):
- verified1 = self.device.verify_token(self.invalid_token())
- self.assertFalse(verified1)
- with freeze_time() as frozen_time:
- # With default settings initial delay is 1 second
- frozen_time.tick(delta=timedelta(seconds=1.1))
- verified2 = self.device.verify_token(self.valid_token())
- self.assertTrue(verified2)
-
- def test_throttling_failure_count(self):
- self.assertEqual(self.device.throttling_failure_count, 0)
- for i in range(0, 5):
- self.device.verify_token(self.invalid_token())
- # Only the first attempt will increase throttling_failure_count,
- # the others will all be within 1 second of first
- # and therefore not count as attempts.
- self.assertEqual(self.device.throttling_failure_count, 1)
-
- def test_verify_is_allowed(self):
- # Initially should be allowed
- verify_is_allowed1, data1 = self.device.verify_is_allowed()
- self.assertEqual(verify_is_allowed1, True)
- self.assertEqual(data1, None)
-
- # After failure, verify is not allowed
- with freeze_time():
- self.device.verify_token(self.invalid_token())
- verify_is_allowed2, data2 = self.device.verify_is_allowed()
- self.assertEqual(verify_is_allowed2, False)
- self.assertEqual(
- data2,
- {
- 'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS,
- 'failure_count': 1,
- 'locked_until': timezone.now() +
timezone.timedelta(seconds=1),
- },
- )
-
- # After a successful attempt, should be allowed again
- with freeze_time() as frozen_time:
- frozen_time.tick(delta=timedelta(seconds=1.1))
- self.device.verify_token(self.valid_token())
-
- verify_is_allowed3, data3 = self.device.verify_is_allowed()
- self.assertEqual(verify_is_allowed3, True)
- self.assertEqual(data3, None)
-
-
-class CooldownTestMixin:
- def setUp(self):
- self.device = None
-
- def valid_token(self):
- """Returns a valid token to pass to our device under test."""
- raise NotImplementedError()
-
- def invalid_token(self):
- """Returns an invalid token to pass to our device under test."""
- raise NotImplementedError()
-
- #
- # Tests
- #
-
- def test_generate_is_allowed_on_first_try(self):
- """Token generation should be allowed on first try."""
- allowed, _ = self.device.generate_is_allowed()
- self.assertTrue(allowed)
-
- def test_cooldown_imposed_after_successful_generation(self):
- """
- Token generation before cooldown should not be allowed
- and the relevant reason should be returned.
- """
- with freeze_time():
- self.device.generate_challenge()
- self.device.refresh_from_db()
- allowed, details = self.device.generate_is_allowed()
-
- self.assertFalse(allowed)
- self.assertEqual(
- details['reason'], GenerateNotAllowed.COOLDOWN_DURATION_PENDING
- )
-
- def test_cooldown_expire_time(self):
- """
- When token generation is not allowed, the cooldown expire time
- should be returned.
- """
- with freeze_time():
- self.device.generate_challenge()
- self.device.refresh_from_db()
- _, details = self.device.generate_is_allowed()
- self.assertEqual(
- details['next_generation_at'], timezone.now() +
timedelta(seconds=10)
- )
-
- def test_cooldown_reset(self):
- """Cooldown can be reset and allow token generation again before the
initial period expires."""
- with freeze_time():
- self.device.generate_is_allowed()
- self.device.refresh_from_db()
- self.device.cooldown_reset()
- self.device.refresh_from_db()
- allowed, _ = self.device.generate_is_allowed()
- self.assertTrue(allowed)
-
- def test_valid_token_verification_resets_cooldown(self):
- """When the token is verified, the cooldown period is reset."""
- with freeze_time():
- self.device.generate_challenge()
- self.device.refresh_from_db()
- verified = self.device.verify_token(self.valid_token())
- self.assertTrue(verified)
- self.device.refresh_from_db()
- allowed, _ = self.device.generate_is_allowed()
- self.assertTrue(allowed)
-
- def test_invalid_token_verification_does_not_reset_cooldown(self):
- """When the token is not verified, the cooldown period is not reset."""
- with freeze_time():
- self.device.generate_challenge()
- self.device.refresh_from_db()
- verified = self.device.verify_token(self.invalid_token())
- self.assertFalse(verified)
- self.device.refresh_from_db()
- allowed, _ = self.device.generate_is_allowed()
- self.assertFalse(allowed)
-
-
@override_settings(OTP_STATIC_THROTTLE_FACTOR=0)
class APITestCase(TestCase):
def setUp(self):
@@ -521,6 +211,104 @@
pickle.dumps(request.user)
+class OTPMiddlewareAsyncTestCase(TestCase):
+ def setUp(self):
+ self.factory = AsyncRequestFactory()
+
+ try:
+ self.alice = self.create_user("alice", "password")
+ self.bob = self.create_user("bob", "password")
+ except IntegrityError:
+ self.skipTest("Unable to create a test user.")
+ else:
+ for user in (self.alice, self.bob):
+ device = user.staticdevice_set.create()
+ device.token_set.create(token=user.get_username())
+
+ # Precompute anything that would otherwise hit the (sync) ORM inside
async tests.
+ alice_device = self.alice.staticdevice_set.get()
+ bob_device = self.bob.staticdevice_set.get()
+
+ self.alice_device_pid = alice_device.persistent_id
+ self.bob_device_pid = bob_device.persistent_id
+ self.alice_device_legacy_pid = "{}.{}/{}".format(
+ alice_device.__module__, alice_device.__class__.__name__,
alice_device.id
+ )
+
+ async def get_response(request):
+ return None
+
+ self.middleware = OTPMiddleware(get_response)
+
+ async def _run_middleware(self, user, session):
+ request = self.factory.get("/")
+ request.session = session
+ request.user = user
+
+ async def auser():
+ return user
+
+ request.auser = auser
+
+ await self.middleware(request)
+ return request
+
+ async def test_verified(self):
+ request = await self._run_middleware(
+ self.alice, {DEVICE_ID_SESSION_KEY: self.alice_device_pid}
+ )
+
+ user = await request.auser()
+ self.assertTrue(user.is_verified())
+
+ async def test_verified_legacy_device_id(self):
+ request = await self._run_middleware(
+ self.alice, {DEVICE_ID_SESSION_KEY: self.alice_device_legacy_pid}
+ )
+
+ user = await request.auser()
+ self.assertTrue(user.is_verified())
+
+ async def test_unverified(self):
+ request = await self._run_middleware(self.alice, {})
+
+ user = await request.auser()
+ self.assertFalse(user.is_verified())
+
+ async def test_no_device(self):
+ request = await self._run_middleware(
+ self.alice, {DEVICE_ID_SESSION_KEY: "otp_static.staticdevice/0"}
+ )
+
+ user = await request.auser()
+ self.assertFalse(user.is_verified())
+
+ async def test_no_model(self):
+ request = await self._run_middleware(
+ self.alice, {DEVICE_ID_SESSION_KEY: "otp_bogus.bogusdevice/0"}
+ )
+
+ user = await request.auser()
+ self.assertFalse(user.is_verified())
+
+ async def test_wrong_user(self):
+ request = await self._run_middleware(
+ self.alice, {DEVICE_ID_SESSION_KEY: self.bob_device_pid}
+ )
+
+ user = await request.auser()
+ self.assertFalse(user.is_verified())
+
+ async def test_pickling(self):
+ request = await self._run_middleware(
+ self.alice, {DEVICE_ID_SESSION_KEY: self.alice_device_pid}
+ )
+
+ user = await request.auser()
+ # Should not raise an exception.
+ pickle.dumps(user)
+
+
class LoginViewTestCase(TestCase):
def setUp(self):
try: