├── tests ├── __init__.py ├── urls.py ├── tests.py ├── test_settings.py ├── test_silly.py ├── backends.py ├── models.py ├── test_customuser.py └── base.py ├── impersonate_auth ├── __init__.py ├── signals.py └── backends.py ├── setup.cfg ├── .gitignore ├── runtests.py ├── .travis.yml ├── LICENSE ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /impersonate_auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.2 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pydevproject 2 | .project 3 | .coverage 4 | .pytest_cache 5 | pip-selfcheck.json 6 | bin 7 | include 8 | *pyc 9 | lib 10 | *,cover 11 | *.egg-info 12 | -------------------------------------------------------------------------------- /impersonate_auth/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | user_impersonated = Signal(providing_args=['request', 'user', 'impersonator']) 4 | user_impersonation_failed = Signal(providing_args=['request', 'user']) 5 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from .base import BaseImpersonationBackendTest, AUTH_BACKEND 4 | 5 | 6 | class TestImpersonationBackend(BaseImpersonationBackendTest, TestCase): 7 | backends = [AUTH_BACKEND, 'django.contrib.auth.backends.ModelBackend'] 8 | pass 9 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | try: 10 | tests = sys.argv[1:] 11 | except ValueError: 12 | tests = ["tests"] 13 | 14 | if __name__ == "__main__": 15 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 16 | django.setup() 17 | TestRunner = get_runner(settings) 18 | test_runner = TestRunner() 19 | failures = test_runner.run_tests(tests) 20 | sys.exit(bool(failures)) 21 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'impersonate_auth_secret_key' 2 | 3 | # SECURITY WARNING: don't run with debug turned on in production! 4 | DEBUG = True 5 | 6 | ALLOWED_HOSTS = [] 7 | 8 | 9 | # Application definition 10 | 11 | INSTALLED_APPS = [ 12 | 'django.contrib.auth', 13 | 'django.contrib.contenttypes', 14 | 'django.contrib.sessions', 15 | 'impersonate_auth', 16 | 'tests', 17 | ] 18 | 19 | DATABASES = { 20 | 'default': { 21 | 'ENGINE': 'django.db.backends.sqlite3', 22 | } 23 | } 24 | 25 | PASSWORD_HASHERS = [ 26 | 'django.contrib.auth.hashers.MD5PasswordHasher', 27 | ] 28 | 29 | ROOT_URLCONF = 'tests.urls' 30 | -------------------------------------------------------------------------------- /tests/test_silly.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from impersonate_auth.backends import ImpersonationBackendMixin 4 | 5 | from .base import TEST_USER, TEST_SUPERUSER 6 | from .base import BaseImpersonationBackendTest 7 | 8 | SILLY_BACKEND = 'tests.backends.SillyBackend' 9 | SILLY_IMP_BACKEND = 'tests.backends.SillyImpersonationBackend' 10 | 11 | 12 | class TestSillyImpersonationBackend(BaseImpersonationBackendTest, TestCase): 13 | backends = [SILLY_IMP_BACKEND, SILLY_BACKEND] 14 | user_name = TEST_USER['email'] 15 | user_pw = TEST_USER['email'][::-1] 16 | superuser_name = TEST_SUPERUSER['email'] 17 | superuser_pw = TEST_SUPERUSER['email'][::-1] 18 | impersonation_backend = SILLY_IMP_BACKEND 19 | 20 | def test_impersonation_login_password_contains_sep(self): 21 | pass # test doesn't apply for this backend 22 | -------------------------------------------------------------------------------- /tests/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | from django.contrib.auth import get_user_model 3 | 4 | from impersonate_auth.backends import ImpersonationBackendMixin 5 | 6 | 7 | class SillyBackend(ModelBackend): 8 | ''' 9 | A silly backend where the password for a user is their email address backwards. 10 | ''' 11 | 12 | def authenticate(self, request=None, username=None, password=None): 13 | UserModel = get_user_model() 14 | try: 15 | silly_user = UserModel.objects.get_by_natural_key(username) 16 | except UserModel.DoesNotExist: 17 | silly_user = None 18 | if silly_user and password and password[::-1] == username: 19 | return silly_user 20 | 21 | 22 | class SillyImpersonationBackend(ImpersonationBackendMixin, SillyBackend): 23 | def __init__(self, *args, **kwargs): 24 | super(SillyImpersonationBackend, self).__init__(*args, **kwargs) 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.6" 7 | env: 8 | - DJANGO="Django>1.10,<1.11" 9 | - DJANGO="Django>1.11,<2.0" 10 | - DJANGO="Django>=2.0,<2.1" 11 | - DJANGO="https://github.com/django/django/archive/master.tar.gz" 12 | matrix: 13 | exclude: 14 | # Django 2.0+ dropped support for Python 2. 15 | - python: "2.7" 16 | env: DJANGO="Django>=2.0,<2.1" 17 | - python: "2.7" 18 | env: DJANGO="https://github.com/django/django/archive/master.tar.gz" 19 | - python: "3.4" 20 | env: DJANGO="https://github.com/django/django/archive/master.tar.gz" 21 | install: 22 | - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then pip install mock; fi 23 | - pip install -U pip 24 | - pip install coverage $DJANGO 25 | script: 26 | - coverage run --source=./impersonate_auth ./runtests.py 27 | after_success: 28 | - coverage report 29 | - pip install --quiet python-coveralls 30 | - coveralls 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Jordan Reiter 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: 5 | README = readme.read() 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | setup( 11 | name='django-impersonate-auth', 12 | version='0.1.2', 13 | packages=find_packages(), 14 | include_package_data=True, 15 | license='MIT License', # example license 16 | description='A simple impersonation backend for Django apps', 17 | long_description=README, 18 | url='https://github.com/JordanReiter/django-impersonate-auth', 19 | author='Jordan Reiter', 20 | author_email='jordanreiter@gmail.com', 21 | classifiers=[ 22 | 'Environment :: Web Environment', 23 | 'Framework :: Django', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', # example license 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 3.6', 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager 3 | 4 | 5 | class UserManager(BaseUserManager): 6 | def _create_user(self, email, password=None, **extra_fields): 7 | email = self.normalize_email(email) 8 | user = self.model(email=email, **extra_fields) 9 | user.set_password(password) 10 | user.save(using=self._db) 11 | return user 12 | 13 | def create_user(self, email, password=None, **extra_fields): 14 | extra_fields.setdefault('is_staff', False) 15 | extra_fields.setdefault('is_superuser', False) 16 | return self._create_user(email, password=password, **extra_fields) 17 | 18 | def create_superuser(self, email, password, **extra_fields): 19 | extra_fields['is_staff'] = True 20 | extra_fields['is_superuser'] = True 21 | return self._create_user(email, password=password, **extra_fields) 22 | 23 | 24 | class EmailUser(AbstractBaseUser): 25 | email = models.EmailField(verbose_name='email address', max_length=255, unique=True) 26 | last_name = models.CharField(max_length=100, blank=True) 27 | first_name = models.CharField(max_length=100, blank=True) 28 | is_active = models.BooleanField(default=True) 29 | is_staff = models.BooleanField(default=False) 30 | is_superuser = models.BooleanField(default=False) 31 | 32 | objects = UserManager() 33 | 34 | USERNAME_FIELD = 'email' 35 | -------------------------------------------------------------------------------- /tests/test_customuser.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | from django.conf import settings 5 | from django.contrib.auth import authenticate, get_user_model 6 | from django.contrib.auth.models import AbstractUser 7 | from django.test import TestCase, override_settings, Client, modify_settings 8 | 9 | from .base import TEST_USER_NAME, TEST_USER_PW, TEST_USER 10 | from .base import TEST_SUPERUSER_NAME, TEST_SUPERUSER_PW, TEST_SUPERUSER 11 | from .base import BaseImpersonationBackendTest 12 | from .base import AUTH_BACKEND 13 | 14 | 15 | @override_settings( 16 | AUTH_USER_MODEL='tests.EmailUser', 17 | ) 18 | class TestEmailImpersonationBackend(BaseImpersonationBackendTest, TestCase): 19 | backends = [AUTH_BACKEND] 20 | user_name = TEST_USER['email'] 21 | # user_pw = TEST_USER_PW 22 | superuser_name = TEST_SUPERUSER['email'] 23 | # superuser_pw = TEST_SUPERUSER_PW 24 | impersonation_backend = AUTH_BACKEND 25 | 26 | def setUp(self): 27 | super(TestEmailImpersonationBackend, self).setUp() 28 | 29 | def tearDown(self): 30 | super(TestEmailImpersonationBackend, self).tearDown() 31 | 32 | def create_user(self, username, email=None, password=None, *args, **kwargs): 33 | UserModel = get_user_model() 34 | return UserModel.objects.create_user(email, password=password, *args, **kwargs) 35 | 36 | def create_superuser(self, username, email, password, *args, **kwargs): 37 | UserModel = get_user_model() 38 | return UserModel.objects.create_superuser(email, password, *args, **kwargs) 39 | -------------------------------------------------------------------------------- /impersonate_auth/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | from django.contrib.auth import get_user_model 3 | 4 | from django.conf import settings 5 | 6 | from . import signals 7 | 8 | 9 | class ImpersonationBackendMixin(object): 10 | def user_can_impersonate(self, user, impersonated): 11 | if user == impersonated: 12 | return False # you can't impersonate yourself 13 | if not impersonated.is_active or not user.is_active: 14 | return False # both users have to be active 15 | if impersonated.is_superuser: 16 | return False # you can't impersonate other superusers 17 | if not user.is_superuser: 18 | return False 19 | return True 20 | 21 | def authenticate(self, request=None, username=None, password=None, **kwargs): 22 | UserModel = get_user_model() 23 | SEPARATOR = getattr(settings, 'IMPERSONATE_AUTH_SEPARATOR', ':') 24 | impersonate_kwargs = {} 25 | if username is None: 26 | username = kwargs.get(UserModel.USERNAME_FIELD) 27 | try: 28 | impersonate_user, impersonate_pass = password.split(SEPARATOR, 1) 29 | if UserModel.USERNAME_FIELD != 'username': 30 | impersonate_kwargs[UserModel.USERNAME_FIELD] = impersonate_user 31 | except (ValueError, TypeError, AttributeError) as err: 32 | return None 33 | # using same code as traditional ModelBackend for retrieving impersonated user 34 | try: 35 | impersonated = UserModel._default_manager.get_by_natural_key(username) 36 | except UserModel.DoesNotExist: 37 | return None 38 | impersonator = super(ImpersonationBackendMixin, self).authenticate(request=request, username=impersonate_user, password=impersonate_pass, **impersonate_kwargs) 39 | if impersonator and self.user_can_impersonate(impersonator, impersonated): 40 | signals.user_impersonated.send(UserModel, request=request, user=impersonated, impersonator=impersonator) 41 | return impersonated 42 | signals.user_impersonation_failed.send(UserModel, request=request, user=impersonated) 43 | 44 | 45 | class ImpersonationBackend(ImpersonationBackendMixin, ModelBackend): 46 | def __init__(self, *args, **kwargs): 47 | super(ImpersonationBackend, self).__init__(*args, **kwargs) 48 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Impersonate Auth 2 | ======================= 3 | |build| |coverage| |contributors| |license| 4 | 5 | Django Impersonate Auth is a simple drop in authentication backend that allows 6 | superusers to impersonate regular users in the system. 7 | 8 | Impersonation is handled by signing in using the target user's username and the 9 | superuser's username and password, separated by a specific character, as the 10 | password. 11 | 12 | Getting Started 13 | --------------- 14 | 15 | To install this library, you can simply run :: 16 | 17 | pip install -e git+https://github.com/JordanReiter/django-impersonate-auth.git#egg=django-impersonate-auth 18 | 19 | Then add :: 20 | 21 | 'impersonate_auth.backends.ImpersonationBackend', 22 | 23 | 24 | to the ``AUTHENTICATION_BACKENDS`` setting. If you don't currently have a value 25 | for ``AUTHENTICATION_BACKENDS``, you can add this to your settings file, which 26 | includes the default backend plus 27 | the impersonation backend (both are required to function correctly):: 28 | 29 | AUTHENTICATION_BACKENDS = [ 30 | 'django.contrib.auth.backends.ModelBackend', 31 | 'impersonate_auth.backends.ImpersonationBackend', 32 | ] 33 | 34 | Usage 35 | ----- 36 | 37 | Using the default separator, a colon, here is an example of how to impersonate 38 | another (non superuser) user: 39 | 40 | - | Normal User: 41 | | Username: ``testuser`` 42 | | Password: ``12345`` 43 | 44 | - | Super User: 45 | | Username: ``superuser`` 46 | | Password: ``987654321`` 47 | 48 | - | Super User impersonating Normal User: 49 | | Username: ``testuser`` 50 | | Password: ``superuser:987654321`` 51 | 52 | Settings 53 | -------- 54 | 55 | The default separator is a colon (`:`), but this can be changed using the 56 | ``IMPERSONATE_AUTH_SEPARATOR`` setting. The following code changes it to an 57 | exclamation mark:: 58 | 59 | IMPERSONATE_AUTH_SEPARATOR = '!' 60 | 61 | With this setting, in the example above, Super User would impersonate Normal 62 | User using the password ``superuser!987654321``. 63 | 64 | **Important**: because the username and password are separated by this character, 65 | it's essential to choose a character that would never be found in a username. 66 | For most login purposes, the colon `:` is a good choice because it is neither a 67 | legal character for a Django username or for an email address. [#email]_ 68 | 69 | Using Custom Authentication Backends 70 | ------------------------------------ 71 | 72 | The package provides a mixin, ``ImpersonationBackendMixin``, which should 73 | provide all the necessary code to implement impersonation for any authentication 74 | backend which uses a combination of username and password. The backend uses the 75 | ``USERNAME_FIELD`` property of the user model, so if you use a custom User model 76 | which uses a different field (for example, E-mail address) then it will still 77 | work correctly. 78 | 79 | Adding impersonation to your custom backend just means creating a new class that 80 | extends both your existing and ``ImpersonationBackendMixin``. For example, 81 | imagine a very insecure authentication backend called ``SillyBackend`` where the 82 | password is simply the username spelled backwards:: 83 | 84 | from django.contrib.auth.backends import ModelBackend 85 | from django.contrib.auth import get_user_model 86 | 87 | class SillyBackend(ModelBackend): 88 | ''' 89 | A silly backend where the password for a user is their email address backwards. 90 | ''' 91 | def authenticate(self, request=None, username=None, password=None): 92 | UserModel = get_user_model() 93 | try: 94 | silly_user = UserModel.objects.get_by_natural_key(username) 95 | except UserModel.DoesNotExist: 96 | silly_user = None 97 | if silly_user and password and password[::-1] == username: 98 | return silly_user 99 | 100 | 101 | In order to implement the impersonation functionality, you would add the 102 | following code to your ``backends.py`` file:: 103 | 104 | from impersonate_auth.backends import ImpersonationBackendMixin 105 | 106 | class SillyImpersonationBackend(ImpersonationBackendMixin, SillyBackend): 107 | def __init__(self, *args, **kwargs): 108 | super(SillyImpersonationBackend, self).__init__(*args, **kwargs) 109 | 110 | No other coding needed! Just make sure to add 111 | ``path.to.backends.SillyImpersonationBackend`` to your 112 | ``AUTHENTICATION_BACKENDS`` setting. 113 | 114 | 115 | Signals 116 | ------- 117 | 118 | In order to log or track impersonation, each time you log in using an 119 | impersonation login, a ``user_impersonated`` signal is sent. If it fails, a 120 | ``user_impersonation_failed`` signal is triggered. Note that these signals are 121 | only triggered if the login would have been a success with the correct login. 122 | That is, they are not triggered if the user you are trying to impersonate does 123 | not exist or would not be available for some other reason (e.g. they are 124 | inactive). 125 | 126 | Credits 127 | ------- 128 | Thanks to Daniele Faraglia and the django-environ 129 | project . Both my .travis.yml file 130 | and this readme were partially modeled on the respective files from that 131 | project. 132 | 133 | User fdemmer on Reddit pointed out that if passwords contain the separator 134 | (normally a colon) this would cause an error as the code was written. It's fixed 135 | in this version. 136 | 137 | 138 | .. [#email] Yes, colons are allowed, but only in the quoted string area of an 139 | email address. Since that's used just for display and not the actual email 140 | address, we can (hopefully) assume that users won't include it. Other 141 | characters that fall under this characters include ``(),:;<>[\]`` See RFC 3696, Section 4.1: 142 | 143 | 144 | -------- 145 | 146 | .. |coverage| image:: https://img.shields.io/coveralls/JordanReiter/django-impersonate-auth/master.svg?style=flat-square 147 | :target: https://coveralls.io/r/JordanReiter/django-impersonate-auth?branch=master 148 | :alt: Test coverage 149 | 150 | .. |build| image:: https://travis-ci.org/JordanReiter/django-impersonate-auth.svg?branch=master 151 | :target: https://travis-ci.org/JordanReiter/django-impersonate-auth 152 | 153 | .. |windows_build| image:: https://img.shields.io/appveyor/ci/JordanReiter/django-impersonate-auth.svg?style=flat-square&logo=windows 154 | :target: https://ci.appveyor.com/project/JordanReiter/django-impersonate-auth 155 | :alt: Build status of the master branch on Windows 156 | 157 | 158 | .. |contributors| image:: https://img.shields.io/github/contributors/JordanReiter/django-impersonate-auth.svg?style=flat-square 159 | :target: https://github.com/JordanReiter/django-impersonate-auth/graphs/contributors 160 | 161 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 162 | :target: https://raw.githubusercontent.com/JordanReiter/django-impersonate-auth/master/LICENSE 163 | :alt: Package license 164 | 165 | .. _`the repository`: https://github.com/JordanReiter/django-impersonate-auth 166 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | try: 4 | from unittest.mock import patch 5 | except ImportError: 6 | from mock import patch 7 | 8 | from django.conf import settings 9 | from django.contrib.auth import authenticate, get_user_model 10 | from django.contrib.auth.models import AbstractUser 11 | from django.test import TestCase, override_settings, Client, modify_settings 12 | 13 | 14 | from impersonate_auth import signals 15 | 16 | 17 | TEST_USER_NAME = str(uuid.uuid4()) 18 | TEST_USER_PW = 'NormalPassword' 19 | TEST_USER = { 20 | 'email': '{}@example.org'.format(TEST_USER_NAME), 21 | 'first_name': 'FirstNormal', 22 | 'last_name': 'LastNormal', 23 | 'password': TEST_USER_PW 24 | } 25 | 26 | TEST_SUPERUSER_NAME = 'super{}'.format(str(uuid.uuid4())[:8]) 27 | TEST_SUPERUSER_PW = 'SuperPassword' 28 | TEST_SUPERUSER = { 29 | 'email': '{}@example.org'.format(TEST_SUPERUSER_NAME), 30 | 'first_name': 'FirstNormal', 31 | 'last_name': 'LastNormal', 32 | 'password': TEST_SUPERUSER_PW 33 | } 34 | 35 | 36 | AUTH_BACKEND = 'impersonate_auth.backends.ImpersonationBackend' 37 | NORMAL_SEP = ':' 38 | ALT_SEP = '^' 39 | 40 | 41 | class BaseImpersonationBackendTest: 42 | backends = ['django.contrib.auth.backends.ModelBackend'] 43 | 44 | user_name = TEST_USER_NAME 45 | user_pw = TEST_USER_PW 46 | superuser_name = TEST_SUPERUSER_NAME 47 | superuser_pw = TEST_SUPERUSER_PW 48 | impersonation_backend = AUTH_BACKEND 49 | 50 | def setUp(self, *args, **kwargs): 51 | self.client = Client() 52 | self.user = self.create_user(self.user_name, **TEST_USER) 53 | self.superuser = self.create_superuser( 54 | self.superuser_name, TEST_SUPERUSER.get('email'), self.superuser_pw, 55 | **{kk: vv for kk, vv in TEST_SUPERUSER.items() if kk not in ('email', 'password')} 56 | ) 57 | self.patched_settings = modify_settings( 58 | AUTHENTICATION_BACKENDS={'append': self.backends}, 59 | INSTALLED_APPS={'append': 'tests'}, 60 | ) 61 | self.patched_settings.enable() 62 | signals.user_impersonated.connect(self.add_user_impersonated) 63 | signals.user_impersonation_failed.connect(self.add_user_impersonation_failed) 64 | self.received_signals = [] 65 | 66 | def add_received_signal(self, signal_name, sender, *args, **kwargs): 67 | kwargs.pop('signal', None) 68 | self.received_signals.append((signal_name, sender, args, kwargs)) 69 | 70 | def add_user_impersonated(self, *args, **kwargs): 71 | self.add_received_signal('user_impersonated', *args, **kwargs) 72 | 73 | def add_user_impersonation_failed(self, *args, **kwargs): 74 | self.add_received_signal('user_impersonation_failed', *args, **kwargs) 75 | 76 | def tearDown(self): 77 | self.patched_settings.disable() 78 | 79 | def login(self, username=None, password=None): 80 | UserModel = get_user_model() 81 | creds = { 82 | UserModel.USERNAME_FIELD: username, 83 | 'password': password 84 | } 85 | return self.client.login(**creds) 86 | 87 | def create_user(self, *args, **kwargs): 88 | UserModel = get_user_model() 89 | return UserModel.objects.create_user(*args, **kwargs) 90 | 91 | def create_superuser(self, *args, **kwargs): 92 | UserModel = get_user_model() 93 | return UserModel.objects.create_superuser(*args, **kwargs) 94 | 95 | def test_impersonation_login_normal_sep(self): 96 | impersonate_pw = '{}{}{}'.format( 97 | self.superuser_name, NORMAL_SEP, self.superuser_pw 98 | ) 99 | success = self.login(username=self.user_name, password=impersonate_pw) 100 | self.assertTrue(success) 101 | logged_in_user = authenticate(username=self.user_name, password=impersonate_pw) 102 | self.assertEqual(logged_in_user, self.user) 103 | self.assertEqual(logged_in_user.backend, self.impersonation_backend) 104 | self.assertFalse(logged_in_user.is_superuser) 105 | 106 | def test_impersonation_login_password_contains_sep(self): 107 | new_pw = self.superuser_pw[:4] + NORMAL_SEP + self.superuser_pw[4:] 108 | self.superuser.set_password(new_pw) 109 | self.superuser.save() 110 | impersonate_pw = '{}{}{}'.format( 111 | self.superuser_name, NORMAL_SEP, new_pw 112 | ) 113 | success = self.login(username=self.user_name, password=impersonate_pw) 114 | self.assertTrue(success) 115 | logged_in_user = authenticate(username=self.user_name, password=impersonate_pw) 116 | self.assertEqual(logged_in_user, self.user) 117 | 118 | def test_impersonation_wrong_password(self): 119 | impersonate_pw = '{}{}{}'.format( 120 | self.superuser_name, NORMAL_SEP, 'WRONG-PASSWORD' 121 | ) 122 | success = self.login(username=self.user_name, password=impersonate_pw) 123 | self.assertFalse(success) 124 | logged_in_user = authenticate(username=self.user_name, password=impersonate_pw) 125 | self.assertIsNone(logged_in_user) 126 | 127 | @override_settings(IMPERSONATE_AUTH_SEPARATOR=ALT_SEP) 128 | def test_impersonation_login_alt_sep(self): 129 | impersonate_pw = '{}{}{}'.format( 130 | self.superuser_name, ALT_SEP, self.superuser_pw 131 | ) 132 | success = self.login(username=self.user_name, password=impersonate_pw) 133 | self.assertTrue(success) 134 | logged_in_user = authenticate(username=self.user_name, password=impersonate_pw) 135 | self.assertEqual(logged_in_user, self.user) 136 | self.assertEqual(logged_in_user.backend, self.impersonation_backend) 137 | self.assertFalse(logged_in_user.is_superuser) 138 | 139 | def test_password_is_none(self): 140 | success = self.login(username=self.superuser_name, password=None) 141 | self.assertFalse(success) 142 | logged_in_user = authenticate(username=self.superuser_name, password=None) 143 | self.assertIsNone(logged_in_user) 144 | 145 | def test_cant_impersonate_yourself(self): 146 | impersonate_pw = '{}{}{}'.format( 147 | self.superuser_name, NORMAL_SEP, self.superuser_pw 148 | ) 149 | success = self.login(username=self.superuser_name, password=impersonate_pw) 150 | self.assertFalse(success) 151 | logged_in_user = authenticate(username=self.superuser_name, password=impersonate_pw) 152 | self.assertIsNone(logged_in_user) 153 | 154 | def test_cant_impersonate_nonexistent_user(self): 155 | impersonate_pw = '{}{}{}'.format( 156 | self.superuser_name, NORMAL_SEP, self.superuser_pw 157 | ) 158 | non_existent_username = 'nonexistent-user' 159 | success = self.login(username=non_existent_username, password=impersonate_pw) 160 | self.assertFalse(success) 161 | logged_in_user = authenticate(username=non_existent_username, password=impersonate_pw) 162 | self.assertIsNone(logged_in_user) 163 | 164 | def test_cant_impersonate_superusers(self): 165 | other_superuser = self.create_superuser( 166 | str(uuid.uuid4()), 167 | 'other.superuser@example.org', 168 | '12345678' 169 | ) 170 | impersonate_pw = '{}{}{}'.format( 171 | self.superuser_name, NORMAL_SEP, self.superuser_pw 172 | ) 173 | username = getattr(other_superuser, other_superuser.USERNAME_FIELD) 174 | success = self.login(username=username, password=impersonate_pw) 175 | self.assertFalse(success) 176 | logged_in_user = authenticate(username=username, password=impersonate_pw) 177 | self.assertIsNone(logged_in_user) 178 | 179 | def test_cant_impersonate_inactive_users(self): 180 | inactive_user = self.create_user( 181 | str(uuid.uuid4()), 182 | email='inactive.user@example.org', 183 | is_active=False 184 | ) 185 | impersonate_pw = '{}{}{}'.format( 186 | self.superuser_name, NORMAL_SEP, self.superuser_pw 187 | ) 188 | inactive_username = getattr(inactive_user, inactive_user.USERNAME_FIELD) 189 | success = self.login(username=inactive_username, password=impersonate_pw) 190 | self.assertFalse(success) 191 | logged_in_user = authenticate(username=inactive_username, password=impersonate_pw) 192 | self.assertIsNone(logged_in_user) 193 | 194 | def test_cant_impersonate_if_inactive(self): 195 | self.superuser.is_active = False 196 | self.superuser.save() 197 | impersonate_pw = '{}{}{}'.format( 198 | self.superuser_name, NORMAL_SEP, self.superuser_pw 199 | ) 200 | success = self.login(username=self.user_name, password=impersonate_pw) 201 | self.assertFalse(success) 202 | logged_in_user = authenticate(username=self.user_name, password=impersonate_pw) 203 | self.assertIsNone(logged_in_user) 204 | self.superuser.is_active = True 205 | self.superuser.save() 206 | 207 | def test_normal_users_cant_impersonate(self): 208 | other_user = self.create_user( 209 | str(uuid.uuid4()), 210 | email='other.normal.user@example.org' 211 | ) 212 | impersonate_pw = '{}{}{}'.format( 213 | self.user_name, NORMAL_SEP, self.user_pw 214 | ) 215 | other_username = getattr(other_user, other_user.USERNAME_FIELD) 216 | success = self.login(username=other_username, password=impersonate_pw) 217 | self.assertFalse(success) 218 | logged_in_user = authenticate(username=other_username, password=impersonate_pw) 219 | self.assertIsNone(logged_in_user) 220 | 221 | def test_staff_cant_impersonate(self): 222 | staff_pw = 'StaffPassword' 223 | staff_user = self.create_user( 224 | str(uuid.uuid4()), 225 | email='staff.user@example.org', 226 | password=staff_pw, 227 | is_staff=True 228 | ) 229 | self.assertTrue(staff_user.is_staff) 230 | other_user = self.create_user( 231 | str(uuid.uuid4()), 232 | 'other.nonstaff.user@example.org' 233 | ) 234 | staff_username = getattr(staff_user, staff_user.USERNAME_FIELD) 235 | other_username = getattr(other_user, other_user.USERNAME_FIELD) 236 | impersonate_pw = '{}{}{}'.format( 237 | staff_username, NORMAL_SEP, staff_pw 238 | ) 239 | success = self.login(username=other_username, password=impersonate_pw) 240 | self.assertFalse(success) 241 | logged_in_user = authenticate(username=other_username, password=impersonate_pw) 242 | self.assertIsNone(logged_in_user) 243 | 244 | def test_success_sends_signal(self): 245 | UserModel = get_user_model() 246 | impersonate_pw = '{}{}{}'.format( 247 | self.superuser_name, NORMAL_SEP, self.superuser_pw 248 | ) 249 | signal_count = len(self.received_signals) 250 | success = self.login(username=self.user_name, password=impersonate_pw) 251 | self.assertTrue(success) 252 | self.assertEqual( 253 | signal_count + 1, 254 | len(self.received_signals), 255 | msg="I expected to add one to {}, for a total of {}. Created signals: {}".format(signal_count, signal_count + 1, self.received_signals[signal_count:]) 256 | ) 257 | signal_name, sender, _, signal_kwargs = self.received_signals[-1] 258 | self.assertEqual(signal_name, 'user_impersonated') 259 | self.assertEqual(sender, UserModel) 260 | self.assertIn('user', signal_kwargs) 261 | self.assertEqual( 262 | signal_kwargs['user'], 263 | UserModel.objects.get_by_natural_key(self.user_name) 264 | ) 265 | self.assertEqual( 266 | signal_kwargs['impersonator'], 267 | UserModel.objects.get_by_natural_key(self.superuser_name) 268 | ) 269 | 270 | def test_failed_sends_signal(self): 271 | UserModel = get_user_model() 272 | impersonate_pw = '{}{}{}'.format( 273 | self.superuser_name, NORMAL_SEP, 'WRONG-PASSWORD' 274 | ) 275 | signal_count = len(self.received_signals) 276 | success = self.login(username=self.user_name, password=impersonate_pw) 277 | self.assertFalse(success) 278 | self.assertEqual( 279 | signal_count + 1, 280 | len(self.received_signals), 281 | msg="I expected to add one to {}, for a total of {}. Created signals: {}".format(signal_count, signal_count + 1, self.received_signals[signal_count:]) 282 | ) 283 | signal_name, sender, _, signal_kwargs = self.received_signals[-1] 284 | self.assertEqual(signal_name, 'user_impersonation_failed') 285 | self.assertEqual(sender, UserModel) 286 | self.assertIn('user', signal_kwargs) 287 | self.assertEqual( 288 | signal_kwargs['user'], 289 | UserModel.objects.get_by_natural_key(self.user_name) 290 | ) 291 | 292 | def test_no_user_no_signal(self): 293 | impersonate_pw = '{}{}{}'.format( 294 | self.superuser_name, NORMAL_SEP, self.superuser_pw 295 | ) 296 | signal_count = len(self.received_signals) 297 | self.login(username='userthatdoesnotexist', password=impersonate_pw) 298 | self.assertEqual( 299 | signal_count, 300 | len(self.received_signals) 301 | ) 302 | --------------------------------------------------------------------------------