├── tests ├── testapp │ ├── models.py │ ├── __init__.py │ ├── templates │ │ └── registration │ │ │ └── login.html │ ├── views.py │ ├── sixmock.py │ ├── urls.py │ └── tests.py └── setup.py ├── docs ├── .gitignore ├── license.rst ├── contributing.rst ├── quickstart.rst ├── authors.rst ├── index.rst ├── supported-versions.rst ├── extension-points.rst ├── release-notes.rst ├── Makefile └── conf.py ├── setup.cfg ├── djactasauth ├── __init__.py ├── models.py ├── util.py ├── views.py └── backends.py ├── MANIFEST.in ├── manage.py ├── setup.py ├── .gitignore ├── .github └── workflows │ └── tox.yml ├── tox.ini ├── pyproject.toml ├── LICENSE ├── .travis.yml ├── settings.py ├── tox2travis.py ├── README.rst └── Makefile /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 -------------------------------------------------------------------------------- /djactasauth/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.6.0' 2 | -------------------------------------------------------------------------------- /djactasauth/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ------- 3 | 4 | .. include:: ../LICENSE 5 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :start-after: .. contributing start 3 | :end-before: .. contributing end 4 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :start-after: .. quickstart start 3 | :end-before: .. quickstart end 4 | 5 | -------------------------------------------------------------------------------- /tests/testapp/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | {{ form }} 5 |
6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | 4 | def whoami(request): 5 | return HttpResponse(request.user.username) 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | recursive-include djactasauth *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 7 | -------------------------------------------------------------------------------- /tests/testapp/sixmock.py: -------------------------------------------------------------------------------- 1 | try: 2 | from unittest.mock import call, patch, Mock, PropertyMock, MagicMock # noqa: E501 3 | except ImportError: 4 | from mock import call, patch, Mock, PropertyMock, MagicMock # noqa: F401 5 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from djactasauth.views import PrefillLoginView 3 | from testapp.views import whoami 4 | 5 | 6 | urlpatterns = [ 7 | path(r'login/', PrefillLoginView.as_view(), {}, 'login'), 8 | path(r'whoami/', whoami), 9 | ] 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | * `Paessler AG `_ https://www.paessler.com 5 | * `Peter Zsoldos `_ http://zsoldosp.eu 6 | * `Kai Richard König `_ 7 | * `Michael Zeidler `_ 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup 4 | import os 5 | 6 | use_unsupported_django = os.environ.get('DJANGO_ACT_AS_AUTH_USE_UNSUPPORTED_DJANGO', '0') == '1' 7 | 8 | dependencies = ['Django'] if use_unsupported_django else ["Django>=4.2, <6.0"] 9 | 10 | 11 | if __name__ == "__main__": 12 | setup(install_requires=dependencies) 13 | -------------------------------------------------------------------------------- /djactasauth/util.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | import urllib.parse 3 | from djactasauth.backends import ActAsBackend 4 | 5 | 6 | def act_as_login_url(auth, act_as, **query): 7 | username = ActAsBackend.sepchar.join([auth, act_as]) 8 | return get_login_url(username=username, **query) 9 | 10 | 11 | def get_login_url(**query): 12 | return reverse('login') + '?' + urllib.parse.urlencode(query) 13 | -------------------------------------------------------------------------------- /djactasauth/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import LoginView 2 | 3 | 4 | class PrefillLoginView(LoginView): 5 | 6 | query2initial = ('username',) 7 | 8 | def get_initial(self): 9 | initial = super(PrefillLoginView, self).get_initial() 10 | field_names = set(self.query2initial) 11 | for field_name in field_names: 12 | val_from_query = self.request.GET.get(field_name, None) 13 | if val_from_query: 14 | initial.setdefault(field_name, val_from_query) 15 | return initial 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Complexity 38 | output/*.html 39 | output/*/index.html 40 | 41 | # Sphinx 42 | docs/_build 43 | no-readme-errors 44 | .travis.yml.generated 45 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Act As Auth documentation master file, created by 2 | sphinx-quickstart on Thu Mar 10 09:22:19 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Act As Auth's documentation! 7 | ============================================== 8 | 9 | .. include:: ../README.rst 10 | :start-after: .. sales pitch start 11 | :end-before: .. sales pitch end 12 | 13 | ---- 14 | 15 | Contents: 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | quickstart 21 | extension-points 22 | supported-versions 23 | license 24 | release-notes 25 | contributing 26 | authors 27 | 28 | 29 | Indices and tables 30 | ================== 31 | 32 | * :ref:`genindex` 33 | * :ref:`search` 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Tox 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install Dependencies 25 | run: | 26 | set -xu 27 | python -m pip install --upgrade pip 28 | pip install tox==4.6.3 29 | - name: Run Tests 30 | run: | 31 | tox --skip-missing-interpreters 32 | 33 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # See README.rst supported versions 2 | [tox] 3 | envlist = 4 | py{39,310,311,312}-django42, 5 | py{310,311,312,313}-django51, 6 | py{310,311,312,313}-django52, 7 | requires = 8 | tox >= 4.6.3 9 | setuptools >= 61.0.0 10 | setuptools-scm[toml] >= 5.0.0 11 | 12 | 13 | [testenv] 14 | commands = 15 | pip install -e tests 16 | make test lint docs 17 | setenv = 18 | DJANGO_SETTINGS_MODULE = settings 19 | PIP_INDEX_URL = https://pypi.python.org/simple/ 20 | deps = 21 | django42: Django>=4.2,<4.3 22 | django51: Django>=5.1,<5.2 23 | django52: Django>=5.2,<5.3 24 | py39,py310,py311: flake8==3.8.4 25 | py312: flake8==5.0 26 | py313: flake8==7.1.0 27 | # TODO: duplicated from pyproject.toml 28 | py313: setuptools>=61.0.0 29 | py313: setuptools-scm[toml]>=5.0.0 30 | docutils==0.15 31 | pyhamcrest<2.0 32 | 33 | whitelist_externals = make 34 | allowlist_externals = make 35 | 36 | [flake8] 37 | exclude = docs 38 | -------------------------------------------------------------------------------- /docs/supported-versions.rst: -------------------------------------------------------------------------------- 1 | Supported Versions 2 | ================== 3 | 4 | Version Numbers 5 | --------------- 6 | 7 | The project is versioned in the spirit of `Semantic Versioning`_. 8 | Note however that currently it's pre 1.0, thus `minor` version 9 | changes can be backwards incompatible. I.e.: ``0.1.3`` and ``0.1.2`` 10 | are compatible, but ``0.2.0`` and ``0.1.3`` are not. 11 | 12 | Django Versions Support Philosophy 13 | ---------------------------------- 14 | 15 | The project aims to support the versions Django itself supports. 16 | 17 | Supported version of djactasauth 18 | -------------------------------- 19 | 20 | The project itself has only a single supported version, that is the latest 21 | stable release. 22 | 23 | I.e.: bugfixes are not backported, i.e.: if the current stable release is ``1.2.3``, 24 | but the bug applies to all versions since ``0.1.2``, the bug will only be fixed in 25 | ``1.2.4``. 26 | 27 | Supported Django and Python versions 28 | ------------------------------------ 29 | 30 | See ``tox.ini``. 31 | 32 | .. include:: ../tox.ini 33 | :start-after: [tox] 34 | :end-before: [testenv] 35 | 36 | 37 | .. _Semantic Versioning: http://semver.org/ 38 | -------------------------------------------------------------------------------- /tests/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | 6 | try: 7 | from setuptools import setup 8 | except ImportError: 9 | from distutils.core import setup 10 | 11 | if sys.argv[-1] in ('publish', 'release'): 12 | raise Exception('this is a test app, do not release it!') 13 | 14 | readme = 'A simple test application to test djactasauth' 15 | 16 | setup( 17 | name='testapp', 18 | version='0.0.0', 19 | description=readme, 20 | long_description=readme, 21 | author='Paessler AG', 22 | url='https://github.com/PaesslerAG/django-act-as-auth', 23 | packages=[ 24 | 'testapp', 25 | ], 26 | include_package_data=True, 27 | install_requires=[ 28 | ], 29 | license="BSD", 30 | zip_safe=False, 31 | keywords='django-act-as-auth', 32 | classifiers=[ 33 | 'Development Status :: 2 - Pre-Alpha', 34 | 'Framework :: Django', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: BSD License', 37 | 'Natural Language :: English', 38 | 'Programming Language :: Python :: 2', 39 | 'Programming Language :: Python :: 2.7', 40 | 'Programming Language :: Python :: 3', 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "setuptools-scm[toml]>=5.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "djactasauth" 7 | dynamic = ["version", "readme", "dependencies"] 8 | authors = [{ name = "Peter Zsoldos", email = "hello@zsoldosp.eu" }] 9 | maintainers = [{ name = "Peter Zsoldos", email = "hello@zsoldosp.eu" }] 10 | description = "Django authentication backend allowing admins to login as another user." 11 | requires-python = ">=3.9" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: BSD License", 16 | "Operating System :: OS Independent", 17 | "Natural Language :: English", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Framework :: Django", 24 | "Framework :: Django :: 4.2", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | ] 28 | 29 | [project.urls] 30 | "Homepage" = "https://github.com/zsoldosp/django-django-act-as-auth" 31 | "Bug Tracker" = "https://github.com/zsoldosp/django-django-act-as-auth/issues" 32 | 33 | [tool.setuptools] 34 | packages = ["djactasauth"] 35 | 36 | [tool.setuptools.dynamic] 37 | version = { attr = "djactasauth.__version__" } 38 | readme = { file = ["README.rst"] } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Paessler AG 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of djactasauth nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | before_install: 4 | - sudo apt-get -qq update 5 | - sudo apt-get install -y make sed 6 | install: 7 | - pip install tox 8 | matrix: 9 | include: 10 | - python: "2.7" 11 | env: TOX_ENVS=py27-django111 12 | - python: "3.5" 13 | env: TOX_ENVS=py35-django111,py35-django21,py35-django22 14 | - python: "3.6" 15 | env: TOX_ENVS=py36-django111,py36-django21,py36-django22 16 | - python: "3.7" 17 | env: TOX_ENVS=py37-django111,py37-django21,py37-django22 18 | script: 19 | - tox -e $TOX_ENVS 20 | before_deploy: "make clean" 21 | deploy: 22 | provider: pypi 23 | user: "paessler_bis" 24 | password: 25 | secure: "N+ark/iPL3Y8/ew9i3y0IQITVFRN6AQmUirG6YBIaNt5jw+N081qYuPtco7AwcbOWBiYz9JX6k6xfFcWGw0+bDUkpGZQkVgBdqAaQUj6kEzpyNU5vsyHY7jqBAYdkjReTXf7s+ZtNCIs/qLuhgipYIOEwCtv5cUkC5WMFa1/wIWKq7LwkS6TmrjbFxC0+fXna9xwa6hdUSkz3t0B8d81tEln5TbJovlViJObM1GqxQPrU8UUoGvdOWzaTdLLWB70Z1M70Gy+XwPba+Ce6tJsRzoKpELCEYuNyTPivPAbNmzqpUB+LzBNg90X7WPO2cfI1mlHBOOV1l8ogac/wEJvxQyNMg08z07JQUJfg6sbBsQMSc7EWj46owCnvvPZ6xQ+wkz3h+HEwYTxTFuoO/9/2LIpXvMqmO6n7WJ/jpBlJA/2ejWX1Eb8EXWBsNm6/8EVZLz5JSnFhyxZ6XxB83rsGGSGtQy4CR2JZisies8RqWNATF+6UGXd4ydPi9WXr4/BsPRMnObWAZrFUzzqgoLpkKQ/YTSqn55Id0PfL9veCWVwrWel5fvBB8Pkad3eG+VVhszgTzlcQyni8hZbI1StCpLIjGqWRxhp7F7fPBs+MBUKTX7P/wrCwAhn67i54rzvdc0Tk1BQuBVa7b6T+o1dO9xtMnz811Pj817bhF9oVvM=" 26 | distributions: sdist bdist_wheel 27 | on: 28 | tags: true 29 | branch: master 30 | skip_existing: true 31 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for autodata project. 2 | import django 3 | 4 | DEBUG = True 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | } 10 | } 11 | 12 | # Make this unique, and don't share it with anybody. 13 | SECRET_KEY = 'mq%31q+sjj^)m^tvy(klwqw6ksv7du2yzdf9yn78iga*r%8w^t-djactasauth' 14 | 15 | INSTALLED_APPS = ( 16 | 'django.contrib.sessions', 17 | 'django.contrib.auth', 18 | 'django.contrib.contenttypes', 19 | 'djactasauth', 20 | 'testapp', 21 | ) 22 | 23 | MIDDLEWARE = [ 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 26 | ] 27 | 28 | STATIC_URL = '/static/' 29 | 30 | ROOT_URLCONF = 'testapp.urls' 31 | 32 | TEMPLATES = [ 33 | { 34 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 35 | 'DIRS': [], 36 | 'APP_DIRS': True, 37 | 'OPTIONS': { 38 | 'context_processors': [ 39 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 40 | # list if you haven't customized them: 41 | 'django.contrib.auth.context_processors.auth', 42 | 'django.template.context_processors.debug', 43 | 'django.template.context_processors.i18n', 44 | 'django.template.context_processors.media', 45 | 'django.template.context_processors.static', 46 | 'django.template.context_processors.tz', 47 | 'django.contrib.messages.context_processors.messages', 48 | ], 49 | }, 50 | }, 51 | ] 52 | -------------------------------------------------------------------------------- /docs/extension-points.rst: -------------------------------------------------------------------------------- 1 | Extension points 2 | ================ 3 | 4 | Authentication Backends 5 | ----------------------- 6 | 7 | FilteredModelBackend 8 | .................... 9 | 10 | If a subclass of ``djactasauth.backends.FilteredModelBackend`` has a class 11 | or instance level ``filter_kwargs`` field, then those filters would be applied 12 | in the ``FilteredModelBackend.get_user`` method. 13 | 14 | If there is no such field, it's ignored, and the behaviour is the same 15 | as its parent, ``django.contrib.auth.backends.ModelBackend``. 16 | 17 | An empty dictionary (``{}``) is also a valid value for filters, again, 18 | the behavior is the same as if no such field was specifiec. 19 | 20 | ``ActAsBackend`` 21 | ..................... 22 | 23 | You can have precise control over which user can act as which other kind 24 | of user, by subclassing ``djactasauth.backends.ActAsBackend``, and describing your policy 25 | by overwriting the ``can_act_as(self, auth_user, user)`` method. For an 26 | example, see ``djactasauth.backends.OnlySuperuserCanActAsBackend``. 27 | 28 | ``ActAsBackend`` by default doesn't allow anyone to act-as, so there 29 | is no chance for misconfiguration. 30 | 31 | 32 | Views 33 | ----- 34 | 35 | ``act_as_login_view`` 36 | ..................... 37 | 38 | 39 | You can extend ``djactasauth.views.act_as_login_view`` through the 40 | standard ``kwargs``, as you would extend 41 | ``django.contrib.auth.views.login``, or you can create your own view 42 | method that eventually delegates to it - the same way this implementation 43 | does for Django's own :-) 44 | 45 | Forms 46 | ----- 47 | 48 | ``get_login_form`` 49 | .................. 50 | 51 | ``djactasauth.views.get_login_form`` 52 | 53 | This is used by ``djactasauth.views.act_as_login_view``. On the one hand, 54 | it backports a Django 1.6 feature to 1.5 (pass in ``request`` as an argument 55 | to the form), and if needed, it mixes in 56 | ``djactasauth.forms.InitialValuesFromRequestGetFormMixin``, so the username 57 | can be prefilled for act-as-auth links from the ``GET`` request. 58 | 59 | ``InitialValuesFromRequestGetFormMixin`` 60 | ........................................ 61 | 62 | ``djactasauth.forms.InitialValuesFromRequestGetFormMixin`` is a 63 | ``Form`` mixin, which - given one of its super`s has initialized 64 | the form's ``self.request``, will got through ``self.request.GET``, and 65 | copy over the values to ``self.initial`` - unless ``self.initial`` already 66 | has a value for the given field names you declared in your class's 67 | ``query2initial`` property (``tuple``). 68 | 69 | This is needed for a feature here, but you might find it useful in other 70 | parts of your code too :-) 71 | 72 | Other 73 | ----- 74 | 75 | ``djactasauth.util.act_as_login_url`` 76 | ..................................... 77 | 78 | Convenience method to encapsulate how the act as auth username should be 79 | constructed from the two usernames. 80 | -------------------------------------------------------------------------------- /docs/release-notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | 0.6.0 5 | ----- 6 | 7 | * version upgrade release 8 | * drop support for Django 1.11, 2.1, 2.2 9 | * add support for Django 4.2, 5.1, 5.2 10 | * drop support for Python 2.7, 3.5, 3.6, 3.7 11 | * add support for Python 3.9, 3.10, 3.11, 3.12, 3.13 12 | 13 | 0.3.0 14 | ----- 15 | 16 | * drop support for deprecated python and django versions 17 | * conform to latest flake8 18 | 19 | 0.2.1 20 | ----- 21 | 22 | * bugfix: #12 - won't crash if username contains multiple act as auth 23 | sepchars (e.g.: ``admin/user/`` (note the trailing slash) 24 | * bugfix: #13 - wrapping ``act_as_auth_view`` in ``sensitive_post_parameters`` 25 | 26 | 0.2.0 27 | ----- 28 | 29 | * BACKWARDS INCOMPATIBLE: not inheriting from ``ModelBackend``, 30 | but rather working in addition to the existing 31 | ``settings.AUTHENTICATION_BACKENDS`` 32 | * BACKWARDS INCOMPATIBLE: only one act-as-auth backend can be 33 | configured for ``settings.AUTHENTICATION_BACKENDS`` 34 | 35 | 0.1.7 36 | ----- 37 | 38 | * add support for Django 1.11 and thus python 3.6 39 | 40 | 0.1.6 41 | ----- 42 | 43 | * add support for Django 1.10 44 | 45 | 0.1.5 46 | ----- 47 | 48 | * fix ``description`` on https://pypi.python.org 49 | 50 | 0.1.4 51 | ----- 52 | 53 | * first public release to pypi 54 | * fixed ``README.rst`` to look OK on https://pypi.python.org 55 | 56 | 0.1.3 57 | ----- 58 | 59 | * explicitly add support for Django 1.6 and 1.7 60 | * use Django's own bundled ``six`` instead of installing the external version 61 | * explicity add support for Django's own supported Python version, i.e.: 62 | Python 3.3 and 3.5 too (dropped 3.2 support as the travis build failed 63 | during setup) 64 | 65 | 0.1.2 66 | ----- 67 | 68 | * introduce 69 | 70 | * ``act_as_login_view`` 71 | * ``act_as_login_url`` 72 | * ``get_login_form`` 73 | * ``InitialValuesFromRequestGetFormMixin`` 74 | 75 | as part of the public api 76 | 77 | * "backport" to Django 1.5: ``authentication_form`` has ``request`` even 78 | on ``POST`` 79 | * can prefill ``username`` from query string 80 | * bugfix: when user to act as is ``None``, don't crash the process (e.g.: 81 | when ``can_act_as`` checked some property of the user, thus generating 82 | an ``AttributeError``) 83 | 84 | 0.1.1 85 | ----- 86 | 87 | * bugfix: ``ActAsModelBackend.is_act_as_username`` used to fail when 88 | ``username`` argument was ``None``, now it returns ``False`` 89 | * explicitly regression testing for login redirecting to 90 | value provided in ``REDIRECT_FIELD_NAME`` 91 | * bugfix: ``setup.py`` now lists its dependencies (and added ``six``) 92 | 93 | 0.1.0 94 | ----- 95 | 96 | * initial release 97 | * supports Django 1.5, 1.8 and 1.9 on python 2.7 and 3.4 98 | * introduce ``FilteredModelBackend``, ``ActAsModelBackend``, 99 | and ``OnlySuperuserCanActAsModelBackend`` 100 | -------------------------------------------------------------------------------- /tox2travis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import subprocess 4 | 5 | 6 | class ToxToTravis: 7 | 8 | def __init__(self, cwd): 9 | self.cwd = cwd 10 | 11 | def parse_tox(self): 12 | proc = subprocess.Popen( 13 | "tox -l", shell=True, stdout=subprocess.PIPE, cwd=self.cwd, 14 | universal_newlines=True) 15 | self.tox_lines = proc.stdout.read().strip().split('\n') 16 | self.parse_python_versions() 17 | 18 | def parse_python_versions(self): 19 | tox_pys = set([]) 20 | djangos = set([]) 21 | tox_py_to_djangos = {} 22 | for tox_line in self.tox_lines: 23 | py, env = tox_line.split('-') 24 | tox_pys.add(py) 25 | djangos.add(env) 26 | tox_py_to_djangos.setdefault(py, []) 27 | tox_py_to_djangos[py].append(env) 28 | 29 | self.djangos = sorted(djangos) 30 | self.tox_pys = sorted(tox_pys) 31 | self.tox_py_to_djangos = tox_py_to_djangos 32 | 33 | def write_travis(self): 34 | lines = self.setup_python() + self.matrix() + self.test_command() 35 | print('\n'.join(lines)) 36 | 37 | def setup_python(self): 38 | return [ 39 | 'dist: xenial', 40 | 'language: python', 41 | 'before_install:', 42 | ' - sudo apt-get -qq update', 43 | ' - sudo apt-get install -y make sed', 44 | 'install:', 45 | ' - pip install tox', 46 | ] 47 | 48 | def matrix(self): 49 | self.tox2travis_py = dict( 50 | py27='2.7', 51 | py35='3.5', 52 | py36='3.6', 53 | py37='3.7', 54 | ) 55 | output = [ 56 | 'matrix:', 57 | ' include:', 58 | ] 59 | for tox_py, djangos in sorted(self.tox_py_to_djangos.items()): 60 | tox_envs_gen = ('-'.join((tox_py, d)) for d in sorted(djangos)) 61 | item = [ 62 | ' - python: "%s"' % self.tox2travis_py[tox_py], 63 | ' env: TOX_ENVS=%s' % ','.join(tox_envs_gen), 64 | ] 65 | output += item 66 | return output 67 | 68 | def test_command(self): 69 | return [ 70 | 'script:', 71 | ' - tox -e $TOX_ENVS', 72 | 'before_deploy: "make clean"', 73 | 'deploy:', 74 | ' provider: pypi', 75 | ' user: "paessler_bis"', 76 | ' password:', 77 | ' secure: "N+ark/iPL3Y8/ew9i3y0IQITVFRN6AQmUirG6YBIaNt5jw+N081qYuPtco7AwcbOWBiYz9JX6k6xfFcWGw0+bDUkpGZQkVgBdqAaQUj6kEzpyNU5vsyHY7jqBAYdkjReTXf7s+ZtNCIs/qLuhgipYIOEwCtv5cUkC5WMFa1/wIWKq7LwkS6TmrjbFxC0+fXna9xwa6hdUSkz3t0B8d81tEln5TbJovlViJObM1GqxQPrU8UUoGvdOWzaTdLLWB70Z1M70Gy+XwPba+Ce6tJsRzoKpELCEYuNyTPivPAbNmzqpUB+LzBNg90X7WPO2cfI1mlHBOOV1l8ogac/wEJvxQyNMg08z07JQUJfg6sbBsQMSc7EWj46owCnvvPZ6xQ+wkz3h+HEwYTxTFuoO/9/2LIpXvMqmO6n7WJ/jpBlJA/2ejWX1Eb8EXWBsNm6/8EVZLz5JSnFhyxZ6XxB83rsGGSGtQy4CR2JZisies8RqWNATF+6UGXd4ydPi9WXr4/BsPRMnObWAZrFUzzqgoLpkKQ/YTSqn55Id0PfL9veCWVwrWel5fvBB8Pkad3eG+VVhszgTzlcQyni8hZbI1StCpLIjGqWRxhp7F7fPBs+MBUKTX7P/wrCwAhn67i54rzvdc0Tk1BQuBVa7b6T+o1dO9xtMnz811Pj817bhF9oVvM="', # noqa: E501 78 | ' distributions: sdist bdist_wheel', 79 | ' on:', 80 | ' tags: true', 81 | ' branch: master', 82 | ' skip_existing: true', 83 | ] 84 | 85 | 86 | def main(): 87 | cwd = os.path.abspath(os.path.dirname(__file__)) 88 | ttt = ToxToTravis(cwd) 89 | ttt.parse_tox() 90 | ttt.write_travis() 91 | 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Act As Auth Backend 2 | ========================== 3 | 4 | .. sales pitch start 5 | 6 | Django authentication back-end that allows one to login as someone else 7 | (an existing Django user allowed to login) without having to know their 8 | password. 9 | 10 | Great for customer support and testing scenarios! 11 | 12 | .. sales pitch end 13 | 14 | .. image:: https://readthedocs.org/projects/django-act-as-auth/badge/?version=latest 15 | :target: http://django-act-as-auth.readthedocs.org/ 16 | 17 | .. quickstart start 18 | 19 | Quickstart 20 | ---------- 21 | 22 | Install ``djactasauth``:: 23 | 24 | pip install djactasauth 25 | 26 | Add it to your auth backends in ``settings``:: 27 | 28 | import djactasauth 29 | AUTHENTICATION_BACKENDS = ( 30 | ..., 31 | 'djactasauth.backends.OnlySuperuserCanActAsBackend', 32 | ..., 33 | ) 34 | 35 | Configure the custom login view to take advantage of all the features 36 | in your ``urls.py``:: 37 | 38 | from django.conf.urls import patterns, url 39 | from djactasauth.views import PrefillLoginView 40 | from testapp.views import whoami 41 | 42 | 43 | urlpatterns = patterns( 44 | '', 45 | url(r'^login/$', PrefillLoginView.as_view(), {}, 'login'), 46 | ) 47 | 48 | 49 | Then you can log in with username ``your_superuser_name/customer`` and password 50 | ``yourpassword``. 51 | 52 | The full `documentation `_ including release notes on read the docs. 53 | .. quickstart end 54 | 55 | 56 | .. contributing start 57 | 58 | Contributing 59 | ------------ 60 | 61 | As an open source project, we welcome contributions. 62 | 63 | The code lives on `github `_. 64 | 65 | Reporting issues/improvements 66 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 67 | 68 | Please open an `issue on github `_ 69 | or provide a `pull request `_ 70 | whether for code or for the documentation. 71 | 72 | For non-trivial changes, we kindly ask you to open an issue, as it might be rejected. 73 | However, if the diff of a pull request better illustrates the point, feel free to make 74 | it a pull request anyway. 75 | 76 | Pull Requests 77 | ~~~~~~~~~~~~~ 78 | 79 | * for code changes 80 | 81 | * it must have tests covering the change. You might be asked to cover missing scenarios 82 | * the latest ``flake8`` will be run and shouldn't produce any warning 83 | * if the change is significant enough, documentation has to be provided 84 | 85 | * if you are not there already, add yourself to the `Authors `_ file 86 | 87 | To trigger packaging, run `make release` on the master branch. 88 | 89 | Setting up all Python versions 90 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 91 | 92 | :: 93 | 94 | sudo apt-get -y install software-properties-common 95 | sudo add-apt-repository ppa:fkrull/deadsnakes 96 | sudo apt-get update 97 | for version in 3.9 3.10 3.11 3.12 3.13; do 98 | py=python$version 99 | if ! which ${py}; then 100 | sudo apt-get -y install ${py} ${py}-dev 101 | fi 102 | done 103 | sudo add-apt-repository --remove ppa:deadsnakes/ppa 104 | sudo apt-get update 105 | 106 | Code of Conduct 107 | ~~~~~~~~~~~~~~~ 108 | 109 | As it is a Django extension, it follows 110 | `Django's own Code of Conduct `_. 111 | As there is no mailing list yet, please just email one of the main authors 112 | (see ``setup.py`` file) 113 | 114 | 115 | .. contributing end 116 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-python clean-build docs clean-tox 2 | #PYPI_SERVER?=pypi 3 | PYPI_SERVER?=testpypi 4 | ifeq ($(PYPI_SERVER),testpypi) 5 | TWINE_PASSWORD=${TEST_TWINE_PASSWORD} 6 | else 7 | TWINE_PASSWORD=${ACTASAUTH_TWINE_PASSWORD} 8 | endif 9 | RELEASE_PYTHON=python3.13 10 | RELEASE_VENV=release-venv-${RELEASE_PYTHON} 11 | RELEASE_PYTHON_ACTIVATE=${RELEASE_VENV}/bin/activate 12 | GIT_REMOTE_NAME?=origin 13 | SHELL=/bin/bash 14 | PACKAGE_NAME=djactasauth 15 | VERSION=$(shell ${RELEASE_PYTHON} -c"import ${PACKAGE_NAME} as m; print(m.__version__)") 16 | PACKAGE_FILE_TGZ=dist/${PACKAGE_NAME}-${VERSION}.tar.gz 17 | PACKAGE_FILE_WHL=dist/${PACKAGE_NAME}-${VERSION}-py3-none-any.whl 18 | 19 | help: 20 | @echo "clean-build - remove build artifacts" 21 | @echo "clean-python - remove Python file artifacts" 22 | @echo "clean-tox - remove test artifacts" 23 | @echo "lint - check style with flake8" 24 | @echo "test - run tests quickly with the default Python" 25 | @echo "test-all - run tests on every Python version with tox" 26 | @echo "coverage - check code coverage quickly with the default Python" 27 | @echo "docs - generate Sphinx HTML documentation, including API docs" 28 | @echo "tag - git tag the current version which creates a new pypi package with travis-ci's help" 29 | @echo "package- build the sdist/wheel" 30 | @echo "release- package, tag, and publush" 31 | 32 | clean: clean-build clean-python clean-tox 33 | 34 | clean-build: 35 | rm -fr build/ 36 | rm -fr dist/ 37 | find -name *.egg-info -type d | xargs rm -rf 38 | 39 | clean-python: 40 | find . -name '*.pyc' -exec rm -f {} + 41 | find . -name '*.pyo' -exec rm -f {} + 42 | find . -name '*~' -exec rm -f {} + 43 | find . -name '__pycache__' -type d -exec rm -rf {} + 44 | 45 | clean-tox: 46 | if [[ -d .tox ]]; then rm -r .tox; fi 47 | 48 | lint: 49 | flake8 ${PACKAGE_NAME} tests --max-complexity=10 50 | 51 | test: 52 | python manage.py test testapp --traceback 53 | 54 | test-all: clean-tox 55 | tox 56 | 57 | coverage: 58 | coverage run --source ${PACKAGE_NAME} setup.py test 59 | coverage report -m 60 | coverage html 61 | open htmlcov/index.html 62 | 63 | docs: outfile=/tmp/readme-errors 64 | docs: 65 | rst2html.py README.rst > /dev/null 2> ${outfile} 66 | cat ${outfile} 67 | test 0 -eq `cat ${outfile} | wc -l` 68 | 69 | tag: TAG:=v${VERSION} 70 | tag: exit_code=$(shell git ls-remote ${GIT_REMOTE_NAME} | grep -q tags/${TAG}; echo $$?) 71 | tag: 72 | ifeq ($(exit_code),0) 73 | @echo "Tag ${TAG} already present" 74 | else 75 | @echo "git tag -a ${TAG} -m"${TAG}"; git push --tags ${GIT_REMOTE_NAME}" 76 | endif 77 | 78 | ${RELEASE_VENV}: 79 | virtualenv --python ${RELEASE_PYTHON} ${RELEASE_VENV} 80 | 81 | build-deps: ${RELEASE_VENV} 82 | source ${RELEASE_PYTHON_ACTIVATE} && python -m pip install --upgrade build 83 | source ${RELEASE_PYTHON_ACTIVATE} && python -m pip install --upgrade twine 84 | 85 | 86 | ${PACKAGE_FILE_TGZ}: ${PACKAGE_NAME}/ pyproject.toml Makefile setup.py setup.cfg 87 | ${PACKAGE_FILE_WHL}: ${PACKAGE_NAME}/ pyproject.toml Makefile setup.py setup.cfg 88 | source ${RELEASE_PYTHON_ACTIVATE} && python -m build 89 | 90 | package: build-deps clean-build clean-python ${PACKAGE_FILE} ${PACKAGE_FILE_WHL} 91 | 92 | 93 | release: package 94 | ifeq ($(TWINE_PASSWORD),) 95 | echo TWINE_PASSWORD empty 96 | echo "USE env vars TEST_TWINE_PASSWORD/ACTASAUTH_TWINE_PASSWORD env vars before invoking make" 97 | false 98 | endif 99 | twine check dist/* 100 | echo "if the release fails, setup a ~/pypirc file as per https://packaging.python.org/en/latest/tutorials/packaging-projects/" 101 | # env | grep TWINE 102 | source ${RELEASE_PYTHON_ACTIVATE} && TWINE_PASSWORD=${TWINE_PASSWORD} python -m twine upload --repository ${PYPI_SERVER} dist/* --verbose 103 | -------------------------------------------------------------------------------- /djactasauth/backends.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | from django.contrib.auth.backends import ModelBackend 5 | from django.contrib import auth 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class FilteredModelBackend(ModelBackend): 11 | def get_user(self, user_id): 12 | user = super(FilteredModelBackend, self).get_user(user_id) 13 | return self.filter_user(user) 14 | 15 | def authenticate(self, request, **kwargs): 16 | user = super(FilteredModelBackend, self).authenticate( 17 | request=request, **kwargs) 18 | return self.filter_user(user) 19 | 20 | def filter_user(self, user): 21 | if not user: 22 | return user 23 | filters = getattr(self, 'filter_kwargs', None) 24 | if filters: 25 | qs = type(user)._default_manager.filter( 26 | pk=user.pk).filter(**filters) 27 | if not qs.exists(): 28 | return None 29 | return user 30 | 31 | 32 | class ActAsBackend(object): 33 | 34 | sepchar = '/' 35 | too_many_sepchar_msg = 'Username holds more than one separation char "{}"'\ 36 | '.'.format(sepchar) 37 | 38 | @classmethod 39 | def is_act_as_username(cls, username): 40 | if not username: 41 | return False 42 | if username.count(ActAsBackend.sepchar) > 1: 43 | log.warn(cls.too_many_sepchar_msg) 44 | return False 45 | return cls.sepchar in username 46 | 47 | def authenticate(self, request, username=None, password=None, **kwargs): 48 | self.fail_unless_one_aaa_backend_is_configured() 49 | assert password is not None 50 | if not self.is_act_as_username(username): 51 | return None 52 | auth_username, act_as_username = username.split(self.sepchar) 53 | backends = [b for b in auth.get_backends() if not 54 | isinstance(b, ActAsBackend)] 55 | for backend in backends: 56 | auth_user = backend.authenticate( 57 | request=request, username=auth_username, password=password, 58 | **kwargs) 59 | if auth_user: 60 | return self.get_act_as_user( 61 | auth_user=auth_user, act_as_username=act_as_username) 62 | 63 | def fail_unless_one_aaa_backend_is_configured(self): 64 | aaa_backends = list( 65 | type(backend) for backend in auth.get_backends() 66 | if isinstance(backend, ActAsBackend)) 67 | if len(aaa_backends) != 1: 68 | raise ValueError( 69 | 'There should be exactly one AAA backend configured, ' 70 | 'but there were {}'.format(aaa_backends)) 71 | 72 | def get_act_as_user(self, auth_user, act_as_username): 73 | if auth_user.username != act_as_username: 74 | UserModel = auth.get_user_model() 75 | try: 76 | user = self._get_user_manager().get_by_natural_key( 77 | act_as_username) 78 | except UserModel.DoesNotExist: 79 | return None 80 | if not self.can_act_as(auth_user=auth_user, user=user): 81 | return None 82 | else: 83 | user = auth_user 84 | return user 85 | 86 | def _get_user_manager(self): 87 | UserModel = auth.get_user_model() 88 | return UserModel._default_manager 89 | 90 | def can_act_as(self, auth_user, user): 91 | return False 92 | 93 | def get_user(self, user_id): 94 | return self._get_user_manager().get(pk=user_id) 95 | 96 | 97 | class OnlySuperuserCanActAsBackend(ActAsBackend): 98 | def can_act_as(self, auth_user, user): 99 | return auth_user.is_superuser and not user.is_superuser 100 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -nWT -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoActAsAuth.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoActAsAuth.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoActAsAuth" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoActAsAuth" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Act As Auth documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Mar 10 09:22:19 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = [] 35 | 36 | # The suffix(es) of source filenames. 37 | # You can specify multiple suffix as a list of string: 38 | # source_suffix = ['.rst', '.md'] 39 | source_suffix = '.rst' 40 | 41 | # The encoding of source files. 42 | #source_encoding = 'utf-8-sig' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = u'Django Act As Auth' 49 | copyright = u'2016, Paessler AG' 50 | author = u'Paessler AG' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = u'0.1.3' 58 | # The full version, including alpha/beta/rc tags. 59 | release = u'0.1.3' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # There are two options for replacing |today|: either, you set today to some 69 | # non-false value, then it is used: 70 | #today = '' 71 | # Else, today_fmt is used as the format for a strftime call. 72 | #today_fmt = '%B %d, %Y' 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | exclude_patterns = ['_build'] 77 | 78 | # The reST default role (used for this markup: `text`) to use for all 79 | # documents. 80 | #default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | #add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | #add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | #show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = 'sphinx' 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | #modindex_common_prefix = [] 98 | 99 | # If true, keep warnings as "system message" paragraphs in the built documents. 100 | #keep_warnings = False 101 | 102 | # If true, `todo` and `todoList` produce output, else they produce nothing. 103 | todo_include_todos = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'alabaster' 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | #html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | #html_theme_path = [] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | #html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | #html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | #html_logo = None 130 | 131 | # The name of an image file (relative to this directory) to use as a favicon of 132 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | #html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | html_static_path = [] 140 | 141 | # Add any extra paths that contain custom files (such as robots.txt or 142 | # .htaccess) here, relative to this directory. These files are copied 143 | # directly to the root of the documentation. 144 | #html_extra_path = [] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | #html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | #html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | #html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | #html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | #html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | #html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | #html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | #html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | #html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | #html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | #html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | #html_file_suffix = None 186 | 187 | # Language to be used for generating the HTML full-text search index. 188 | # Sphinx supports the following languages: 189 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 190 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 191 | #html_search_language = 'en' 192 | 193 | # A dictionary with options for the search language support, empty by default. 194 | # Now only 'ja' uses this config value 195 | #html_search_options = {'type': 'default'} 196 | 197 | # The name of a javascript file (relative to the configuration directory) that 198 | # implements a search results scorer. If empty, the default will be used. 199 | #html_search_scorer = 'scorer.js' 200 | 201 | # Output file base name for HTML help builder. 202 | htmlhelp_basename = 'DjangoActAsAuthdoc' 203 | 204 | # -- Options for LaTeX output --------------------------------------------- 205 | 206 | latex_elements = { 207 | # The paper size ('letterpaper' or 'a4paper'). 208 | #'papersize': 'letterpaper', 209 | 210 | # The font size ('10pt', '11pt' or '12pt'). 211 | #'pointsize': '10pt', 212 | 213 | # Additional stuff for the LaTeX preamble. 214 | #'preamble': '', 215 | 216 | # Latex figure (float) alignment 217 | #'figure_align': 'htbp', 218 | } 219 | 220 | # Grouping the document tree into LaTeX files. List of tuples 221 | # (source start file, target name, title, 222 | # author, documentclass [howto, manual, or own class]). 223 | latex_documents = [ 224 | (master_doc, 'DjangoActAsAuth.tex', u'Django Act As Auth Documentation', 225 | u'Paessler AG', 'manual'), 226 | ] 227 | 228 | # The name of an image file (relative to this directory) to place at the top of 229 | # the title page. 230 | #latex_logo = None 231 | 232 | # For "manual" documents, if this is true, then toplevel headings are parts, 233 | # not chapters. 234 | #latex_use_parts = False 235 | 236 | # If true, show page references after internal links. 237 | #latex_show_pagerefs = False 238 | 239 | # If true, show URL addresses after external links. 240 | #latex_show_urls = False 241 | 242 | # Documents to append as an appendix to all manuals. 243 | #latex_appendices = [] 244 | 245 | # If false, no module index is generated. 246 | #latex_domain_indices = True 247 | 248 | 249 | # -- Options for manual page output --------------------------------------- 250 | 251 | # One entry per manual page. List of tuples 252 | # (source start file, name, description, authors, manual section). 253 | man_pages = [ 254 | (master_doc, 'djangoactasauth', u'Django Act As Auth Documentation', 255 | [author], 1) 256 | ] 257 | 258 | # If true, show URL addresses after external links. 259 | #man_show_urls = False 260 | 261 | 262 | # -- Options for Texinfo output ------------------------------------------- 263 | 264 | # Grouping the document tree into Texinfo files. List of tuples 265 | # (source start file, target name, title, author, 266 | # dir menu entry, description, category) 267 | texinfo_documents = [ 268 | (master_doc, 'DjangoActAsAuth', u'Django Act As Auth Documentation', 269 | author, 'DjangoActAsAuth', 'One line description of project.', 270 | 'Miscellaneous'), 271 | ] 272 | 273 | # Documents to append as an appendix to all manuals. 274 | #texinfo_appendices = [] 275 | 276 | # If false, no module index is generated. 277 | #texinfo_domain_indices = True 278 | 279 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 280 | #texinfo_show_urls = 'footnote' 281 | 282 | # If true, do not generate a @detailmenu in the "Top" node's menu. 283 | #texinfo_no_detailmenu = False 284 | -------------------------------------------------------------------------------- /tests/testapp/tests.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | from django.contrib.auth.backends import ModelBackend 3 | from django.contrib.auth.models import User 4 | from django.contrib.auth import signals as auth_signals, REDIRECT_FIELD_NAME 5 | from django.contrib.auth.forms import AuthenticationForm 6 | from django.test import TransactionTestCase 7 | from django.test.utils import override_settings 8 | 9 | from djactasauth.backends import \ 10 | FilteredModelBackend, ActAsBackend, OnlySuperuserCanActAsBackend 11 | from djactasauth.util import act_as_login_url, get_login_url 12 | from testapp.sixmock import patch, call 13 | 14 | 15 | def create_user( 16 | username, password='password', is_superuser=False, is_staff=False): 17 | user = User(username=username, is_superuser=is_superuser) 18 | user.set_password(password) 19 | user.save() 20 | return user 21 | 22 | 23 | def auth_through_backend(backend, **kwargs): 24 | request = None 25 | return backend.authenticate(request, **kwargs) 26 | 27 | 28 | class FilteredBackendTestCase(TransactionTestCase): 29 | 30 | def test_it_is_a_model_backend(self): 31 | self.assertTrue( 32 | issubclass(FilteredModelBackend, ModelBackend), 33 | FilteredModelBackend.__mro__) 34 | 35 | def test_can_declare_filters_which_apply_to_get_user(self): 36 | staff = create_user( 37 | username='staff', is_staff=True, is_superuser=False) 38 | superuser = create_user( 39 | username='superuser', is_staff=True, is_superuser=True) 40 | customer = create_user( 41 | username='customer', is_staff=False, is_superuser=False) 42 | for u in [staff, superuser, customer]: 43 | u.set_password('password') 44 | u.save() 45 | 46 | class TestFilteredBackend(FilteredModelBackend): 47 | 48 | def __init__(self, filter_kwargs): 49 | self.filter_kwargs = filter_kwargs 50 | 51 | def run_scenarios_with(test_method): 52 | self.assertEqual(staff, test_method(staff, dict())) 53 | self.assertEqual(superuser, test_method(superuser, dict())) 54 | self.assertEqual(customer, test_method(customer, dict())) 55 | 56 | self.assertEqual(None, test_method(customer, dict(is_staff=True))) 57 | self.assertEqual( 58 | superuser, test_method(superuser, dict(is_superuser=True))) 59 | self.assertEqual( 60 | customer, test_method( 61 | customer, dict(username__startswith='c'))) 62 | self.assertEqual( 63 | None, test_method(superuser, dict(username__startswith='c'))) 64 | 65 | def get_user(user, filter_kwargs): 66 | backend = TestFilteredBackend(filter_kwargs) 67 | return backend.get_user(user.pk) 68 | 69 | run_scenarios_with(get_user) 70 | 71 | def authenticate(user, filter_kwargs): 72 | backend = TestFilteredBackend(filter_kwargs) 73 | return auth_through_backend( 74 | backend=backend, username=user.username, password='password') 75 | 76 | run_scenarios_with(authenticate) 77 | 78 | 79 | class TestableBackend(object): 80 | 81 | def __init__(self): 82 | self.reset() 83 | 84 | def authenticate(self, *a, **kw): 85 | kw.pop('request') 86 | self.calls.append((a, kw)) 87 | return self.authenticated_user 88 | 89 | def reset(self): 90 | self.calls = [] 91 | self.authenticated_user = None 92 | 93 | 94 | def patched_get_backends(backends): 95 | return patch( 96 | 'django.contrib.auth._get_backends', 97 | return_value=backends 98 | ) 99 | 100 | 101 | class ActAsBackendAuthenticateTestCase(TransactionTestCase): 102 | 103 | def setUp(self): 104 | super(ActAsBackendAuthenticateTestCase, self).setUp() 105 | self.first_test_backend = TestableBackend() 106 | self.second_test_backend = TestableBackend() 107 | self.third_test_backend_not_in_get_backends = TestableBackend() 108 | self.act_as_auth_backend = ActAsBackend() 109 | self.backends = [ 110 | self.first_test_backend, 111 | self.act_as_auth_backend, 112 | self.second_test_backend 113 | ] 114 | 115 | def patched_get_backends(self): 116 | return patched_get_backends(self.backends) 117 | 118 | def test_does_not_inherit_from_any_backend(self): 119 | self.assertEqual( 120 | (ActAsBackend, object), 121 | ActAsBackend.__mro__ 122 | ) 123 | 124 | def test_fails_if_multiple_act_as_backends_are_configured(self): 125 | """ 126 | while I can see how one could like to have multiple rules for 127 | when one can becomes another user, I foresee complexity, unexpected 128 | bugs, corner cases, etc. and thus would much rather place the burden 129 | of managing the complexity/interaction between these various rules 130 | on the user of this library - break the rules apart into multiple 131 | methods, and compose them in your own code, so this library can 132 | remain simple 133 | """ 134 | self.backends.append(ActAsBackend()) 135 | with self.patched_get_backends(): 136 | with self.assertRaises(ValueError): 137 | auth_through_backend( 138 | self.act_as_auth_backend, 139 | username='foo/bar', password='password') 140 | 141 | def test_it_tries_all_other_configured_backends(self): 142 | with self.patched_get_backends(): 143 | auth_through_backend( 144 | self.act_as_auth_backend, 145 | username='foo/bar', password='password') 146 | self.assertEqual( 147 | [(tuple(), {'password': 'password', 'username': 'foo'})], 148 | self.first_test_backend.calls) 149 | self.assertEqual( 150 | [(tuple(), {'password': 'password', 'username': 'foo'})], 151 | self.second_test_backend.calls) 152 | self.assertEqual([], self.third_test_backend_not_in_get_backends.calls) 153 | 154 | def test_first_successful_backend_returned_later_ones_not_called(self): 155 | self.first_test_backend.authenticated_user = User() 156 | with self.patched_get_backends(): 157 | auth_through_backend( 158 | self.act_as_auth_backend, 159 | username='foo/bar', password='password') 160 | self.assertEqual( 161 | [(tuple(), {'password': 'password', 'username': 'foo'})], 162 | self.first_test_backend.calls) 163 | self.assertEqual([], self.second_test_backend.calls) 164 | 165 | def test_cannot_authenticate_regular_user(self): 166 | with self.patched_get_backends(): 167 | self.assertEqual( 168 | None, 169 | auth_through_backend( 170 | self.act_as_auth_backend, 171 | username='foo', password='password')) 172 | self.assertEqual([], self.first_test_backend.calls) 173 | self.assertEqual([], self.second_test_backend.calls) 174 | 175 | def test_can_become_another_user_with_own_password(self): 176 | create_user(username='admin', password='admin password') 177 | user = create_user(username='user', password='user password') 178 | self.assertEqual( 179 | None, self.authenticate( 180 | username='admin/user', password='user password')) 181 | self.assertEqual( 182 | user, self.authenticate( 183 | username='admin/user', password='admin password')) 184 | 185 | @patch("djactasauth.backends.log") 186 | def test_usernames_with_multiple_sepchars_trigger_log_warning(self, 187 | mock_log): 188 | create_user(username='admin', password='foo') 189 | self.assertEqual(None, self.authenticate(username='admin/user/', 190 | password='foo')) 191 | self.assertEqual(None, self.authenticate(username='admin//user', 192 | password='foo')) 193 | self.assertEqual(None, self.authenticate(username='admin/us/er', 194 | password='foo')) 195 | self.assertEqual(None, self.authenticate(username='/admin/user', 196 | password='foo')) 197 | calls = [call(ActAsBackend.too_many_sepchar_msg) for i in range(4)] 198 | mock_log.warn.assert_has_calls(calls) 199 | 200 | def test_cannot_become_nonexistent_user(self): 201 | create_user(username='admin', password='password') 202 | self.assertEqual( 203 | None, self.authenticate( 204 | username='admin/user', password='password')) 205 | 206 | def test_authenticate_does_not_fire_login_signal(self): 207 | def should_not_fire_login_signal(user, **kwargs): 208 | self.fail( 209 | 'should not have fired login signal but did for %r' % user) 210 | 211 | create_user(username='admin', password='admin password') 212 | user = create_user(username='user', password='user password') 213 | 214 | auth_signals.user_logged_in.connect(should_not_fire_login_signal) 215 | try: 216 | self.authenticate(username='admin/user', password='admin password') 217 | finally: 218 | auth_signals.user_logged_in.disconnect( 219 | should_not_fire_login_signal) 220 | self.assertEqual( 221 | user, self.authenticate( 222 | username='admin/user', password='admin password')) 223 | 224 | def test_only_super_user_can_act_as_model_backend_regression(self): 225 | create_user( 226 | username='admin1', password='admin1 password', is_superuser=True) 227 | create_user( 228 | username='admin2', password='admin2 password', is_superuser=True) 229 | user = create_user( 230 | username='user', password='user password', is_superuser=False) 231 | 232 | self.assertEqual( 233 | None, self.authenticate( 234 | username='user/admin1', password='user password', 235 | backend_cls=OnlySuperuserCanActAsBackend)) 236 | self.assertEqual( 237 | None, self.authenticate( 238 | username='user/admin2', password='user password', 239 | backend_cls=OnlySuperuserCanActAsBackend)) 240 | 241 | self.assertEqual( 242 | user, self.authenticate( 243 | backend_cls=OnlySuperuserCanActAsBackend, 244 | username='admin1/user', password='admin1 password')) 245 | self.assertEqual( 246 | user, self.authenticate( 247 | backend_cls=OnlySuperuserCanActAsBackend, 248 | username='admin2/user', password='admin2 password')) 249 | 250 | self.assertEqual( 251 | None, self.authenticate( 252 | backend_cls=OnlySuperuserCanActAsBackend, 253 | username='admin1/admin2', password='admin1 password')) 254 | self.assertEqual( 255 | None, self.authenticate( 256 | backend_cls=OnlySuperuserCanActAsBackend, 257 | username='admin2/admin1', password='admin2 password')) 258 | 259 | def test_can_customize_can_act_as_policy_by_subclassing(self): 260 | alice = create_user(username='alice', password='alice') 261 | create_user(username='bob', password='bob') 262 | 263 | class OnlyShortUserNamesCanActAs(ActAsBackend): 264 | 265 | def can_act_as(self, auth_user, user): 266 | return len(auth_user.username) <= 3 267 | 268 | self.assertEqual( 269 | None, self.authenticate( 270 | backend_cls=OnlyShortUserNamesCanActAs, 271 | username='alice/bob', password='alice')) 272 | self.assertEqual( 273 | alice, self.authenticate( 274 | backend_cls=OnlyShortUserNamesCanActAs, 275 | username='bob/alice', password='bob')) 276 | 277 | def test_when_users_none_doesnt_crash_process(self): 278 | create_user(username='jane', password='doe') 279 | 280 | class ShouldNotCallCanActAs(ActAsBackend): 281 | 282 | def can_act_as(backend_self, auth_user, user): 283 | self.fail('should not have called') 284 | 285 | self.assertEqual( 286 | None, self.authenticate( 287 | backend_cls=ShouldNotCallCanActAs, 288 | username='jane/non-existent-user', password='doe')) 289 | 290 | def test_is_act_as_username_method(self): 291 | def assert_classification(username, expected_to_be_act_as_username): 292 | self.assertEqual( 293 | expected_to_be_act_as_username, 294 | ActAsBackend.is_act_as_username(username)) 295 | 296 | assert_classification(None, False) 297 | assert_classification('', False) 298 | assert_classification('user', False) 299 | assert_classification('user/johndoe', True) 300 | 301 | ### 302 | 303 | def authenticate(self, username, password, backend_cls=None): 304 | if not backend_cls: 305 | class EveryoneCanActAs(ActAsBackend): 306 | def can_act_as(self, auth_user, user): 307 | return True 308 | backend_cls = EveryoneCanActAs 309 | 310 | backend = backend_cls() 311 | with patched_get_backends([backend, ModelBackend()]): 312 | return auth_through_backend( 313 | backend, username=username, password=password) 314 | 315 | 316 | @override_settings( 317 | AUTHENTICATION_BACKENDS=[ 318 | 'djactasauth.backends.OnlySuperuserCanActAsBackend', 319 | 'django.contrib.auth.backends.ModelBackend']) 320 | class EndToEndActAsThroughFormAndView(TransactionTestCase): 321 | 322 | def test_login_page_is_set_up_as_expected(self): 323 | self.goto_login_page() 324 | response = self.login_get_response 325 | self.assertEqual(200, response.status_code) 326 | form = response.context['form'] 327 | self.assertTrue( 328 | isinstance(form, AuthenticationForm), type(form).__mro__) 329 | 330 | def test_successful_act_as_login_fires_signal_with_act_as_user(self): 331 | logged_in_users = [] 332 | 333 | def handle_user_logged_in(user, **kwargs): 334 | logged_in_users.append(user) 335 | 336 | auth_signals.user_logged_in.connect(handle_user_logged_in) 337 | create_user(username='admin', password='admin', is_superuser=True) 338 | user = create_user( 339 | username='user', password='user', is_superuser=False) 340 | try: 341 | self.goto_login_page() 342 | self.submit_login(username='admin/user', password='admin') 343 | self.assertEqual(302, self.login_post_response.status_code) 344 | finally: 345 | auth_signals.user_logged_in.disconnect(handle_user_logged_in) 346 | self.assertEqual([user], logged_in_users) 347 | 348 | def test_after_login_correct_user_is_passed_in_the_request_no_act_as(self): 349 | create_user(username='admin', password='admin', is_superuser=True) 350 | self.assert_logged_in_user_on_next_request( 351 | username='admin', password='admin', display_user='admin') 352 | 353 | def test_after_login_correct_user_is_passed_in_the_request_act_as(self): 354 | create_user(username='admin', password='admin', is_superuser=True) 355 | create_user(username='user', password='user', is_superuser=False) 356 | self.assert_logged_in_user_on_next_request( 357 | username='admin/user', password='admin', display_user='user') 358 | 359 | def test_next_from_GET_is_respected_and_user_is_redirected_there(self): 360 | create_user(username='user', password='user', is_superuser=False) 361 | self.assert_logged_in_user_on_next_request( 362 | username='user', password='user', display_user='user', 363 | **{REDIRECT_FIELD_NAME: '/foo/'}) 364 | redir_to = urllib.parse.urlparse(self.login_post_response['Location']) 365 | self.assertEqual('/foo/', redir_to.path) 366 | 367 | def test_on_post_form_has_access_to_request(self): 368 | self.goto_login_page() 369 | self.submit_login(username='foo', password='bar') 370 | response = self.login_post_response 371 | self.assertEqual(200, response.status_code) 372 | form = response.context['form'] 373 | self.assertTrue(hasattr(form, 'request')) 374 | self.assertIsNotNone(form.request) 375 | 376 | def test_can_initialize_username_from_querystring(self): 377 | self.goto_login_page(username='foo') 378 | form = self.login_get_response.context['form'] 379 | self.assertEqual('foo', form.initial.get('username')) 380 | 381 | ### 382 | 383 | def assert_logged_in_user_on_next_request( 384 | self, username, password, display_user, **query): 385 | 386 | self.goto_login_page(**query) 387 | 388 | self.submit_login(username=username, password=password, **query) 389 | response_content = self.login_post_response.content.decode('ascii') 390 | self.assertEqual( 391 | 302, self.login_post_response.status_code, 392 | (username, password, response_content)) 393 | 394 | self.get_whoami_page() 395 | self.assertEqual( 396 | display_user, self.whoami_response.content.decode('ascii')) 397 | 398 | def goto_login_page(self, **query): 399 | url = get_login_url(**query) 400 | self.login_get_response = self.client.get(url) 401 | self.assertEqual(200, self.login_get_response.status_code) 402 | 403 | def submit_login(self, username, password, **query): 404 | url = get_login_url(**query) 405 | self.login_post_response = self.client.post( 406 | url, dict(username=username, password=password)) 407 | 408 | def get_whoami_page(self): 409 | self.whoami_response = self.client.get('/whoami/') 410 | self.assertEqual(200, self.whoami_response.status_code) 411 | 412 | 413 | class ActAsUrlGeneratorTestCase(TransactionTestCase): 414 | 415 | def test_generates_the_correct_url(self): 416 | self.assertEqual( 417 | '/login/?username=admin%2Fuser', 418 | act_as_login_url(auth='admin', act_as='user')) 419 | 420 | self.assertEqual( 421 | '/login/?username=foo%2Fbar', 422 | act_as_login_url(auth='foo', act_as='bar')) 423 | --------------------------------------------------------------------------------