Skip to content
Snippets Groups Projects
Commit ed0aa5dc authored by Michael Fladischer's avatar Michael Fladischer
Browse files

New upstream version 1.5.4

parent 07128fe2
No related branches found
No related tags found
No related merge requests found
Showing
with 229 additions and 26 deletions
[bumpversion]
current_version = 1.5.0
current_version = 1.5.4
commit = true
message = Version {new_version}
tag = true
......
v1.5.4 - September 06, 2024 - Ignore proxy models when enumerating device classes
--------------------------------------------------------------------------------
- `#161`_: Discard proxied models when iterating device models
.. _#161: https://github.com/django-otp/django-otp/pull/161
v1.5.3 - September 04, 2024 - Small admin template fix
--------------------------------------------------------------------------------
- `#158`_: Remove JS focus() in admin login template
.. _#158: https://github.com/django-otp/django-otp/pull/158
v1.5.2 - August 18, 2024 - otp_verification_failed signal
--------------------------------------------------------------------------------
- `#150`_: Add signal when OTP verification fails
.. _#150: https://github.com/django-otp/django-otp/pull/150
v1.5.1 - July 23, 2024 - Admin search fields
--------------------------------------------------------------------------------
- `#147`_: Add search ability in admin using username and email
.. _#147: https://github.com/django-otp/django-otp/pull/147
v1.5.0 - April 16, 2024 - Support segno for QR codes
--------------------------------------------------------------------------------
......
......@@ -50,9 +50,10 @@ easiest way is to use :class:`django_otp.forms.OTPAuthenticationForm` instead.
This form includes additional fields and behavior to solicit an OTP token from
the user and verify it against their registered devices. This form's validation
only succeeds if it is able to both authenticate the user with the username and
password and also verify them with an OTP token. The form can be used with
:class:`django.contrib.auth.views.LoginView` simply by passing it in the
``authentication_form`` keyword parameter::
password and also verify them with an OTP token. If the verification fails, the
:data:`~django_otp.forms.otp_verification_failed` signal is emitted. The form
can be used with :class:`django.contrib.auth.views.LoginView` simply by passing
it in the ``authentication_form`` keyword parameter::
from django.contrib.auth.views import LoginView
from django_otp.forms import OTPAuthenticationForm
......@@ -117,6 +118,24 @@ token to verify, you can use :class:`django_otp.forms.OTPTokenForm`.
.. autoclass:: django_otp.forms.OTPTokenForm
Signals
~~~~~~~
.. data:: django_otp.forms.otp_verification_failed
This signal is sent when an OTP verification attempt fails. The source of
the signal is :class:`~django_otp.forms.OTPAuthenticationFormMixin`,
therefore it will generally be emitted only if using the provided
:class:`~django_otp.forms.OTPAuthenticationForm` or
:class:`~django_otp.forms.OTPTokenForm`. The signal provides the following
arguments:
``sender``
The class of the form that attempted the verification.
``user``
The user that attempted the verification.
Custom Forms
~~~~~~~~~~~~
......
......@@ -89,7 +89,7 @@ project = 'django-otp'
# built documents.
#
# The full version, including alpha/beta/rc tags.
release = '1.5.0'
release = '1.5.4'
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
......
[project]
name = "django-otp"
version = "1.5.0"
version = "1.5.4"
description = "A pluggable framework for adding two-factor authentication to Django using one-time passwords."
readme = "README.rst"
requires-python = ">=3.7"
......@@ -45,12 +45,12 @@ features = [
"qrcode",
]
dependencies = [
"black ~= 23.0",
"black ~= 24.8.0",
"bumpversion ~= 0.6.0",
"coverage ~= 7.3",
"flake8 ~= 6.1",
"freezegun ~= 1.2.0",
"isort ~= 5.10",
"coverage ~= 7.6.1",
"flake8 ~= 7.1.1",
"freezegun ~= 1.5.1",
"isort ~= 5.13.1",
"psycopg2",
"tomli >= 1.1.0; python_version < '3.11'",
]
......@@ -64,12 +64,12 @@ manage = "python -m django {args}"
lint = [
"flake8 src",
"isort --check --quiet src",
"black --check --quiet src",
"isort --check src",
"black --check src",
]
fix = [
"isort --quiet src",
"black --quiet src",
"isort src",
"black src",
]
test = "python -s -m django test {args:django_otp}"
......@@ -87,9 +87,9 @@ run = "test"
[tool.hatch.envs.test.overrides]
matrix.django.dependencies = [
{ value = "django ~= 3.2.0", if = ["3.2"] },
{ value = "django ~= 4.1.0", if = ["4.1"] },
{ value = "django ~= 4.2.0", if = ["4.2"] },
{ value = "django ~= 5.0.0", if = ["5.0"] },
{ value = "django ~= 5.1.0", if = ["5.1"] },
]
matrix.mode.scripts = [
{ key = "run", value = "lint", if = ["lint"] },
......@@ -103,15 +103,15 @@ mode = ["lint"]
# .github/workflows/* as well.
[[tool.hatch.envs.test.matrix]]
python = ["3.8"]
django = ["3.2"]
django = ["4.2"]
[[tool.hatch.envs.test.matrix]]
python = ["3.10"]
django = ["4.1"]
django = ["5.0"]
[[tool.hatch.envs.test.matrix]]
python = ["3.12"]
django = ["4.2"]
django = ["5.1"]
[[tool.hatch.envs.test.matrix]]
mode = ["coverage"]
......
......@@ -166,5 +166,5 @@ def device_classes():
for config in apps.get_app_configs():
for model in config.get_models():
if issubclass(model, Device):
if issubclass(model, Device) and not model._meta.proxy:
yield model
from django import forms
from django.contrib.admin.forms import AdminAuthenticationForm
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.core.exceptions import FieldDoesNotExist
from .forms import OTPAuthenticationFormMixin
......@@ -16,6 +18,45 @@ def _admin_template_for_django_version():
return 'otp/admin111/login.html'
def user_model_search_fields(field_names):
"""
Check whether the provided field names exist, and return a tuple of
search fields and help text for the validated field names.
Parameters:
field_names (list of str): A list of field names to search for in the user model.
Returns:
tuple: A tuple containing:
- search_fields (list of str): A list of search fields formatted for querying.
- search_help_text (str): A help text string describing the valid search fields.
"""
User = get_user_model()
search_fields = []
help_texts = []
for name in field_names:
try:
field = User._meta.get_field(name)
except FieldDoesNotExist:
continue
else:
search_fields.append(f'user__{field.name}')
help_texts.append(str(field.verbose_name))
if len(help_texts) == 0:
search_help_text = ""
else:
search_help_text = "Search by user's "
if len(help_texts) == 1:
search_help_text += help_texts[0]
else:
search_help_text += ", ".join(help_texts[:-1]) + f" or {help_texts[-1]}"
return search_fields, search_help_text
class OTPAdminAuthenticationForm(AdminAuthenticationForm, OTPAuthenticationFormMixin):
"""
An :class:`~django.contrib.admin.forms.AdminAuthenticationForm` subclass
......
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.db import transaction
from django.dispatch import Signal
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from . import devices_for_user, match_token
from .models import Device, VerifyNotAllowed
otp_verification_failed = Signal()
class OTPAuthenticationFormMixin:
"""
......@@ -176,6 +179,10 @@ class OTPAuthenticationFormMixin:
device = match_token(user, token)
if device is None:
otp_verification_failed.send(
sender=self.__class__,
user=user,
)
raise forms.ValidationError(
self.otp_error_messages['invalid_token'], code='invalid_token'
)
......
from django.contrib import admin
from django.contrib.admin.sites import AlreadyRegistered
from django.contrib.auth import get_user_model
from django_otp.admin import user_model_search_fields
from .models import EmailDevice
def _search_fields():
User = get_user_model()
candidate_search_field = [User.USERNAME_FIELD, 'email']
search_fields, search_help_text = user_model_search_fields(candidate_search_field)
search_fields += ['email']
return search_fields, search_help_text
class EmailDeviceAdmin(admin.ModelAdmin):
"""
:class:`~django.contrib.admin.ModelAdmin` for
......@@ -15,6 +28,7 @@ class EmailDeviceAdmin(admin.ModelAdmin):
raw_id_fields = ['user']
readonly_fields = ['created_at', 'last_used_at']
search_fields, search_help_text = _search_fields()
fieldsets = [
(
......
from django.contrib import admin
from django.contrib.admin.sites import AlreadyRegistered
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from django_otp.admin import user_model_search_fields
from django_otp.conf import settings
from django_otp.qr import write_qrcode_image
......@@ -18,8 +20,12 @@ class HOTPDeviceAdmin(admin.ModelAdmin):
:class:`~django_otp.plugins.otp_hotp.models.HOTPDevice`.
"""
User = get_user_model()
candidate_search_field = [User.USERNAME_FIELD, 'email']
list_display = ['user', 'name', 'created_at', 'last_used_at', 'confirmed']
list_filter = ['created_at', 'last_used_at', 'confirmed']
search_fields, search_help_text = user_model_search_fields(candidate_search_field)
raw_id_fields = ['user']
readonly_fields = ['created_at', 'last_used_at', 'qrcode_link']
......
from django.contrib import admin
from django.contrib.admin.sites import AlreadyRegistered
from django.contrib.auth import get_user_model
from django_otp.admin import user_model_search_fields
from django_otp.conf import settings
from .models import StaticDevice, StaticToken
......@@ -17,8 +19,12 @@ class StaticDeviceAdmin(admin.ModelAdmin):
:class:`~django_otp.plugins.otp_static.models.StaticDevice`.
"""
User = get_user_model()
candidate_search_field = [User.USERNAME_FIELD, 'email']
list_display = ['user', 'name', 'created_at', 'last_used_at', 'confirmed']
list_filter = ['created_at', 'last_used_at', 'confirmed']
search_fields, search_help_text = user_model_search_fields(candidate_search_field)
raw_id_fields = ['user']
readonly_fields = ['created_at', 'last_used_at']
......
from django.contrib import admin
from django.contrib.admin.sites import AlreadyRegistered
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from django_otp.admin import user_model_search_fields
from django_otp.conf import settings
from django_otp.qr import write_qrcode_image
......@@ -18,8 +20,12 @@ class TOTPDeviceAdmin(admin.ModelAdmin):
:class:`~django_otp.plugins.otp_totp.models.TOTPDevice`.
"""
User = get_user_model()
candidate_search_field = [User.USERNAME_FIELD, 'email']
list_display = ['user', 'name', 'created_at', 'last_used_at', 'confirmed']
list_filter = ['created_at', 'last_used_at', 'confirmed']
search_fields, search_help_text = user_model_search_fields(candidate_search_field)
raw_id_fields = ['user']
readonly_fields = ['created_at', 'last_used_at', 'qrcode_link']
......
......@@ -91,9 +91,5 @@
{% endif %}
</div>
</form>
<script type="text/javascript">
document.getElementById('id_username').focus()
</script>
</div>
{% endblock %}
......@@ -22,13 +22,14 @@ from django.utils import timezone
from django_otp import (
DEVICE_ID_SESSION_KEY,
device_classes,
match_token,
oath,
user_has_device,
util,
verify_token,
)
from django_otp.forms import OTPTokenForm
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
......@@ -391,6 +392,42 @@ class APITestCase(TestCase):
verified = match_token(self.alice, 'alice')
self.assertEqual(verified, self.alice.staticdevice_set.first())
def test_device_classes(self):
classes = list(device_classes())
self.assertFalse(any(model._meta.proxy for model in classes))
class OTPVerificationFailedSignalTestCase(TestCase):
def setUp(self):
try:
self.alice = self.create_user('alice', 'password')
except IntegrityError:
self.skipTest("Unable to create a test user.")
else:
self.device = self.alice.staticdevice_set.create()
self.device.token_set.create(token='valid')
self.signal_received = False
otp_verification_failed.connect(self.signal_handler)
def tearDown(self):
otp_verification_failed.disconnect(self.signal_handler)
def signal_handler(self, sender, **kwargs):
self.signal_received = True
def test_otp_verification_failed_signal(self):
form = OTPTokenForm(
self.alice,
None,
{'otp_device': self.device.persistent_id, 'otp_token': 'invalid'},
)
form.is_valid()
self.assertTrue(
self.signal_received, "otp_verification_failed signal was not emitted."
)
class OTPMiddlewareTestCase(TestCase):
def setUp(self):
......
......@@ -37,6 +37,8 @@ INSTALLED_APPS = [
'django_otp.plugins.otp_hotp',
'django_otp.plugins.otp_static',
'django_otp.plugins.otp_totp',
'test_project.test_app',
]
INSTALLED_APPS.extend(cfg.get('plugins', []))
......
from django.apps import AppConfig
class TestAppConfig(AppConfig):
name = 'test_project.test_app'
default_auto_field = 'django.db.models.AutoField'
# Generated by Django 5.1.1 on 2024-09-05 22:20
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = [
('otp_totp', '0003_add_timestamps'),
]
operations = [
migrations.CreateModel(
name='TOTPDeviceProxy',
fields=[],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('otp_totp.totpdevice',),
),
]
from django_otp.plugins.otp_totp.models import TOTPDevice
class TOTPDeviceProxy(TOTPDevice):
class Meta:
proxy = True
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment