Fixed #25232 -- Made ModelBackend/RemoteUserBackend reject inactive users.

This commit is contained in:
Alexander Gaevsky 2016-02-05 16:46:19 +02:00 committed by Tim Graham
parent 1555d50ea4
commit e0a3d93730
11 changed files with 216 additions and 30 deletions

View File

@ -15,12 +15,21 @@ class ModelBackend(object):
username = kwargs.get(UserModel.USERNAME_FIELD) username = kwargs.get(UserModel.USERNAME_FIELD)
try: try:
user = UserModel._default_manager.get_by_natural_key(username) user = UserModel._default_manager.get_by_natural_key(username)
if user.check_password(password):
return user
except UserModel.DoesNotExist: except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing # Run the default password hasher once to reduce the timing
# difference between an existing and a non-existing user (#20760). # difference between an existing and a non-existing user (#20760).
UserModel().set_password(password) UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user
def user_can_authenticate(self, user):
"""
Reject users with is_active=False. Custom user models that don't have
that attribute are allowed.
"""
is_active = getattr(user, 'is_active', None)
return is_active or is_active is None
def _get_user_permissions(self, user_obj): def _get_user_permissions(self, user_obj):
return user_obj.user_permissions.all() return user_obj.user_permissions.all()
@ -90,9 +99,15 @@ class ModelBackend(object):
def get_user(self, user_id): def get_user(self, user_id):
UserModel = get_user_model() UserModel = get_user_model()
try: try:
return UserModel._default_manager.get(pk=user_id) user = UserModel._default_manager.get(pk=user_id)
except UserModel.DoesNotExist: except UserModel.DoesNotExist:
return None return None
return user if self.user_can_authenticate(user) else None
class AllowAllUsersModelBackend(ModelBackend):
def user_can_authenticate(self, user):
return True
class RemoteUserBackend(ModelBackend): class RemoteUserBackend(ModelBackend):
@ -140,7 +155,7 @@ class RemoteUserBackend(ModelBackend):
user = UserModel._default_manager.get_by_natural_key(username) user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist: except UserModel.DoesNotExist:
pass pass
return user return user if self.user_can_authenticate(user) else None
def clean_username(self, username): def clean_username(self, username):
""" """
@ -158,3 +173,8 @@ class RemoteUserBackend(ModelBackend):
By default, returns the user unmodified. By default, returns the user unmodified.
""" """
return user return user
class AllowAllUsersRemoteUserBackend(RemoteUserBackend):
def user_can_authenticate(self, user):
return True

View File

@ -64,10 +64,21 @@ remote users. These interfaces work with users stored in the database
regardless of ``AUTHENTICATION_BACKENDS``. regardless of ``AUTHENTICATION_BACKENDS``.
.. note:: .. note::
Since the ``RemoteUserBackend`` inherits from ``ModelBackend``, you will Since the ``RemoteUserBackend`` inherits from ``ModelBackend``, you will
still have all of the same permissions checking that is implemented in still have all of the same permissions checking that is implemented in
``ModelBackend``. ``ModelBackend``.
Users with :attr:`is_active=False
<django.contrib.auth.models.User.is_active>` won't be allowed to
authenticate. Use
:class:`~django.contrib.auth.backends.AllowAllUsersRemoteUserBackend` if
you want to allow them to.
.. versionchanged:: 1.10
In older versions, inactive users weren't rejected as described above.
If your authentication mechanism uses a custom HTTP header and not If your authentication mechanism uses a custom HTTP header and not
``REMOTE_USER``, you can subclass ``RemoteUserMiddleware`` and set the ``REMOTE_USER``, you can subclass ``RemoteUserMiddleware`` and set the
``header`` attribute to the desired ``request.META`` key. For example:: ``header`` attribute to the desired ``request.META`` key. For example::

View File

