From 69cd57a122243ad53b79629c401df584880605b9 Mon Sep 17 00:00:00 2001 From: Noumbissi Valere Date: Tue, 29 Dec 2020 19:24:57 -0800 Subject: [PATCH] [feature] Allowed authentication with email or phone number #206 Closes #206 Co-authored-by: Federico Capoano --- README.rst | 24 +++++- openwisp_users/backends.py | 24 ++++++ openwisp_users/tests/test_admin.py | 2 +- openwisp_users/tests/test_backends.py | 106 ++++++++++++++++++++++++++ tests/openwisp2/sample_users/tests.py | 6 ++ tests/openwisp2/settings.py | 4 + 6 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 openwisp_users/backends.py create mode 100644 openwisp_users/tests/test_backends.py diff --git a/README.rst b/README.rst index 4e69601d..03a28696 100644 --- a/README.rst +++ b/README.rst @@ -97,10 +97,14 @@ Setup (integrate in an existing django project) 'drf_yasg', ] -also add ``AUTH_USER_MODEL`` and ``SITE_ID`` to your ``settings.py``:: +also add ``AUTH_USER_MODEL``, ``SITE_ID`` and ``AUTHENTICATION_BACKENDS`` +to your ``settings.py``:: AUTH_USER_MODEL = 'openwisp_users.User' SITE_ID = 1 + AUTHENTICATION_BACKENDS = [ + 'openwisp_users.backends.UsersAuthenticationBackend', + ] ``urls.py``: @@ -491,6 +495,24 @@ to avoid generating database queries each time is called. >>> user.has_permission('openwisp_users.add_user') ... True +Authentication Backend +---------------------- + +The authentication backend in ``openwisp_users.backends.UsersAuthenticationBackend`` +allows users to authenticate using their +``email`` or ``phone_number`` instead of their ``username``. +Authenticating with the ``username`` is still allowed, +but ``email`` and ``phone_number`` have precedence. + +It can be used as follows: + +.. code-block:: python + + from openwisp_users.backends import UsersAuthenticationBackend + + backend = UsersAuthenticationBackend() + backend.authenticate(request, phone_number, password) + Django REST Framework Permission Classes ---------------------------------------- diff --git a/openwisp_users/backends.py b/openwisp_users/backends.py new file mode 100644 index 00000000..56a18520 --- /dev/null +++ b/openwisp_users/backends.py @@ -0,0 +1,24 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q + +User = get_user_model() + + +class UsersAuthenticationBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + queryset = self.get_users(username) + try: + # can not use queryset.first() because it orders the queryset + # by pk before returning the first object which is not what we want + user = queryset[0] + except IndexError: + return None + if user.check_password(password) and self.user_can_authenticate(user): + return user + return None + + def get_users(self, identifier): + return User.objects.filter( + Q(email=identifier) | Q(phone_number=identifier) | Q(username=identifier) + ) diff --git a/openwisp_users/tests/test_admin.py b/openwisp_users/tests/test_admin.py index c21dac2f..4f92a633 100644 --- a/openwisp_users/tests/test_admin.py +++ b/openwisp_users/tests/test_admin.py @@ -1585,7 +1585,7 @@ def test_multitenant_admin_manager_only(self): staff.groups.add(admin_group) self._create_org_user(organization=other_org, user=staff, is_admin=False) self._create_org_user(organization=staff_org, user=staff, is_admin=True) - self._login(staff) + self._login(staff.username) user1 = self._create_user(username='user1__otherorg', email='user1@user1.org') self._create_org_user(organization=other_org, user=user1, is_admin=False) self._test_multitenant_admin( diff --git a/openwisp_users/tests/test_backends.py b/openwisp_users/tests/test_backends.py new file mode 100644 index 00000000..a6dec3d6 --- /dev/null +++ b/openwisp_users/tests/test_backends.py @@ -0,0 +1,106 @@ +from uuid import UUID + +from django.test import TestCase +from django.test.utils import override_settings + +from openwisp_users.backends import UsersAuthenticationBackend + +from .utils import TestOrganizationMixin + +auth_backend = UsersAuthenticationBackend() + + +class TestBackends(TestOrganizationMixin, TestCase): + def _test_user_auth_backend_helper(self, username, password, pk): + self.client.login(username=username, password=password) + self.assertIn('_auth_user_id', self.client.session) + self.assertEqual( + UUID(self.client.session['_auth_user_id'], version=4), pk, + ) + self.client.logout() + self.assertNotIn('_auth_user_id', self.client.session) + + @override_settings( + AUTHENTICATION_BACKENDS=('openwisp_users.backends.UsersAuthenticationBackend',) + ) + def test_user_auth_backend(self): + user = self._create_user( + username='tester', + email='tester@gmail.com', + phone_number='+237675579231', + password='tester', + ) + with self.subTest('Test login with username'): + self._test_user_auth_backend_helper('tester', 'tester', user.pk) + + with self.subTest('Test login with email'): + self._test_user_auth_backend_helper('tester@gmail.com', 'tester', user.pk) + + with self.subTest('Test login with phone_number'): + self._test_user_auth_backend_helper('+237675579231', 'tester', user.pk) + + @override_settings( + AUTHENTICATION_BACKENDS=('openwisp_users.backends.UsersAuthenticationBackend',) + ) + def test_user_with_email_as_username_auth_backend(self): + user = self._create_user( + username='tester', + email='tester@gmail.com', + phone_number='+237675579231', + password='tester', + ) + self._create_user( + username='tester@gmail.com', + email='tester1@gmail.com', + phone_number='+237675579232', + password='tester1', + ) + self._test_user_auth_backend_helper(user.email, 'tester', user.pk) + + @override_settings( + AUTHENTICATION_BACKENDS=('openwisp_users.backends.UsersAuthenticationBackend',) + ) + def test_user_with_phone_number_as_username_auth_backend(self): + user = self._create_user( + username='tester', + email='tester@gmail.com', + phone_number='+237675579231', + password='tester', + ) + self._create_user( + username='+237675579231', + email='tester1@gmail.com', + phone_number='+237675579232', + password='tester1', + ) + self._test_user_auth_backend_helper(user.phone_number, 'tester', user.pk) + + def test_auth_backend_get_user(self): + user = self._create_user( + username='tester', + email='tester@gmail.com', + phone_number='+237675579231', + password='tester', + ) + user1 = self._create_user( + username='tester1', + email='tester1@gmail.com', + phone_number='+237675579232', + password='tester1', + ) + + with self.subTest('get user with invalid identifier'): + self.assertEqual(len(auth_backend.get_users('invalid')), 0) + + with self.subTest('get user with email'): + user1.username = user.email + user1.save() + self.assertEqual(auth_backend.get_users(user.email)[0], user) + + with self.subTest('get user with phone_number'): + user1.username = user.phone_number + user1.save() + self.assertEqual(auth_backend.get_users(user.phone_number)[0], user) + + with self.subTest('get user with username'): + self.assertEqual(auth_backend.get_users(user.username)[0], user) diff --git a/tests/openwisp2/sample_users/tests.py b/tests/openwisp2/sample_users/tests.py index 462f3636..9ed287c3 100644 --- a/tests/openwisp2/sample_users/tests.py +++ b/tests/openwisp2/sample_users/tests.py @@ -14,6 +14,7 @@ from openwisp_users.tests.test_api.test_views import ( TestRestFrameworkViews as BaseTestRestFrameworkViews, ) +from openwisp_users.tests.test_backends import TestBackends as BaseTestBackends from openwisp_users.tests.test_models import TestUsers as BaseTestUsers additional_fields = [ @@ -98,6 +99,10 @@ class RatelimitTests(BaseRatelimitTests): pass +class TestBackends(BaseTestBackends): + pass + + del BaseTestUsersAdmin del BaseTestBasicUsersIntegration del BaseTestMultitenantAdmin @@ -105,3 +110,4 @@ class RatelimitTests(BaseRatelimitTests): del BaseAuthenticationTests del BaseRatelimitTests del BaseTestRestFrameworkViews +del BaseTestBackends diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 528e21a8..42ed8d69 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -90,6 +90,10 @@ } ] +AUTHENTICATION_BACKENDS = [ + 'openwisp_users.backends.UsersAuthenticationBackend', +] + if not TESTING and SHELL: LOGGING = { 'disable_existing_loggers': False,