From d33c3993c0c735f23cbedc60fa59fce69354f19d Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 3 May 2024 22:32:45 +0200 Subject: [PATCH 01/12] rollback 8f65bfff16577c7fb0f52bbabf5fb69f6809ba62, add support for ModelBackend.user_can_authenticate --- djoser/serializers.py | 10 +- .../testapp/tests/test_token_create.py | 234 ++++++++++++++++-- 2 files changed, 220 insertions(+), 24 deletions(-) diff --git a/djoser/serializers.py b/djoser/serializers.py index 805bf01c..129c9f70 100644 --- a/djoser/serializers.py +++ b/djoser/serializers.py @@ -118,17 +118,13 @@ def __init__(self, *args, **kwargs): def validate(self, attrs): password = attrs.get("password") - params = {settings.LOGIN_FIELD: attrs.get(settings.LOGIN_FIELD)} + params = {"username": attrs.get(settings.LOGIN_FIELD)} self.user = authenticate( request=self.context.get("request"), **params, password=password ) if not self.user: - self.user = User.objects.filter(**params).first() - if self.user and not self.user.check_password(password): - self.fail("invalid_credentials") - if self.user and self.user.is_active: - return attrs - self.fail("invalid_credentials") + self.fail("invalid_credentials") + return attrs class UserFunctionsMixin: diff --git a/testproject/testapp/tests/test_token_create.py b/testproject/testapp/tests/test_token_create.py index ad1f94ea..16498a7c 100644 --- a/testproject/testapp/tests/test_token_create.py +++ b/testproject/testapp/tests/test_token_create.py @@ -1,6 +1,9 @@ import django +from unittest import mock + from django.conf import settings as django_settings from django.contrib.auth import user_logged_in, user_login_failed +from django.contrib.auth.backends import ModelBackend from django.test import override_settings from djet import assertions from rest_framework import status @@ -37,9 +40,221 @@ def test_post_should_login_user(self): self.assertNotEqual(user.last_login, previous_last_login) self.assertTrue(self.signal_sent) + @override_settings( + AUTHENTICATION_BACKENDS=[ + "django.contrib.auth.backends.ModelBackend", + ] + ) + @override_settings( + DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}) + ) + def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_username__USERNAME_FIELD_username( # noqa: E501 + self, + ): + user = create_user() + user_logged_in.connect(self.signal_receiver) + previous_last_login = user.last_login + + with mock.patch("djoser.serializers.User.USERNAME_FIELD", "username"): + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_200_OK) + + user.refresh_from_db() + self.assertEqual(response.data["auth_token"], user.auth_token.key) + self.assertNotEqual(user.last_login, previous_last_login) + self.assertTrue(self.signal_sent) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + @override_settings( + AUTHENTICATION_BACKENDS=[ + "django.contrib.auth.backends.ModelBackend", + ] + ) + @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) + def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_email__USERNAME_FIELD_username( # noqa: E501 + self, + ): + user = create_user() + user_logged_in.connect(self.signal_receiver) + previous_last_login = user.last_login + + with mock.patch("djoser.serializers.User.USERNAME_FIELD", "username"): + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + user.refresh_from_db() + self.assertEqual(user.last_login, previous_last_login) + self.assertFalse(self.signal_sent) + + @override_settings( + AUTHENTICATION_BACKENDS=[ + "django.contrib.auth.backends.ModelBackend", + ] + ) + @override_settings( + DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}) + ) + def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_username__USERNAME_FIELD_email( # noqa: E501 + self, + ): + user = create_user() + user_logged_in.connect(self.signal_receiver) + previous_last_login = user.last_login + + with mock.patch("djoser.serializers.User.USERNAME_FIELD", "email"): + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + user.refresh_from_db() + self.assertEqual(user.last_login, previous_last_login) + self.assertFalse(self.signal_sent) + + @override_settings( + AUTHENTICATION_BACKENDS=[ + "django.contrib.auth.backends.ModelBackend", + ] + ) + @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) + def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_email__USERNAME_FIELD_email( # noqa: E501 + self, + ): + user = create_user() + user_logged_in.connect(self.signal_receiver) + previous_last_login = user.last_login + + with mock.patch("djoser.serializers.User.USERNAME_FIELD", "email"): + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, + {"username": user.username, "password": user.raw_password}, + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=True + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_200_OK) + + user.refresh_from_db() + self.assertEqual(response.data["auth_token"], user.auth_token.key) + self.assertNotEqual(user.last_login, previous_last_login) + self.assertTrue(self.signal_sent) + + with mock.patch.object( + ModelBackend, "user_can_authenticate", return_value=False + ): + response = self.client.post( + self.base_url, {"email": user.email, "password": user.raw_password} + ) + self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) + def test_post_should_not_login_if_user_is_not_active(self): - """In Django >= 1.10 authenticate() returns None if user is inactive, - while in Django < 1.10 authenticate() succeeds if user is inactive.""" user = create_user() data = {"username": user.username, "password": user.raw_password} user.is_active = False @@ -81,18 +296,3 @@ def test_post_should_not_login_if_empty_request(self): response.data["non_field_errors"], [settings.CONSTANTS.messages.INVALID_CREDENTIALS_ERROR], ) - - @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) - def test_login_using_email(self): - user = create_user() - previous_last_login = user.last_login - data = {"email": user.email, "password": user.raw_password} - user_logged_in.connect(self.signal_receiver) - - response = self.client.post(self.base_url, data) - user.refresh_from_db() - - self.assert_status_equal(response, status.HTTP_200_OK) - self.assertEqual(response.data["auth_token"], user.auth_token.key) - self.assertNotEqual(user.last_login, previous_last_login) - self.assertTrue(self.signal_sent) From 893e0ddd743d62f539ef6bf2970a522a45ecbfa6 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 3 May 2024 22:39:03 +0200 Subject: [PATCH 02/12] improve test names --- testproject/testapp/tests/test_token_create.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testproject/testapp/tests/test_token_create.py b/testproject/testapp/tests/test_token_create.py index 16498a7c..7f6afe61 100644 --- a/testproject/testapp/tests/test_token_create.py +++ b/testproject/testapp/tests/test_token_create.py @@ -48,7 +48,7 @@ def test_post_should_login_user(self): @override_settings( DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}) ) - def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_username__USERNAME_FIELD_username( # noqa: E501 + def test_login__LOGIN_FIELD_username__USERNAME_FIELD_username( self, ): user = create_user() @@ -101,7 +101,7 @@ def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIE ] ) @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) - def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_email__USERNAME_FIELD_username( # noqa: E501 + def test_login__LOGIN_FIELD_email__USERNAME_FIELD_username( self, ): user = create_user() @@ -155,7 +155,7 @@ def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIE @override_settings( DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}) ) - def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_username__USERNAME_FIELD_email( # noqa: E501 + def test_login__LOGIN_FIELD_username__USERNAME_FIELD_email( self, ): user = create_user() @@ -207,7 +207,7 @@ def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIE ] ) @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) - def test_post_should_not_login_if_model_backend_user_can_authenticate__LOGIN_FIELD_email__USERNAME_FIELD_email( # noqa: E501 + def test_login__LOGIN_FIELD_email__USERNAME_FIELD_email( self, ): user = create_user() From e92863fe0c49c781131973d2fee38d6c9433ab15 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 12:34:38 +0200 Subject: [PATCH 03/12] merge decorators in tests for better readability --- .../testapp/tests/test_token_create.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/testproject/testapp/tests/test_token_create.py b/testproject/testapp/tests/test_token_create.py index 4291e142..028decae 100644 --- a/testproject/testapp/tests/test_token_create.py +++ b/testproject/testapp/tests/test_token_create.py @@ -42,10 +42,8 @@ def test_post_should_login_user(self): @override_settings( AUTHENTICATION_BACKENDS=[ "django.contrib.auth.backends.ModelBackend", - ] - ) - @override_settings( - DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}) + ], + DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}), ) def test_login__LOGIN_FIELD_username__USERNAME_FIELD_username( self, @@ -97,9 +95,9 @@ def test_login__LOGIN_FIELD_username__USERNAME_FIELD_username( @override_settings( AUTHENTICATION_BACKENDS=[ "django.contrib.auth.backends.ModelBackend", - ] + ], + DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"}), ) - @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) def test_login__LOGIN_FIELD_email__USERNAME_FIELD_username( self, ): @@ -149,10 +147,8 @@ def test_login__LOGIN_FIELD_email__USERNAME_FIELD_username( @override_settings( AUTHENTICATION_BACKENDS=[ "django.contrib.auth.backends.ModelBackend", - ] - ) - @override_settings( - DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}) + ], + DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}), ) def test_login__LOGIN_FIELD_username__USERNAME_FIELD_email( self, @@ -203,9 +199,9 @@ def test_login__LOGIN_FIELD_username__USERNAME_FIELD_email( @override_settings( AUTHENTICATION_BACKENDS=[ "django.contrib.auth.backends.ModelBackend", - ] + ], + DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"}), ) - @override_settings(DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"})) def test_login__LOGIN_FIELD_email__USERNAME_FIELD_email( self, ): From eda2cd9c848996d16843ea84a2f0e15694d1ebe3 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 13:29:27 +0200 Subject: [PATCH 04/12] add pytest-mock to test deps --- poetry.lock | 131 ++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 2 + 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index da0cd5f1..54ddc2c9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -15,6 +16,7 @@ files = [ name = "asgiref" version = "3.7.2" description = "ASGI specs, helper code, and adapters" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -32,6 +34,7 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] name = "babel" version = "2.13.1" description = "Internationalization utilities" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -50,6 +53,7 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] name = "backports-zoneinfo" version = "0.2.1" description = "Backport of the standard library zoneinfo module" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -78,6 +82,7 @@ tzdata = ["tzdata"] name = "black" version = "23.10.1" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -120,6 +125,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "cachetools" version = "5.3.2" description = "Extensible memoizing collections and decorators" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -131,6 +137,7 @@ files = [ name = "cbor2" version = "5.5.1" description = "CBOR (de)serializer with extensive tag support" +category = "main" optional = true python-versions = ">=3.8" files = [ @@ -182,6 +189,7 @@ test = ["coverage (>=7)", "hypothesis", "pytest"] name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -193,6 +201,7 @@ files = [ name = "cffi" version = "1.16.0" description = "Foreign Function Interface for Python calling C code." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -257,6 +266,7 @@ pycparser = "*" name = "cfgv" version = "3.4.0" description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -268,6 +278,7 @@ files = [ name = "chardet" version = "5.2.0" description = "Universal encoding detector for Python 3" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -279,6 +290,7 @@ files = [ name = "charset-normalizer" version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -378,6 +390,7 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -392,6 +405,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -403,6 +417,7 @@ files = [ name = "coverage" version = "7.3.2" description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -470,6 +485,7 @@ toml = ["tomli"] name = "cryptography" version = "41.0.6" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -515,6 +531,7 @@ test-randomorder = ["pytest-randomly"] name = "defusedxml" version = "0.7.1" description = "XML bomb protection for Python stdlib modules" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -526,6 +543,7 @@ files = [ name = "distlib" version = "0.3.7" description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" files = [ @@ -537,6 +555,7 @@ files = [ name = "django" version = "4.2.7" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -558,6 +577,7 @@ bcrypt = ["bcrypt"] name = "django-templated-mail" version = "1.1.1" description = "Send emails using Django template system." +category = "main" optional = false python-versions = "*" files = [ @@ -569,6 +589,7 @@ files = [ name = "djangorestframework" version = "3.14.0" description = "Web APIs for Django, made easy." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -584,6 +605,7 @@ pytz = "*" name = "djangorestframework-simplejwt" version = "5.3.0" description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -608,6 +630,7 @@ test = ["cryptography", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", name = "djet" version = "0.3.0" description = "Set of helpers for easy testing of Django apps." +category = "main" optional = true python-versions = "*" files = [ @@ -619,6 +642,7 @@ files = [ name = "docformatter" version = "1.7.5" description = "Formats docstrings to follow PEP 257" +category = "dev" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -637,6 +661,7 @@ tomli = ["tomli (>=2.0.0,<3.0.0)"] name = "docutils" version = "0.18.1" description = "Docutils -- Python Documentation Utilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -648,6 +673,7 @@ files = [ name = "exceptiongroup" version = "1.1.3" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -662,6 +688,7 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.13.1" description = "A platform independent file lock." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -678,6 +705,7 @@ typing = ["typing-extensions (>=4.8)"] name = "future" version = "0.18.3" description = "Clean single-source support for Python 3 and 2" +category = "main" optional = true python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -688,6 +716,7 @@ files = [ name = "identify" version = "2.5.31" description = "File identification library for Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -702,6 +731,7 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -713,6 +743,7 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -724,6 +755,7 @@ files = [ name = "importlib-metadata" version = "6.8.0" description = "Read metadata from Python packages" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -743,6 +775,7 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -754,6 +787,7 @@ files = [ name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -771,6 +805,7 @@ i18n = ["Babel (>=2.7)"] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -794,16 +829,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -840,6 +865,7 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -851,6 +877,7 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -865,6 +892,7 @@ setuptools = "*" name = "oauthlib" version = "3.2.2" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -881,6 +909,7 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] name = "packaging" version = "23.2" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -892,6 +921,7 @@ files = [ name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -903,6 +933,7 @@ files = [ name = "platformdirs" version = "3.11.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -918,6 +949,7 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co name = "pluggy" version = "1.3.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -933,6 +965,7 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -951,6 +984,7 @@ virtualenv = ">=20.10.0" name = "pre-commit-hooks" version = "4.5.0" description = "Some out-of-the-box hooks for pre-commit." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -966,6 +1000,7 @@ tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} name = "pycparser" version = "2.21" description = "C parser in Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -977,6 +1012,7 @@ files = [ name = "pygments" version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -991,6 +1027,7 @@ plugins = ["importlib-metadata"] name = "pyjwt" version = "2.8.0" description = "JSON Web Token implementation in Python" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1008,6 +1045,7 @@ tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] name = "pyopenssl" version = "23.3.0" description = "Python wrapper module around the OpenSSL library" +category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1026,6 +1064,7 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"] name = "pyproject-api" version = "1.6.1" description = "API to interact with the python pyproject.toml based projects" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1045,6 +1084,7 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes name = "pytest" version = "7.4.3" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1067,6 +1107,7 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1085,6 +1126,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-django" version = "4.6.0" description = "A Django plugin for pytest." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1099,10 +1141,29 @@ pytest = ">=7.0.0" docs = ["sphinx", "sphinx-rtd-theme"] testing = ["Django", "django-configurations (>=2.0)"] +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python3-openid" version = "3.2.0" description = "OpenID support for modern servers and consumers." +category = "main" optional = false python-versions = "*" files = [ @@ -1121,6 +1182,7 @@ postgresql = ["psycopg2"] name = "pytz" version = "2023.3.post1" description = "World timezone definitions, modern and historical" +category = "main" optional = false python-versions = "*" files = [ @@ -1132,6 +1194,7 @@ files = [ name = "pyupgrade" version = "3.8.0" description = "A tool to automatically upgrade syntax for newer versions." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1146,6 +1209,7 @@ tokenize-rt = ">=3.2.0" name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1205,6 +1269,7 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1226,6 +1291,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "requests-oauthlib" version = "1.3.1" description = "OAuthlib authentication support for Requests." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1244,6 +1310,7 @@ rsa = ["oauthlib[signedtoken] (>=3.0.0)"] name = "ruamel-yaml" version = "0.18.5" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1262,29 +1329,30 @@ jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] name = "ruamel-yaml-clib" version = "0.2.8" description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "dev" optional = false python-versions = ">=3.6" files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:d92f81886165cb14d7b067ef37e142256f1c6a90a65cd156b063a43da1708cfd"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:b5edda50e5e9e15e54a6a8a0070302b00c518a9d32accc2346ad6c984aacd279"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:7048c338b6c86627afb27faecf418768acb6331fc24cfa56c93e8c9780f815fa"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, @@ -1292,7 +1360,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3fcc54cb0c8b811ff66082de1680b4b14cf8a81dce0d4fbf665c2265a81e07a1"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, @@ -1300,7 +1368,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:665f58bfd29b167039f714c6998178d27ccd83984084c286110ef26b230f259f"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, @@ -1308,7 +1376,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9eb5dee2772b0f704ca2e45b1713e4e5198c18f515b52743576d196348f374d3"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, @@ -1321,6 +1389,7 @@ files = [ name = "ruff" version = "0.0.241" description = "An extremely fast Python linter, written in Rust." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1346,6 +1415,7 @@ files = [ name = "setuptools" version = "68.2.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1362,6 +1432,7 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1373,6 +1444,7 @@ files = [ name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" optional = false python-versions = "*" files = [ @@ -1384,6 +1456,7 @@ files = [ name = "social-auth-app-django" version = "5.4.0" description = "Python Social Authentication, Django integration." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1399,6 +1472,7 @@ social-auth-core = ">=4.4.1" name = "social-auth-core" version = "4.5.0" description = "Python social authentication made simple." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1425,6 +1499,7 @@ saml = ["python3-saml (>=1.5.0)"] name = "sphinx" version = "6.2.1" description = "Python documentation generator" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1460,6 +1535,7 @@ test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] name = "sphinx-rtd-theme" version = "1.3.0" description = "Read the Docs theme for Sphinx" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -1479,6 +1555,7 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] name = "sphinxcontrib-applehelp" version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1494,6 +1571,7 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1509,6 +1587,7 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1524,6 +1603,7 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jquery" version = "4.1" description = "Extension to include jQuery on newer Sphinx releases" +category = "dev" optional = false python-versions = ">=2.7" files = [ @@ -1538,6 +1618,7 @@ Sphinx = ">=1.8" name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1552,6 +1633,7 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1567,6 +1649,7 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1582,6 +1665,7 @@ test = ["pytest"] name = "sqlparse" version = "0.4.4" description = "A non-validating SQL parser." +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -1598,6 +1682,7 @@ test = ["pytest", "pytest-cov"] name = "tokenize-rt" version = "5.2.0" description = "A wrapper around the stdlib `tokenize` which roundtrips." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1609,6 +1694,7 @@ files = [ name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1620,6 +1706,7 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1631,6 +1718,7 @@ files = [ name = "tox" version = "4.11.3" description = "tox is a generic virtualenv management and test command line tool" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1658,6 +1746,7 @@ testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pol name = "typing-extensions" version = "4.8.0" description = "Backported and Experimental Type Hints for Python 3.8+" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1669,6 +1758,7 @@ files = [ name = "tzdata" version = "2023.3" description = "Provider of IANA time zone data" +category = "main" optional = false python-versions = ">=2" files = [ @@ -1680,6 +1770,7 @@ files = [ name = "untokenize" version = "0.1.1" description = "Transforms tokens into original source code (while preserving whitespace)." +category = "dev" optional = false python-versions = "*" files = [ @@ -1690,6 +1781,7 @@ files = [ name = "urllib3" version = "2.0.7" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1707,6 +1799,7 @@ zstd = ["zstandard (>=0.18.0)"] name = "virtualenv" version = "20.24.6" description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1727,6 +1820,7 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "webauthn" version = "0.4.7" description = "A WebAuthn Python module." +category = "main" optional = true python-versions = "*" files = [ @@ -1745,6 +1839,7 @@ six = ">=1.11.0" name = "zipp" version = "3.17.0" description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1763,4 +1858,4 @@ webauthn = ["webauthn"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "4b8e29ce8141ab945560299f666ba070b6dd7c9ce3f0062bbcfaf0ae4a58fee7" +content-hash = "21be4e9669daf5a932d57e6c6e8726a5e387a3f93fe3cd0585a3ab764488caac" diff --git a/pyproject.toml b/pyproject.toml index d0c50be4..809fc2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ django = ">=3.0.0" djet = ["djet"] webauthn = ["webauthn"] +# poetry add --group test [tool.poetry.group.test.dependencies] pytest = "^7.2.2" coverage = "^7.2.2" @@ -56,6 +57,7 @@ pytest-cov = "^4.0.0" pytest-django = "^4.5.2" tox = "^4.4.8" babel = "^2.12.1" +pytest-mock = "^3.14.0" [tool.poetry.group.code-quality.dependencies] black = "^23.1.0" From 7bed216be97ae41642960841e656fed8cb148f29 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 13:30:06 +0200 Subject: [PATCH 05/12] move tests from webauthn to test_webauthn to fix import collision (pycharm debugger) --- testproject/testapp/tests/{webauthn => test_webauthn}/__init__.py | 0 .../testapp/tests/{webauthn => test_webauthn}/test_login.py | 0 .../tests/{webauthn => test_webauthn}/test_login_request.py | 0 .../testapp/tests/{webauthn => test_webauthn}/test_signup.py | 0 .../tests/{webauthn => test_webauthn}/test_signup_request.py | 0 testproject/testapp/tests/{webauthn => test_webauthn}/utils.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename testproject/testapp/tests/{webauthn => test_webauthn}/__init__.py (100%) rename testproject/testapp/tests/{webauthn => test_webauthn}/test_login.py (100%) rename testproject/testapp/tests/{webauthn => test_webauthn}/test_login_request.py (100%) rename testproject/testapp/tests/{webauthn => test_webauthn}/test_signup.py (100%) rename testproject/testapp/tests/{webauthn => test_webauthn}/test_signup_request.py (100%) rename testproject/testapp/tests/{webauthn => test_webauthn}/utils.py (100%) diff --git a/testproject/testapp/tests/webauthn/__init__.py b/testproject/testapp/tests/test_webauthn/__init__.py similarity index 100% rename from testproject/testapp/tests/webauthn/__init__.py rename to testproject/testapp/tests/test_webauthn/__init__.py diff --git a/testproject/testapp/tests/webauthn/test_login.py b/testproject/testapp/tests/test_webauthn/test_login.py similarity index 100% rename from testproject/testapp/tests/webauthn/test_login.py rename to testproject/testapp/tests/test_webauthn/test_login.py diff --git a/testproject/testapp/tests/webauthn/test_login_request.py b/testproject/testapp/tests/test_webauthn/test_login_request.py similarity index 100% rename from testproject/testapp/tests/webauthn/test_login_request.py rename to testproject/testapp/tests/test_webauthn/test_login_request.py diff --git a/testproject/testapp/tests/webauthn/test_signup.py b/testproject/testapp/tests/test_webauthn/test_signup.py similarity index 100% rename from testproject/testapp/tests/webauthn/test_signup.py rename to testproject/testapp/tests/test_webauthn/test_signup.py diff --git a/testproject/testapp/tests/webauthn/test_signup_request.py b/testproject/testapp/tests/test_webauthn/test_signup_request.py similarity index 100% rename from testproject/testapp/tests/webauthn/test_signup_request.py rename to testproject/testapp/tests/test_webauthn/test_signup_request.py diff --git a/testproject/testapp/tests/webauthn/utils.py b/testproject/testapp/tests/test_webauthn/utils.py similarity index 100% rename from testproject/testapp/tests/webauthn/utils.py rename to testproject/testapp/tests/test_webauthn/utils.py From 95ae2a7c27474277b55f0598585ff1ad0e95dbeb Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 13:45:10 +0200 Subject: [PATCH 06/12] refactor login/username test cases into pytest TestUsernameLoginFields --- djoser/serializers.py | 3 + .../testapp/tests/test_token_create.py | 215 ------------------ ...ken_create_custom_username_login_fields.py | 136 +++++++++++ 3 files changed, 139 insertions(+), 215 deletions(-) create mode 100644 testproject/testapp/tests/test_token_create_custom_username_login_fields.py diff --git a/djoser/serializers.py b/djoser/serializers.py index ae42e77f..f9016dc3 100644 --- a/djoser/serializers.py +++ b/djoser/serializers.py @@ -117,6 +117,9 @@ def __init__(self, *args, **kwargs): def validate(self, attrs): password = attrs.get("password") + # https://github.com/sunscrapers/djoser/issues/389 + # https://github.com/sunscrapers/djoser/issues/429 + # https://github.com/sunscrapers/djoser/issues/795 params = {"username": attrs.get(settings.LOGIN_FIELD)} self.user = authenticate( request=self.context.get("request"), **params, password=password diff --git a/testproject/testapp/tests/test_token_create.py b/testproject/testapp/tests/test_token_create.py index 028decae..cc01fde5 100644 --- a/testproject/testapp/tests/test_token_create.py +++ b/testproject/testapp/tests/test_token_create.py @@ -1,9 +1,4 @@ -from django.conf import settings as django_settings -from unittest import mock - from django.contrib.auth import user_logged_in, user_login_failed -from django.contrib.auth.backends import ModelBackend -from django.test import override_settings from djet import assertions from rest_framework import status from rest_framework.reverse import reverse @@ -39,216 +34,6 @@ def test_post_should_login_user(self): self.assertNotEqual(user.last_login, previous_last_login) self.assertTrue(self.signal_sent) - @override_settings( - AUTHENTICATION_BACKENDS=[ - "django.contrib.auth.backends.ModelBackend", - ], - DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}), - ) - def test_login__LOGIN_FIELD_username__USERNAME_FIELD_username( - self, - ): - user = create_user() - user_logged_in.connect(self.signal_receiver) - previous_last_login = user.last_login - - with mock.patch("djoser.serializers.User.USERNAME_FIELD", "username"): - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_200_OK) - - user.refresh_from_db() - self.assertEqual(response.data["auth_token"], user.auth_token.key) - self.assertNotEqual(user.last_login, previous_last_login) - self.assertTrue(self.signal_sent) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - @override_settings( - AUTHENTICATION_BACKENDS=[ - "django.contrib.auth.backends.ModelBackend", - ], - DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"}), - ) - def test_login__LOGIN_FIELD_email__USERNAME_FIELD_username( - self, - ): - user = create_user() - user_logged_in.connect(self.signal_receiver) - previous_last_login = user.last_login - - with mock.patch("djoser.serializers.User.USERNAME_FIELD", "username"): - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - user.refresh_from_db() - self.assertEqual(user.last_login, previous_last_login) - self.assertFalse(self.signal_sent) - - @override_settings( - AUTHENTICATION_BACKENDS=[ - "django.contrib.auth.backends.ModelBackend", - ], - DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "username"}), - ) - def test_login__LOGIN_FIELD_username__USERNAME_FIELD_email( - self, - ): - user = create_user() - user_logged_in.connect(self.signal_receiver) - previous_last_login = user.last_login - - with mock.patch("djoser.serializers.User.USERNAME_FIELD", "email"): - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - user.refresh_from_db() - self.assertEqual(user.last_login, previous_last_login) - self.assertFalse(self.signal_sent) - - @override_settings( - AUTHENTICATION_BACKENDS=[ - "django.contrib.auth.backends.ModelBackend", - ], - DJOSER=dict(django_settings.DJOSER, **{"LOGIN_FIELD": "email"}), - ) - def test_login__LOGIN_FIELD_email__USERNAME_FIELD_email( - self, - ): - user = create_user() - user_logged_in.connect(self.signal_receiver) - previous_last_login = user.last_login - - with mock.patch("djoser.serializers.User.USERNAME_FIELD", "email"): - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, - {"username": user.username, "password": user.raw_password}, - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=True - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_200_OK) - - user.refresh_from_db() - self.assertEqual(response.data["auth_token"], user.auth_token.key) - self.assertNotEqual(user.last_login, previous_last_login) - self.assertTrue(self.signal_sent) - - with mock.patch.object( - ModelBackend, "user_can_authenticate", return_value=False - ): - response = self.client.post( - self.base_url, {"email": user.email, "password": user.raw_password} - ) - self.assert_status_equal(response, status.HTTP_400_BAD_REQUEST) - def test_post_should_not_login_if_user_is_not_active(self): """In Django >= 1.10 authenticate() returns None if user is inactive, while in Django < 1.10 authenticate() succeeds if user is inactive.""" diff --git a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py new file mode 100644 index 00000000..27769184 --- /dev/null +++ b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py @@ -0,0 +1,136 @@ +from unittest import mock + +import pytest +from django.contrib.auth import user_logged_in, user_login_failed +from django.contrib.auth.backends import ModelBackend +from rest_framework import status +from rest_framework.reverse import reverse + +from testapp.tests.common import create_user + + +@pytest.mark.django_db +class TestUsernameLoginFields: + url = reverse("login") + + @pytest.fixture(autouse=True) + def settings(self, settings): + settings.AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + ] + return settings + + @pytest.fixture + def user(self): + return create_user() + + @pytest.fixture + def signal_user_logged_in_patched(self): + signal_handler = mock.MagicMock() + user_logged_in.connect(signal_handler) + return signal_handler + + @pytest.fixture + def signal_user_login_failed_patched(self): + signal_handler = mock.MagicMock() + user_login_failed.connect(signal_handler) + return signal_handler + + def configure_djoser_settings( + self, settings, mocker, login_field, username_field, user_can_authenticate + ): + settings.DJOSER["LOGIN_FIELD"] = login_field + mocker.patch("djoser.serializers.settings.LOGIN_FIELD", login_field) + mocker.patch("djoser.serializers.User.USERNAME_FIELD", username_field) + mocker.patch.object( + ModelBackend, "user_can_authenticate", return_value=user_can_authenticate + ) + + @pytest.mark.parametrize( + "login_field, username_field, send_field", + [ + ("username", "username", "username"), + ("email", "email", "email"), + ], + ) + def test_successful_login( + self, + user, + client, + settings, + mocker, + signal_user_logged_in_patched, + login_field, + username_field, + send_field, + ): + self.configure_djoser_settings( + settings=settings, + mocker=mocker, + login_field=login_field, + username_field=username_field, + user_can_authenticate=True, + ) + + if send_field == "username": + data = {"username": user.username, "password": user.raw_password} + else: + data = {"email": user.email, "password": user.raw_password} + + previous_last_login = user.last_login + response = client.post(self.url, data) + + assert response.status_code == status.HTTP_200_OK + user.refresh_from_db() + + assert response.data["auth_token"] == user.auth_token.key + assert user.last_login != previous_last_login + signal_user_logged_in_patched.assert_called_once() + + @pytest.mark.parametrize( + "login_field, username_field, user_can_authenticate, send_field", + [ + ("username", "username", False, "username"), + ("username", "username", True, "email"), + ("username", "email", True, "username"), + ("username", "email", False, "username"), + ("email", "username", False, "username"), + ("email", "username", True, "email"), + ("email", "email", True, "username"), + ("email", "email", False, "username"), + ("username", "email", True, "email"), + ("email", "username", True, "username"), + ], + ) + def test_failing_login( + self, + user, + client, + settings, + mocker, + signal_user_login_failed_patched, + login_field, + username_field, + send_field, + user_can_authenticate, + ): + self.configure_djoser_settings( + settings=settings, + mocker=mocker, + login_field=login_field, + username_field=username_field, + user_can_authenticate=user_can_authenticate, + ) + if send_field == "username": + data = {"username": user.username, "password": user.raw_password} + else: + data = {"email": user.email, "password": user.raw_password} + + previous_last_login = user.last_login + response = client.post(self.url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + user.refresh_from_db() + + assert user.last_login == previous_last_login + signal_user_login_failed_patched.assert_called_once() From ccd26cfa4c469f912f3c03d8fc7966ecf80335ec Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 13:51:39 +0200 Subject: [PATCH 07/12] fix flaky webauthn test --- testproject/testapp/tests/test_webauthn/test_login_request.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testproject/testapp/tests/test_webauthn/test_login_request.py b/testproject/testapp/tests/test_webauthn/test_login_request.py index da23dbf1..29a4b315 100644 --- a/testproject/testapp/tests/test_webauthn/test_login_request.py +++ b/testproject/testapp/tests/test_webauthn/test_login_request.py @@ -1,15 +1,18 @@ from django.contrib.auth import get_user_model +from django.test import override_settings from djet import assertions from rest_framework import status from rest_framework.reverse import reverse from rest_framework.test import APITestCase from testapp.tests.common import create_user +from django.conf import settings from .utils import create_credential_options User = get_user_model() +@override_settings(DJOSER=dict(settings.DJOSER, **{"LOGIN_FIELD": "username"})) class TestLoginRequestView(APITestCase, assertions.StatusCodeAssertionsMixin): url = reverse("webauthn_login_request") From fb084fcbf8c0b195c9029d7d85da2ffd94d57c8b Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 14:17:38 +0200 Subject: [PATCH 08/12] add LoginFieldBackend that allows to log in by LOGIN_FIELD --- djoser/auth_backends.py | 29 +++ djoser/serializers.py | 2 +- ...ken_create_custom_username_login_fields.py | 191 +++++++++++++++--- 3 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 djoser/auth_backends.py diff --git a/djoser/auth_backends.py b/djoser/auth_backends.py new file mode 100644 index 00000000..1b9991a0 --- /dev/null +++ b/djoser/auth_backends.py @@ -0,0 +1,29 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from djoser.conf import settings + + +UserModel = get_user_model() + + +class LoginFieldBackend(ModelBackend): + """Allows to log in by a different value than the default Django + USERNAME_FIELD.""" + + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + if username is None or password is None: + return + get_kwargs = { + settings.LOGIN_FIELD: username, + } + try: + user = UserModel._default_manager.get(**get_kwargs) + except UserModel.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (#20760). + UserModel().set_password(password) + else: + if user.check_password(password) and self.user_can_authenticate(user): + return user diff --git a/djoser/serializers.py b/djoser/serializers.py index f9016dc3..e201852a 100644 --- a/djoser/serializers.py +++ b/djoser/serializers.py @@ -120,7 +120,7 @@ def validate(self, attrs): # https://github.com/sunscrapers/djoser/issues/389 # https://github.com/sunscrapers/djoser/issues/429 # https://github.com/sunscrapers/djoser/issues/795 - params = {"username": attrs.get(settings.LOGIN_FIELD)} + params = {User.USERNAME_FIELD: attrs.get(settings.LOGIN_FIELD)} self.user = authenticate( request=self.context.get("request"), **params, password=password ) diff --git a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py index 27769184..5c883adf 100644 --- a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py +++ b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py @@ -10,9 +10,13 @@ @pytest.mark.django_db -class TestUsernameLoginFields: +class BaseTestUsernameLoginFields: url = reverse("login") + @pytest.fixture(autouse=True) + def add_authentication_backend(self, settings): + raise NotImplementedError + @pytest.fixture(autouse=True) def settings(self, settings): settings.AUTHENTICATION_BACKENDS = [ @@ -46,14 +50,7 @@ def configure_djoser_settings( ModelBackend, "user_can_authenticate", return_value=user_can_authenticate ) - @pytest.mark.parametrize( - "login_field, username_field, send_field", - [ - ("username", "username", "username"), - ("email", "email", "email"), - ], - ) - def test_successful_login( + def _test_successful_login( self, user, client, @@ -87,22 +84,7 @@ def test_successful_login( assert user.last_login != previous_last_login signal_user_logged_in_patched.assert_called_once() - @pytest.mark.parametrize( - "login_field, username_field, user_can_authenticate, send_field", - [ - ("username", "username", False, "username"), - ("username", "username", True, "email"), - ("username", "email", True, "username"), - ("username", "email", False, "username"), - ("email", "username", False, "username"), - ("email", "username", True, "email"), - ("email", "email", True, "username"), - ("email", "email", False, "username"), - ("username", "email", True, "email"), - ("email", "username", True, "username"), - ], - ) - def test_failing_login( + def _test_failing_login( self, user, client, @@ -134,3 +116,162 @@ def test_failing_login( assert user.last_login == previous_last_login signal_user_login_failed_patched.assert_called_once() + + +@pytest.mark.django_db +class TestModelBackendLoginFields(BaseTestUsernameLoginFields): + url = reverse("login") + + @pytest.fixture(autouse=True) + def add_authentication_backend(self, settings): + settings.AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + ] + + @pytest.mark.parametrize( + "login_field, username_field, send_field", + [ + ("username", "username", "username"), + ("email", "email", "email"), + ], + ) + def test_successful_login( + self, + user, + client, + settings, + mocker, + signal_user_logged_in_patched, + login_field, + username_field, + send_field, + ): + self._test_successful_login( + user, + client, + settings, + mocker, + signal_user_logged_in_patched, + login_field, + username_field, + send_field, + ) + + @pytest.mark.parametrize( + "login_field, username_field, user_can_authenticate, send_field", + [ + ("username", "username", False, "username"), + ("username", "username", True, "email"), + ("username", "email", True, "username"), + ("username", "email", False, "username"), + ("email", "username", False, "username"), + ("email", "username", True, "email"), + ("email", "email", True, "username"), + ("email", "email", False, "username"), + ("username", "email", True, "email"), + ("email", "username", True, "username"), + ], + ) + def test_failing_login( + self, + user, + client, + settings, + mocker, + signal_user_login_failed_patched, + login_field, + username_field, + send_field, + user_can_authenticate, + ): + self._test_failing_login( + user, + client, + settings, + mocker, + signal_user_login_failed_patched, + login_field, + username_field, + send_field, + user_can_authenticate, + ) + + +@pytest.mark.django_db +class TestLoginFieldBackend(BaseTestUsernameLoginFields): + url = reverse("login") + + @pytest.fixture(autouse=True) + def add_authentication_backend(self, settings): + settings.AUTHENTICATION_BACKENDS = [ + "djoser.auth_backends.LoginFieldBackend", + ] + + @pytest.mark.parametrize( + "login_field, username_field, send_field", + [ + ("username", "username", "username"), + ("email", "email", "email"), + ("email", "username", "email"), + ("username", "email", "username"), + ], + ) + def test_successful_login( + self, + user, + client, + settings, + mocker, + signal_user_logged_in_patched, + login_field, + username_field, + send_field, + ): + self._test_successful_login( + user, + client, + settings, + mocker, + signal_user_logged_in_patched, + login_field, + username_field, + send_field, + ) + + @pytest.mark.parametrize( + "login_field, username_field, user_can_authenticate, send_field", + [ + ("username", "username", False, "username"), + ("username", "email", False, "username"), + ("email", "username", False, "username"), + ("email", "email", False, "username"), + ("username", "username", True, "email"), + # ("username", "email", True, "username"), + ("email", "email", True, "username"), + ("username", "email", True, "email"), + ("email", "username", True, "username"), + ], + ) + def test_failing_login( + self, + user, + client, + settings, + mocker, + signal_user_login_failed_patched, + login_field, + username_field, + send_field, + user_can_authenticate, + ): + self._test_failing_login( + user, + client, + settings, + mocker, + signal_user_login_failed_patched, + login_field, + username_field, + send_field, + user_can_authenticate, + ) From f2d7dc4f5fbac62fc9762e35b1b1010ed4da0025 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 14:20:11 +0200 Subject: [PATCH 09/12] rm commented out test case --- .../tests/test_token_create_custom_username_login_fields.py | 1 - 1 file changed, 1 deletion(-) diff --git a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py index 5c883adf..bccb2819 100644 --- a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py +++ b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py @@ -246,7 +246,6 @@ def test_successful_login( ("email", "username", False, "username"), ("email", "email", False, "username"), ("username", "username", True, "email"), - # ("username", "email", True, "username"), ("email", "email", True, "username"), ("username", "email", True, "email"), ("email", "username", True, "username"), From bc21dcffc08f8d83234a5b61586ae8db60bcfbe7 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 14:30:57 +0200 Subject: [PATCH 10/12] update docs for LOGIN_FIELD --- docs/source/settings.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/source/settings.rst b/docs/source/settings.rst index e2aa8347..eeb668aa 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -32,6 +32,15 @@ Name of a field in User model to be used as login field. This is useful if you want to change the login field from ``username`` to ``email`` without providing custom User model. +.. versionchanged:: 2.3.0 + +As the authentication is happening within the Django's authentication backends, you need a +custom authentication backend that's using ``LOGIN_FIELD`` instead of ``User.USERNAME_FIELD`` +for this setting to work as expected. + +If you don't want to roll out your own authentication backend, ``LoginFieldBackend`` has been prepared. +It works the same as ``ModelBackend``, but it is using ``LOGIN_FIELD`` instead of ``User.USERNAME_FIELD``. + **Default**: ``User.USERNAME_FIELD`` where ``User`` is the model set with Django's setting AUTH_USER_MODEL. .. warning:: From 81cd1e2c4f92a60c38951cde69a68606d693732e Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 7 Jun 2024 14:35:35 +0200 Subject: [PATCH 11/12] add test case for user does not exist in LoginFieldBackend --- ...st_token_create_custom_username_login_fields.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py index bccb2819..f77d0d2d 100644 --- a/testproject/testapp/tests/test_token_create_custom_username_login_fields.py +++ b/testproject/testapp/tests/test_token_create_custom_username_login_fields.py @@ -274,3 +274,17 @@ def test_failing_login( send_field, user_can_authenticate, ) + + def test_user_does_not_exist(self, client, settings, mocker): + self.configure_djoser_settings( + settings=settings, + mocker=mocker, + login_field="username", + username_field="username", + user_can_authenticate=True, + ) + data = {"username": "idontexist1337", "password": "P455W0RD"} + + response = client.post(self.url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST From bd23a35016457b78b062159e8d9fdcbdd763feaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20W=C3=B3jcik?= Date: Sat, 9 Nov 2024 15:10:14 +0100 Subject: [PATCH 12/12] Update docs/source/settings.rst Co-authored-by: Przemek Lewandowski --- docs/source/settings.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/source/settings.rst b/docs/source/settings.rst index eeb668aa..6c3f3edb 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -41,6 +41,15 @@ for this setting to work as expected. If you don't want to roll out your own authentication backend, ``LoginFieldBackend`` has been prepared. It works the same as ``ModelBackend``, but it is using ``LOGIN_FIELD`` instead of ``User.USERNAME_FIELD``. +To make your code backward compatible with previous Djoser versions, add this auth backend to your Django settings: + +.. code-block:: python + + AUTHENTICATION_BACKENDS = [ + "djoser.auth_backends.LoginFieldBackend", + ] + +Please notice that it is not recommended way of authentication and it may be removed in the future Djoser versions. **Default**: ``User.USERNAME_FIELD`` where ``User`` is the model set with Django's setting AUTH_USER_MODEL. .. warning::