@ -76,15 +76,26 @@ Fields
This doesn't necessarily control whether or not the user can log in. This doesn't necessarily control whether or not the user can log in.
Authentication backends aren't required to check for the ``is_active`` Authentication backends aren't required to check for the ``is_active``
flag, and the default backends do not. If you want to reject a login flag but the default backend
based on ``is_active`` being ``False``, it's up to you to check that in (:class:`~django.contrib.auth.backends.ModelBackend`) and the
your own login view or a custom authentication backend. However, the :class:`~django.contrib.auth.backends.RemoteUserBackend` do. You can
use :class:`~django.contrib.auth.backends.AllowAllUsersModelBackend`
or :class:`~django.contrib.auth.backends.AllowAllUsersRemoteUserBackend`
if you want to allow inactive users to login. In this case, you'll also
want to customize the
:class:`~django.contrib.auth.forms.AuthenticationForm` used by the :class:`~django.contrib.auth.forms.AuthenticationForm` used by the
:func:`~django.contrib.auth.views.login` view (which is the default) :func:`~django.contrib.auth.views.login` view as it rejects inactive
*does* perform this check, as do the permission-checking methods such users. Be aware that the permission-checking methods such as
as :meth:`~django.contrib.auth.models.User.has_perm` and the :meth:`~django.contrib.auth.models.User.has_perm` and the
authentication in the Django admin. All of those functions/methods will authentication in the Django admin all return ``False`` for inactive
return ``False`` for inactive users. users.
.. versionchanged:: 1.10
In older versions,
:class:`~django.contrib.auth.backends.ModelBackend` and
:class:`~django.contrib.auth.backends.RemoteUserBackend` allowed
inactive users to authenticate.
.. attribute:: is_superuser .. attribute:: is_superuser
@ -488,6 +499,32 @@ The following backends are available in :mod:`django.contrib.auth.backends`:
Returns whether the ``user_obj`` has any permissions on the app Returns whether the ``user_obj`` has any permissions on the app
``app_label``. ``app_label``.
.. method:: ModelBackend.user_can_authenticate()
.. versionadded:: 1.10
Returns whether the user is allowed to authenticate. To match the
behavior of :class:`~django.contrib.auth.forms.AuthenticationForm`
which :meth:`prohibits inactive users from logging in
<django.contrib.auth.forms.AuthenticationForm.confirm_login_allowed>`,
this method returns ``False`` for users with :attr:`is_active=False
<django.contrib.auth.models.User.is_active>`. Custom user models that
don't have an :attr:`~django.contrib.auth.models.CustomUser.is_active`
field are allowed.
.. class:: AllowAllUsersModelBackend
.. versionadded:: 1.10
Same as :class:`ModelBackend` except that it doesn't reject inactive users
because :meth:`~ModelBackend.user_can_authenticate` always returns ``True``.
When using this backend, you'll likely want to customize the
:class:`~django.contrib.auth.forms.AuthenticationForm` used by the
:func:`~django.contrib.auth.views.login` view by overriding the
:meth:`~django.contrib.auth.forms.AuthenticationForm.confirm_login_allowed`
method as it rejects inactive users.
.. class:: RemoteUserBackend .. class:: RemoteUserBackend
Use this backend to take advantage of external-to-Django-handled Use this backend to take advantage of external-to-Django-handled
@ -529,3 +566,21 @@ The following backends are available in :mod:`django.contrib.auth.backends`:
new user is created, and can be used to perform custom setup actions, such new user is created, and can be used to perform custom setup actions, such
as setting the user's groups based on attributes in an LDAP directory. as setting the user's groups based on attributes in an LDAP directory.
Returns the user object. Returns the user object.
.. method:: RemoteUserBackend.user_can_authenticate()
.. versionadded:: 1.10
Returns whether the user is allowed to authenticate. This method returns
``False`` for users with :attr:`is_active=False
<django.contrib.auth.models.User.is_active>`. Custom user models that don't
have an :attr:`~django.contrib.auth.models.CustomUser.is_active` field are
allowed.
.. class:: AllowAllUsersRemoteUserBackend
.. versionadded:: 1.10
Same as :class:`RemoteUserBackend` except that it doesn't reject inactive
users because :attr:`~RemoteUserBackend.user_can_authenticate` always
returns ``True``.

View File

@ -669,6 +669,15 @@ Miscellaneous
calling ``Command.execute()``, pass the command object as the first argument calling ``Command.execute()``, pass the command object as the first argument
to ``call_command()``. to ``call_command()``.
* :class:`~django.contrib.auth.backends.ModelBackend` and
:class:`~django.contrib.auth.backends.RemoteUserBackend` now reject inactive
users. This means that inactive users can't login and will be logged
out if they are switched from ``is_active=True`` to ``False``. If you need
the previous behavior, use the new
:class:`~django.contrib.auth.backends.AllowAllUsersModelBackend` or
:class:`~django.contrib.auth.backends.AllowAllUsersRemoteUserBackend`
in :setting:`AUTHENTICATION_BACKENDS` instead.
.. _deprecated-features-1.10: .. _deprecated-features-1.10:
Features deprecated in 1.10 Features deprecated in 1.10

View File

@ -235,10 +235,17 @@ for example, to control anonymous access.
Authorization for inactive users Authorization for inactive users
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
An inactive user is a one that is authenticated but has its attribute An inactive user is a one that has its
``is_active`` set to ``False``. However this does not mean they are not :attr:`~django.contrib.auth.models.User.is_active` field set to ``False``. The
authorized to do anything. For example they are allowed to activate their :class:`~django.contrib.auth.backends.ModelBackend` and
account. :class:`~django.contrib.auth.backends.RemoteUserBackend` authentication
backends prohibits these users from authenticating. If a custom user model
doesn't have an :attr:`~django.contrib.auth.models.CustomUser.is_active` field,
all users will be allowed to authenticate.
You can use :class:`~django.contrib.auth.backends.AllowAllUsersModelBackend`
or :class:`~django.contrib.auth.backends.AllowAllUsersRemoteUserBackend` if you
want to allow inactive users to authenticate.
The support for anonymous users in the permission system allows for a scenario The support for anonymous users in the permission system allows for a scenario
where anonymous users have permissions to do something while inactive where anonymous users have permissions to do something while inactive
@ -247,6 +254,10 @@ authenticated users do not.
Do not forget to test for the ``is_active`` attribute of the user in your own Do not forget to test for the ``is_active`` attribute of the user in your own
backend permission methods. backend permission methods.
.. versionchanged:: 1.10
In older versions, the :class:`~django.contrib.auth.backends.ModelBackend`
allowed inactive users to authenticate.
Handling object permissions Handling object permissions
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -1,12 +1,14 @@
from .custom_permissions import CustomPermissionsUser from .custom_permissions import CustomPermissionsUser
from .custom_user import CustomUser, ExtensionUser from .custom_user import (
CustomUser, CustomUserWithoutIsActiveField, ExtensionUser,
)
from .invalid_models import CustomUserNonUniqueUsername from .invalid_models import CustomUserNonUniqueUsername
from .is_active import IsActiveTestUser1 from .is_active import IsActiveTestUser1
from .uuid_pk import UUIDUser from .uuid_pk import UUIDUser
from .with_foreign_key import CustomUserWithFK, Email from .with_foreign_key import CustomUserWithFK, Email
__all__ = ( __all__ = (
'CustomUser', 'CustomPermissionsUser', 'CustomUserWithFK', 'Email', 'CustomUser', 'CustomUserWithoutIsActiveField', 'CustomPermissionsUser',
'ExtensionUser', 'IsActiveTestUser1', 'UUIDUser', 'CustomUserWithFK', 'Email', 'ExtensionUser', 'IsActiveTestUser1',
'CustomUserNonUniqueUsername', 'UUIDUser', 'CustomUserNonUniqueUsername',
) )

View File

@ -97,6 +97,15 @@ class RemoveGroupsAndPermissions(object):
PermissionsMixin._meta.local_many_to_many = self._old_pm_local_m2m PermissionsMixin._meta.local_many_to_many = self._old_pm_local_m2m
class CustomUserWithoutIsActiveField(AbstractBaseUser):
username = models.CharField(max_length=150, unique=True)
email = models.EmailField(unique=True)
objects = UserManager()
USERNAME_FIELD = 'username'
# The extension user is a simple extension of the built-in user class, # The extension user is a simple extension of the built-in user class,
# adding a required date_of_birth field. This allows us to check for # adding a required date_of_birth field. This allows us to check for
# any hard references to the name "User" in forms/handlers etc. # any hard references to the name "User" in forms/handlers etc.

View File

@ -15,7 +15,10 @@ from django.test import (
SimpleTestCase, TestCase, modify_settings, override_settings, SimpleTestCase, TestCase, modify_settings, override_settings,
) )
from .models import CustomPermissionsUser, CustomUser, ExtensionUser, UUIDUser from .models import (
CustomPermissionsUser, CustomUser, CustomUserWithoutIsActiveField,
ExtensionUser, UUIDUser,
)
class CountingMD5PasswordHasher(MD5PasswordHasher): class CountingMD5PasswordHasher(MD5PasswordHasher):
@ -200,19 +203,35 @@ class ModelBackendTest(BaseModelBackendTest, TestCase):
Tests for the ModelBackend using the default User model. Tests for the ModelBackend using the default User model.
""" """
UserModel = User UserModel = User
user_credentials = {'username': 'test', 'password': 'test'}
def create_users(self): def create_users(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(email='test@example.com', **self.user_credentials)
username='test',
email='test@example.com',
password='test',
)
self.superuser = User.objects.create_superuser( self.superuser = User.objects.create_superuser(
username='test2', username='test2',
email='test2@example.com', email='test2@example.com',
password='test', password='test',
) )
def test_authenticate_inactive(self):
"""
An inactive user can't authenticate.
"""
self.assertEqual(authenticate(**self.user_credentials), self.user)
self.user.is_active = False
self.user.save()
self.assertIsNone(authenticate(**self.user_credentials))
@override_settings(AUTH_USER_MODEL='auth_tests.CustomUserWithoutIsActiveField')
def test_authenticate_user_without_is_active_field(self):
"""
A custom user without an `is_active` field is allowed to authenticate.
"""
user = CustomUserWithoutIsActiveField.objects._create_user(
username='test', email='test@example.com', password='test',
)
self.assertEqual(authenticate(username='test', password='test'), user)
@override_settings(AUTH_USER_MODEL='auth_tests.ExtensionUser') @override_settings(AUTH_USER_MODEL='auth_tests.ExtensionUser')
class ExtensionUserModelBackendTest(BaseModelBackendTest, TestCase): class ExtensionUserModelBackendTest(BaseModelBackendTest, TestCase):
@ -676,3 +695,29 @@ class SelectingBackendTests(TestCase):
user = User.objects.create_user(self.username, 'email', self.password) user = User.objects.create_user(self.username, 'email', self.password)
self.client._login(user, self.other_backend) self.client._login(user, self.other_backend)
self.assertBackendInSession(self.other_backend) self.assertBackendInSession(self.other_backend)
@override_settings(AUTHENTICATION_BACKENDS=['django.contrib.auth.backends.AllowAllUsersModelBackend'])
class AllowAllUsersModelBackendTest(TestCase):
"""
Inactive users may authenticate with the AllowAllUsersModelBackend.
"""
user_credentials = {'username': 'test', 'password': 'test'}
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
email='test@example.com', is_active=False,
**cls.user_credentials
)
def test_authenticate(self):
self.assertFalse(self.user.is_active)
self.assertEqual(authenticate(**self.user_credentials), self.user)
def test_get_user(self):
self.client.force_login(self.user)
request = HttpRequest()
request.session = self.client.session
user = get_user(request)
self.assertEqual(user, self.user)

View File

@ -166,6 +166,9 @@ class UserCreationFormTest(TestDataMixin, TestCase):
self.assertEqual(form.cleaned_data['password2'], data['password2']) self.assertEqual(form.cleaned_data['password2'], data['password2'])
# To verify that the login form rejects inactive users, use an authentication
# backend that allows them.
@override_settings(AUTHENTICATION_BACKENDS=['django.contrib.auth.backends.AllowAllUsersModelBackend'])
class AuthenticationFormTest(TestDataMixin, TestCase): class AuthenticationFormTest(TestDataMixin, TestCase):
def test_invalid_username(self): def test_invalid_username(self):

View File

@ -145,6 +145,11 @@ class RemoteUserTest(TestCase):
# In backends that do not create new users, it is '' (anonymous user) # In backends that do not create new users, it is '' (anonymous user)
self.assertNotEqual(response.context['user'].username, 'knownuser') self.assertNotEqual(response.context['user'].username, 'knownuser')
def test_inactive_user(self):
User.objects.create(username='knownuser', is_active=False)
response = self.client.get('/remote_user/', **{self.header: 'knownuser'})
self.assertTrue(response.context['user'].is_anonymous())
class RemoteUserNoCreateBackend(RemoteUserBackend): class RemoteUserNoCreateBackend(RemoteUserBackend):
"""Backend that doesn't create unknown users.""" """Backend that doesn't create unknown users."""
@ -166,6 +171,16 @@ class RemoteUserNoCreateTest(RemoteUserTest):
self.assertEqual(User.objects.count(), num_users) self.assertEqual(User.objects.count(), num_users)
class AllowAllUsersRemoteUserBackendTest(RemoteUserTest):
"""Backend that allows inactive users."""
backend = 'django.contrib.auth.backends.AllowAllUsersRemoteUserBackend'
def test_inactive_user(self):
user = User.objects.create(username='knownuser', is_active=False)
response = self.client.get('/remote_user/', **{self.header: self.known_user})
self.assertEqual(response.context['user'].username, user.username)
class CustomRemoteUserBackend(RemoteUserBackend): class CustomRemoteUserBackend(RemoteUserBackend):
""" """
Backend that overrides RemoteUserBackend methods. Backend that overrides RemoteUserBackend methods.

View File

@ -437,6 +437,12 @@ class ClientTest(TestCase):
login = self.client.login(username='inactive', password='password') login = self.client.login(username='inactive', password='password')
self.assertFalse(login) self.assertFalse(login)
@override_settings(
AUTHENTICATION_BACKENDS=[
'django.contrib.auth.backends.ModelBackend',
'django.contrib.auth.backends.AllowAllUsersModelBackend',
]
)
def test_view_with_inactive_force_login(self): def test_view_with_inactive_force_login(self):
"Request a page that is protected with @login, but use an inactive login" "Request a page that is protected with @login, but use an inactive login"
@ -445,7 +451,7 @@ class ClientTest(TestCase):
self.assertRedirects(response, '/accounts/login/?next=/login_protected_view/') self.assertRedirects(response, '/accounts/login/?next=/login_protected_view/')
# Log in # Log in
self.client.force_login(self.u2) self.client.force_login(self.u2, backend='django.contrib.auth.backends.AllowAllUsersModelBackend')
# Request a page that requires a login # Request a page that requires a login
response = self.client.get('/login_protected_view/') response = self.client.get('/login_protected_view/')