├── lockdown ├── __init__.py ├── tests │ ├── __init__.py │ ├── forms.py │ ├── test_settings.py │ ├── urls.py │ ├── views.py │ └── tests.py ├── templates │ └── lockdown │ │ ├── base.html │ │ └── form.html ├── decorators.py ├── forms.py └── middleware.py ├── .coveragerc ├── .yamllint ├── .isort.cfg ├── .gitignore ├── MANIFEST.in ├── AUTHORS.rst ├── .prospector.yml ├── runtests.py ├── .github ├── pull_request_template.md └── workflows │ └── main.yml ├── LICENSE.txt ├── .pre-commit-config.yaml ├── setup.py ├── CHANGES.rst └── README.rst /lockdown/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lockdown/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = lockdown/* 3 | omit = lockdown/tests/* 4 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | truthy: disable 6 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_localfolder=lockdown 3 | not_skip=__init__.py 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | django_lockdown.egg-info/* 3 | build/* 4 | .idea 5 | *.pyc 6 | .project 7 | .pydevproject 8 | .coverage 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.rst 6 | recursive-include lockdown/templates *.html 7 | recursive-exclude lockdown/tests * 8 | -------------------------------------------------------------------------------- /lockdown/templates/lockdown/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | {% block content %} 8 | {% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | - Carl Meyer 2 | - Chris Beaven 3 | - Markus Kaiserswerth 4 | - Daniel Roschka 5 | - Jan Van Bruggen 6 | - Adam Taylor 7 | - Piotr Frankowski 8 | - Jairus Martin 9 | - Bruno Alla 10 | -------------------------------------------------------------------------------- /lockdown/decorators.py: -------------------------------------------------------------------------------- 1 | """Provide a decorator based on the LockdownMiddleware. 2 | 3 | This module provides a decorator that takes the same arguments as the 4 | middleware, but allows more granular locking than the middleware. 5 | """ 6 | from django.utils.decorators import decorator_from_middleware_with_args 7 | 8 | from lockdown.middleware import LockdownMiddleware 9 | 10 | lockdown = decorator_from_middleware_with_args(LockdownMiddleware) 11 | -------------------------------------------------------------------------------- /.prospector.yml: -------------------------------------------------------------------------------- 1 | --- 2 | strictness: veryhigh 3 | doc-warnings: "yes" 4 | test-warnings: "yes" 5 | uses: 6 | - django 7 | pylint: 8 | disable: 9 | - django-not-configured 10 | - missing-docstring 11 | - no-self-use 12 | - unused-argument 13 | - useless-object-inheritance 14 | pep8: 15 | disable: 16 | - N802 17 | pep257: 18 | disable: 19 | - D100 20 | - D104 21 | - D203 22 | - D213 23 | mccabe: 24 | run: false 25 | -------------------------------------------------------------------------------- /lockdown/tests/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class CustomLockdownForm(forms.Form): 5 | """A form to test the behavior of using custom forms for authentication.""" 6 | 7 | answer = forms.IntegerField() 8 | 9 | def clean_answer(self): 10 | """Clean the answer field, by checking its value.""" 11 | if self.cleaned_data['answer'] == 42: 12 | return 42 13 | raise forms.ValidationError('Wrong answer.') 14 | -------------------------------------------------------------------------------- /lockdown/templates/lockdown/form.html: -------------------------------------------------------------------------------- 1 | {% extends "lockdown/base.html" %} 2 | 3 | {% block title %}Coming soon...{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Coming soon...

8 | 9 |

This is not yet available to the public.

10 | 11 | {% if form %} 12 |
{% csrf_token %} 13 | {{ form.as_p }} 14 |

15 |
16 | {% endif %} 17 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.conf import settings 8 | from django.test.utils import get_runner 9 | 10 | 11 | def runtests(*test_args): 12 | """Set up and run django-lockdowns test suite.""" 13 | os.environ['DJANGO_SETTINGS_MODULE'] = 'lockdown.tests.test_settings' 14 | 15 | django.setup() 16 | 17 | if not test_args: 18 | test_args = ['lockdown.tests'] 19 | 20 | test_runner = get_runner(settings)() 21 | failures = test_runner.run_tests(test_args) 22 | sys.exit(bool(failures)) 23 | 24 | 25 | if __name__ == '__main__': 26 | runtests(*sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Thanks a lot for opening a pull request. :tada: 2 | 3 | To ensure your pull request can get merged as fast as possible, please check 4 | all points in the following list: 5 | 6 | - [ ] When adding new code add corresponding tests as well. 7 | - [ ] Ensure your code follows the [Python Style Guide (PEP8)][1] and the 8 | [Docstring Conventions (PEP257)][2]. 9 | - [ ] Update the `AUTHORS.rst`, `CHANGES.rst` and `README.rst` as necessary. 10 | - [ ] Check that [continuous integration (CI)][1] runs succeed after you opened 11 | the pull request and fix the issues it detects. 12 | 13 | Please also replace this template with a description of your changes before you 14 | open the pull request. 15 | 16 | [1]: https://www.python.org/dev/peps/pep-0008/ 17 | [2]: https://www.python.org/dev/peps/pep-0257/ 18 | [3]: https://github.com/Dunedan/django-lockdown/actions 19 | -------------------------------------------------------------------------------- /lockdown/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3' 6 | } 7 | } 8 | 9 | SECRET_KEY = ''.join([choice('abcdefghijklmnopqrstuvwxyz' # nosec 10 | '0123456789!@#$%^&*(-_=+)') 11 | for i in range(64)]) 12 | 13 | MIDDLEWARE = [ 14 | 'django.contrib.sessions.middleware.SessionMiddleware', 15 | 'django.middleware.common.CommonMiddleware', 16 | 'django.middleware.csrf.CsrfViewMiddleware', 17 | 'django.contrib.auth.middleware.AuthenticationMiddleware' 18 | ] 19 | 20 | INSTALLED_APPS = ( 21 | 'django.contrib.sessions', 22 | 'django.contrib.contenttypes', 23 | 'django.contrib.auth', 24 | 'lockdown' 25 | ) 26 | 27 | ROOT_URLCONF = 'lockdown.tests.urls' 28 | 29 | TEMPLATES = [ 30 | { 31 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 32 | 'APP_DIRS': True, 33 | 'OPTIONS': { 34 | 'context_processors': [ 35 | 'django.template.context_processors.debug', 36 | 'django.template.context_processors.request', 37 | 'django.contrib.auth.context_processors.auth', 38 | 'django.contrib.messages.context_processors.messages', 39 | ], 40 | }, 41 | }, 42 | ] 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009, Carl Meyer 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-byte-order-marker 9 | - id: check-case-conflict 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-yaml 13 | - id: debug-statements 14 | - id: detect-aws-credentials 15 | args: 16 | - --allow-missing-credentials 17 | - id: detect-private-key 18 | 19 | - repo: https://github.com/adrienverge/yamllint 20 | rev: v1.37.0 21 | hooks: 22 | - id: yamllint 23 | args: 24 | - -s 25 | 26 | - repo: https://github.com/PyCQA/isort 27 | rev: 6.0.1 28 | hooks: 29 | - id: isort 30 | args: 31 | - --check-only 32 | - --diff 33 | 34 | - repo: https://github.com/prospector-dev/prospector/ 35 | rev: v1.16.0 36 | hooks: 37 | - id: prospector 38 | additional_dependencies: 39 | - django 40 | - setuptools 41 | 42 | - repo: https://github.com/Lucas-C/pre-commit-hooks 43 | rev: v1.5.5 44 | hooks: 45 | - id: forbid-crlf 46 | - id: forbid-tabs 47 | 48 | - repo: https://github.com/PyCQA/bandit 49 | rev: 1.8.3 50 | hooks: 51 | - id: bandit 52 | args: 53 | - -l 54 | 55 | - repo: https://github.com/Lucas-C/pre-commit-hooks-safety 56 | rev: v1.4.0 57 | hooks: 58 | - id: python-safety-dependencies-check 59 | 60 | - repo: https://github.com/codespell-project/codespell 61 | rev: v2.4.1 62 | hooks: 63 | - id: codespell 64 | -------------------------------------------------------------------------------- /lockdown/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('a/view/', views.a_view), 7 | path('locked/view/', views.locked_view), 8 | path('overridden/locked/view/', views.overridden_locked_view), 9 | re_path(r'^locked/view/with/exception1/', 10 | views.locked_view_with_exception), 11 | re_path(r'^locked/view/with/exception2/', 12 | views.locked_view_with_exception), 13 | re_path(r'^locked/view/with/ip_exception/', 14 | views.locked_view_with_ip_exception), 15 | re_path(r'^locked/view/with/ip_exception_ipv6/', 16 | views.locked_view_with_ip_exception_ipv6), 17 | re_path(r'^locked/view/with/ip_exception_subnet/', 18 | views.locked_view_with_ip_exception_subnet), 19 | re_path(r'^locked/view/with/ip_exception_ipv6_subnet/', 20 | views.locked_view_with_ip_exception_ipv6_subnet), 21 | re_path(r'^locked/view/with/extra/context/', 22 | views.locked_view_with_extra_context), 23 | re_path(r'^locked/view/until/yesterday/', 24 | views.locked_view_until_yesterday), 25 | re_path(r'^locked/view/until/tomorrow/', 26 | views.locked_view_until_tomorrow), 27 | re_path(r'^locked/view/after/yesterday/', 28 | views.locked_view_after_yesterday), 29 | re_path(r'^locked/view/after/tomorrow/', 30 | views.locked_view_after_tomorrow), 31 | re_path(r'^locked/view/until/and/after/', 32 | views.locked_view_until_and_after), 33 | path('auth/user/locked/view/', views.user_locked_view), 34 | path('auth/staff/locked/view/', views.staff_locked_view), 35 | path('auth/superuser/locked/view/', views.superuser_locked_view), 36 | ] 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | - push 5 | - pull_request 6 | jobs: 7 | test: 8 | runs-on: ubuntu-22.04 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: 13 | - "3.9" 14 | - "3.10" 15 | - "3.11" 16 | - "3.12" 17 | - "3.13" 18 | - "pypy3.10" 19 | django-version: 20 | - Django>=4.2,<5.0 21 | - Django>=5.0,<5.1 22 | - Django>=5.1,<5.2 23 | - Django>=5.2,<6.0 24 | exclude: 25 | - python-version: "3.9" 26 | django-version: "Django>=5.0,<5.1" 27 | - python-version: "3.9" 28 | django-version: "Django>=5.1,<5.2" 29 | - python-version: "3.9" 30 | django-version: "Django>=5.2,<6.0" 31 | - python-version: "3.13" 32 | django-version: "Django>=4.2,<5.0" 33 | - python-version: "3.13" 34 | django-version: "Django>=5.0,<5.1" 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | - name: Install Django 41 | run: pip install "${{ matrix.django-version}}" 42 | - name: Install coveralls 43 | run: pip install coveralls 44 | - name: Run tests 45 | run: coverage run ./runtests.py 46 | - name: Run coveralls 47 | run: coveralls --service=github 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | pre-commit: 51 | runs-on: ubuntu-22.04 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions/setup-python@v5 55 | with: 56 | python-version: "3.11" 57 | - uses: pre-commit/action@v3.0.1 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open('README.rst', encoding='utf-8') as rfd: 4 | with open('CHANGES.rst', encoding='utf-8') as cfd: 5 | LONG_DESCRIPTION = "\n".join([rfd.read(), cfd.read()]) 6 | 7 | setup( 8 | name='django-lockdown', 9 | version='5.0.0', 10 | description=('Lock down a Django site or individual views, with ' 11 | 'configurable preview authorization'), 12 | long_description=LONG_DESCRIPTION, 13 | long_description_content_type='text/x-rst', 14 | author='Carl Meyer', 15 | author_email='carl@dirtcircle.com', 16 | maintainer='Daniel Roschka', 17 | maintainer_email='danielroschka@phoenitydawn.de', 18 | url='https://github.com/Dunedan/django-lockdown/', 19 | packages=find_packages(exclude=['lockdown.tests']), 20 | python_requires='>=3.9', 21 | install_requires=['Django>=4.2'], 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Environment :: Web Environment', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: BSD License', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python', 29 | 'Programming Language :: Python :: 3.9', 30 | 'Programming Language :: Python :: 3.10', 31 | 'Programming Language :: Python :: 3.11', 32 | 'Programming Language :: Python :: 3.12', 33 | 'Programming Language :: Python :: 3.13', 34 | 'Programming Language :: Python :: Implementation :: CPython', 35 | 'Programming Language :: Python :: Implementation :: PyPy', 36 | 'Framework :: Django', 37 | 'Framework :: Django :: 4.2', 38 | 'Framework :: Django :: 5.0', 39 | 'Framework :: Django :: 5.1', 40 | 'Framework :: Django :: 5.2', 41 | ], 42 | zip_safe=False, 43 | test_suite='runtests.runtests', 44 | package_data={'lockdown': ['templates/lockdown/*.html']}, 45 | ) 46 | -------------------------------------------------------------------------------- /lockdown/tests/views.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name 2 | 3 | import datetime 4 | 5 | from django.http import HttpResponse 6 | 7 | from lockdown.decorators import lockdown 8 | from lockdown.forms import AuthForm 9 | 10 | YESTERDAY = datetime.datetime.now() - datetime.timedelta(days=1) 11 | TOMORROW = datetime.datetime.now() + datetime.timedelta(days=1) 12 | 13 | 14 | def a_view(request): 15 | """Regular unlocked view.""" 16 | return HttpResponse('A view.') 17 | 18 | 19 | @lockdown() 20 | def locked_view(request): 21 | """View, locked by the default lockdown decorator.""" 22 | return HttpResponse('A locked view.') 23 | 24 | 25 | @lockdown(passwords=('squirrel',)) 26 | def overridden_locked_view(request): 27 | """View, locked by the decorator with a custom password.""" 28 | return HttpResponse('A locked view.') 29 | 30 | 31 | @lockdown(url_exceptions=(r'^/locked/view/with/exception2/',)) 32 | def locked_view_with_exception(request): 33 | """View, locked by the decorator with url exceptions.""" 34 | return HttpResponse('A locked view.') 35 | 36 | 37 | @lockdown(remote_addr_exceptions=['192.168.0.1']) 38 | def locked_view_with_ip_exception(request): 39 | """View, locked except for the configured IPv4-address.""" 40 | return HttpResponse('A locked view.') 41 | 42 | 43 | @lockdown(remote_addr_exceptions=['fd::1']) 44 | def locked_view_with_ip_exception_ipv6(request): 45 | """View, locked except for the configured IPv6-address.""" 46 | return HttpResponse('A locked view.') 47 | 48 | 49 | @lockdown(remote_addr_exceptions=['192.168.0.0/24']) 50 | def locked_view_with_ip_exception_subnet(request): 51 | """View, locked except for the configured IPv4-subnet.""" 52 | return HttpResponse('A locked view.') 53 | 54 | 55 | @lockdown(remote_addr_exceptions=['fd::0/80']) 56 | def locked_view_with_ip_exception_ipv6_subnet(request): 57 | """View, locked except for the configured IPv6-subnet.""" 58 | return HttpResponse('A locked view.') 59 | 60 | 61 | @lockdown(extra_context={'foo': 'bar'}) 62 | def locked_view_with_extra_context(request): 63 | """View, locked by the decorator with extra context.""" 64 | return HttpResponse('A locked view.') 65 | 66 | 67 | @lockdown(until_date=YESTERDAY) 68 | def locked_view_until_yesterday(request): 69 | """View, locked till yesterday.""" 70 | return HttpResponse('A locked view.') 71 | 72 | 73 | @lockdown(until_date=TOMORROW) 74 | def locked_view_until_tomorrow(request): 75 | """View, locked till tomorrow.""" 76 | return HttpResponse('A locked view.') 77 | 78 | 79 | @lockdown(after_date=YESTERDAY) 80 | def locked_view_after_yesterday(request): 81 | """View, locked since yesterday.""" 82 | return HttpResponse('A locked view.') 83 | 84 | 85 | @lockdown(after_date=TOMORROW) 86 | def locked_view_after_tomorrow(request): 87 | """View, locked starting from tomorrow.""" 88 | return HttpResponse('A locked view.') 89 | 90 | 91 | @lockdown(until_date=YESTERDAY, after_date=TOMORROW) 92 | def locked_view_until_and_after(request): 93 | """View, only not looked between yesterday and tomorrow.""" 94 | return HttpResponse('A locked view.') 95 | 96 | 97 | @lockdown(form=AuthForm, staff_only=False) 98 | def user_locked_view(request): 99 | """View, locked by the decorator with access for known users only.""" 100 | return HttpResponse('A locked view.') 101 | 102 | 103 | @lockdown(form=AuthForm) 104 | def staff_locked_view(request): 105 | """View, locked by the decorator with access for staff users only.""" 106 | return HttpResponse('A locked view.') 107 | 108 | 109 | @lockdown(form=AuthForm, superusers_only=True) 110 | def superuser_locked_view(request): 111 | """View, locked by the decorator with access for superusers only.""" 112 | return HttpResponse('A locked view.') 113 | -------------------------------------------------------------------------------- /lockdown/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.contrib import auth 4 | from django.contrib.auth.forms import AuthenticationForm 5 | 6 | 7 | class LockdownForm(forms.Form): 8 | """Defines a form to enter a password for accessing locked down content.""" 9 | 10 | password = forms.CharField(widget=forms.PasswordInput()) 11 | 12 | # pylint: disable=keyword-arg-before-vararg 13 | def __init__(self, passwords=None, *args, **kwargs): 14 | """Initialize the form by setting the valid passwords.""" 15 | super().__init__(*args, **kwargs) 16 | if passwords is None: 17 | passwords = getattr(settings, 'LOCKDOWN_PASSWORDS', ()) 18 | if not isinstance(passwords, (tuple, list)): 19 | passwords = (passwords,) if passwords else () 20 | 21 | self.valid_passwords = passwords 22 | 23 | def clean_password(self): 24 | """Check that the password is valid.""" 25 | value = self.cleaned_data.get('password') 26 | if value not in self.valid_passwords: 27 | raise forms.ValidationError('Incorrect password.') 28 | return value 29 | 30 | def generate_token(self): 31 | """Save the password as the authentication token. 32 | 33 | It's acceptable to store the password raw, as it is stored server-side 34 | in the user's session. 35 | """ 36 | return self.cleaned_data['password'] 37 | 38 | def authenticate(self, token_value): 39 | """Check that the password is valid. 40 | 41 | This allows for revoking of a user's preview rights by changing the 42 | valid passwords. 43 | """ 44 | return token_value in self.valid_passwords 45 | 46 | def show_form(self): 47 | """Show the form if there are any valid passwords.""" 48 | return bool(self.valid_passwords) 49 | 50 | 51 | class AuthForm(AuthenticationForm): 52 | """Defines a form using Djangos authentication to access locked content. 53 | 54 | This form is a sample implementation of how to use a custom form to provide 55 | access to locked down content. 56 | """ 57 | 58 | # pylint: disable=keyword-arg-before-vararg 59 | def __init__(self, staff_only=None, superusers_only=None, *args, 60 | **kwargs): 61 | """Initialize the form by setting permissions needed for access.""" 62 | super().__init__(*args, **kwargs) 63 | if staff_only is None: 64 | staff_only = getattr(settings, 65 | 'LOCKDOWN_AUTHFORM_STAFF_ONLY', 66 | True) 67 | if superusers_only is None: 68 | superusers_only = getattr(settings, 69 | 'LOCKDOWN_AUTHFORM_SUPERUSERS_ONLY', 70 | False) 71 | self.staff_only = staff_only 72 | self.superusers_only = superusers_only 73 | 74 | def clean(self): 75 | """When receiving the filled out form, check for valid access.""" 76 | cleaned_data = super().clean() 77 | user = self.get_user() 78 | if self.staff_only and (not user or not user.is_staff): 79 | raise forms.ValidationError('Sorry, only staff are allowed.') 80 | if self.superusers_only and (not user or not user.is_superuser): 81 | raise forms.ValidationError('Sorry, only superusers are allowed.') 82 | return cleaned_data 83 | 84 | def generate_token(self): 85 | """Save the password as the authentication token. 86 | 87 | It's acceptable to store the password raw, as it is stored server-side 88 | in the user's session. 89 | """ 90 | user = self.get_user() 91 | return f'{user.backend}:{user.pk}' 92 | 93 | def authenticate(self, token_value): 94 | """Check that the password is valid. 95 | 96 | This allows for revoking of a user's preview rights by changing the 97 | valid passwords. 98 | """ 99 | try: 100 | backend_path, user_id = token_value.split(':', 1) 101 | except (ValueError, AttributeError): 102 | return False 103 | backend = auth.load_backend(backend_path) 104 | return bool(backend.get_user(user_id)) 105 | 106 | def show_form(self): 107 | """Determine if the form should be shown on locked pages.""" 108 | return True 109 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 5.0.0 (2025-04-25) 5 | ------------------ 6 | 7 | - Remove support for end-of-life Django versions (from 2.2 until 4.1). 8 | 9 | - Remove support for end-of-life Python versions (3.6, 3.7 and 3.8). 10 | 11 | - Add support for Django 4.2, 5.0, 5.1 and 5.2. 12 | 13 | - Add support for Python 3.10, 3.11, 3.12 and 3.13. 14 | 15 | - Updated pre-commit hooks and modernize code style. Most notably started to 16 | use f-strings. 17 | 18 | 4.0.0 (2021-02-14) 19 | ------------------ 20 | 21 | - Remove support for end-of-life Django versions (1.11, 2.0 and 2.1). 22 | 23 | - Add support for Python 3.9 and remove support for Python 3.5. 24 | 25 | - Add support for Django 3.1. 26 | 27 | 3.0.0. (2020-01-01) 28 | ------------------- 29 | 30 | - Added support for Python 3.8. 31 | 32 | - Added support for Django 3.0. 33 | 34 | - Removed support for Python 2.7 and 3.4. 35 | 36 | 2.0.0 (2019-05-26) 37 | ------------------ 38 | 39 | - Added support for proxies when using IP-address based lockdown exceptions. 40 | 41 | - This introduces a breaking change: Installations running behind a proxy will 42 | need to set the newly introduced ``LOCKDOWN_TRUSTED_PROXIES``, otherwise 43 | access won't be granted anymore, when accessing the site through a proxy. 44 | 45 | - Added the ability to whitelist views when locking down a whole site using 46 | the middleware. 47 | 48 | - Added support for Django 2.2. 49 | 50 | - Only require ``mock`` as separate third-party test dependency for 51 | Python <3.3. 52 | 53 | - Fix detection of compacted IP-addresses. 54 | 55 | - This introduces a breaking change for users which make use of the 56 | ``REMOTE_ADDR_EXCEPTIONS`` feature and passed the IP-addresses to except as 57 | byte strings in the configuration. While it's unlikely somebody did that 58 | with Python 3, it's the default for Python 2. With this version, byte 59 | strings don't work anymore, but using unicode strings is required. 60 | 61 | - Add the ability to specify IP-subnets for remote addresses exception. 62 | 63 | 1.6.0 (2018-11-25) 64 | ------------------ 65 | 66 | - Drops support for Django <=1.10. 67 | 68 | - Drops support for Python 3.3. 69 | 70 | - Add the ability to bypass the lockdown for configured IP-addresses. 71 | 72 | - Integrate pre-commit for code style checks during commit and CI. 73 | 74 | - Added support for Django 2.1. 75 | 76 | - Add support for Python 3.7. 77 | 78 | - Add support for PyPy. 79 | 80 | 1.5.0 (2017-12-05) 81 | ------------------ 82 | 83 | - Add support for Django 2.0 84 | 85 | - Improve the code style in some areas 86 | 87 | 1.4.2 (2017-04-07) 88 | ------------------ 89 | 90 | - Fix formatting for PyPi 91 | 92 | 93 | 1.4.1 (2017-04-07) 94 | ------------------ 95 | 96 | - Fix problem with upload for PyPi 97 | 98 | 99 | 1.4.0 (2017-04-06) 100 | ------------------ 101 | 102 | - Refactor tests to use Mocks 103 | 104 | - Add support for Python 3.6 105 | 106 | - Add support for Django 1.11 107 | 108 | 109 | 1.3 (2016-08-07) 110 | ---------------- 111 | 112 | - Adds support for Django 1.10. 113 | 114 | - Adds support for providing additional context data to the lockdown template. 115 | 116 | 117 | 1.2 (2015-12-03) 118 | ---------------- 119 | 120 | - Adds support for Python 3.5. 121 | 122 | - Adds support for Django 1.9. 123 | 124 | - Drops support for Django <=1.7. 125 | 126 | - Fixes not working URL exceptions when specifying them in the decorator 127 | arguments. 128 | 129 | - Improves tests. 130 | 131 | 1.1 (2015-04-06) 132 | ---------------- 133 | 134 | - Proper new version after 0.1.2 and 0.1.3 have been tagged after the release 135 | of 1.0. Contains all new features of 0.1.2 and 0.1.3, most notably support 136 | for Python 3. 137 | 138 | - Last version of django-lockdown with support for Django 1.3, 1.5 and 1.6. 139 | Upcoming versions will only support Django versions with official security 140 | support. For the time being these are Django 1.4 LTS, 1.7 and 1.8 LTS. 141 | 142 | - Fixes testing for Django >=1.7 143 | 144 | 0.1.3 (2014-03-15) (never released) 145 | ----------------------------------- 146 | 147 | - Added ``LOCKDOWN_ENABLED`` setting. 148 | 149 | - Removed Django 1.1 backport of ``decorator_from_middleware_with_args``. 150 | 151 | 0.1.2 (2014-03-15) (never released) 152 | ----------------------------------- 153 | 154 | - Require at least Django 1.3. 155 | 156 | - Fixed the test runner script to work with recent Django versions. 157 | 158 | - Added the csrf_token template tag to the included form template. 159 | 160 | - Minor syntax adjustments for Python 3 compatibility. 161 | 162 | 1.0 (2013-07-10) 163 | ---------------- 164 | 165 | - BACKWARDS INCOMPATIBLE: Allow multiple passwords (the passwords setting has 166 | changed from ``LOCKDOWN_PASSWORD`` to ``LOCKDOWN_PASSWORDS``). 167 | 168 | - Decorator changed to a callable decorator (so settings can be overridden for 169 | an individual decorator). 170 | 171 | - Add ``AuthForm`` which can be used to allow previewing from authenticated 172 | users (via ``django.contrib.auth``). 173 | 174 | - Allow locking up until or only after certain dates. 175 | 176 | 0.1.1 (2009-11-24) 177 | ------------------ 178 | 179 | - Fix setup.py so ``tests`` package is not installed. 180 | 181 | 0.1 (2009-11-16) 182 | ---------------- 183 | 184 | - Initial release. 185 | -------------------------------------------------------------------------------- /lockdown/middleware.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import ipaddress 3 | import re 4 | from importlib import import_module 5 | 6 | from django.conf import settings 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.http import HttpResponseRedirect 9 | from django.shortcuts import render 10 | from django.urls import Resolver404, resolve 11 | 12 | 13 | def compile_url_exceptions(url_exceptions): 14 | """Return a list of compiled regex objects, containing the url exceptions. 15 | 16 | All URLs in that list returned won't be considered as locked. 17 | """ 18 | return [re.compile(p) for p in url_exceptions] 19 | 20 | 21 | def get_lockdown_form(form_path): 22 | """Return a form class for a given string pointing to a lockdown form.""" 23 | if not form_path: 24 | raise ImproperlyConfigured('No LOCKDOWN_FORM specified.') 25 | form_path_list = form_path.split(".") 26 | new_module = ".".join(form_path_list[:-1]) 27 | attr = form_path_list[-1] 28 | try: 29 | mod = import_module(new_module) 30 | except (ImportError, ValueError): 31 | # pylint: disable=raise-missing-from 32 | raise ImproperlyConfigured("Module configured in LOCKDOWN_FORM " 33 | f"({new_module}) to contain the form class " 34 | "couldn't be " "found.") 35 | try: 36 | form = getattr(mod, attr) 37 | except AttributeError: 38 | # pylint: disable=raise-missing-from 39 | raise ImproperlyConfigured('The module configured in LOCKDOWN_FORM ' 40 | f" ({new_module}) doesn't define a '{attr}'" 41 | " form.") 42 | return form 43 | 44 | 45 | # pylint: disable=too-many-instance-attributes 46 | class LockdownMiddleware(object): 47 | """Middleware to lock down a whole Django site.""" 48 | 49 | # pylint: disable=too-many-arguments,too-many-positional-arguments 50 | def __init__(self, get_response=None, form=None, until_date=None, 51 | after_date=None, logout_key=None, session_key=None, 52 | url_exceptions=None, remote_addr_exceptions=None, 53 | trusted_proxies=None, extra_context=None, **form_kwargs): 54 | """Initialize the middleware, by setting the configuration values.""" 55 | if logout_key is None: 56 | logout_key = getattr(settings, 57 | 'LOCKDOWN_LOGOUT_KEY', 58 | 'preview-logout') 59 | if session_key is None: 60 | session_key = getattr(settings, 61 | 'LOCKDOWN_SESSION_KEY', 62 | 'lockdown-allow') 63 | self.get_response = get_response 64 | self.form = form 65 | self.form_kwargs = form_kwargs 66 | self.until_date = until_date 67 | self.after_date = after_date 68 | self.logout_key = logout_key 69 | self.session_key = session_key 70 | self.url_exceptions = url_exceptions 71 | self.remote_addr_exceptions = remote_addr_exceptions 72 | self.trusted_proxies = trusted_proxies 73 | self.extra_context = extra_context 74 | 75 | def __call__(self, request): 76 | """Handle calls to the class instance.""" 77 | response = self.process_request(request) 78 | 79 | if not response: 80 | response = self.get_response(request) 81 | 82 | return response 83 | 84 | # pylint: disable=too-many-locals,too-many-return-statements 85 | # pylint: disable=too-many-statements,too-many-branches 86 | def process_request(self, request): 87 | """Check if each request is allowed to access the current resource.""" 88 | try: 89 | session = request.session 90 | except AttributeError as exc: 91 | raise ImproperlyConfigured('django-lockdown requires the Django ' 92 | 'sessions framework') from exc 93 | 94 | # Don't lock down if django-lockdown is disabled altogether. 95 | if getattr(settings, 'LOCKDOWN_ENABLED', True) is False: 96 | return None 97 | 98 | # Don't lock down if the client REMOTE_ADDR matched and is part of the 99 | # exception list. 100 | if self.remote_addr_exceptions: 101 | remote_addr_exceptions = self.remote_addr_exceptions 102 | else: 103 | remote_addr_exceptions = getattr(settings, 104 | 'LOCKDOWN_REMOTE_ADDR_EXCEPTIONS', 105 | []) 106 | 107 | remote_addr_exceptions = [ipaddress.ip_network(ip) 108 | for ip in remote_addr_exceptions] 109 | if remote_addr_exceptions: 110 | # If forwarding proxies are used they must be listed as trusted 111 | trusted_proxies = self.trusted_proxies or \ 112 | getattr(settings, 'LOCKDOWN_TRUSTED_PROXIES', []) 113 | trusted_proxies = [ipaddress.ip_network(ip) 114 | for ip in trusted_proxies] 115 | 116 | remote_addr = ipaddress.ip_address(request.META.get('REMOTE_ADDR')) 117 | if any(remote_addr for ip_exceptions in remote_addr_exceptions 118 | if remote_addr in ip_exceptions): 119 | return None 120 | 121 | if any(remote_addr for proxy in trusted_proxies 122 | if remote_addr in proxy): 123 | # If REMOTE_ADDR is a trusted proxy check x-forwarded-for 124 | x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') 125 | if x_forwarded_for: 126 | remote_addr = ipaddress.ip_address( 127 | x_forwarded_for.split(',')[-1].strip()) 128 | if any(remote_addr for ip_exceptions in 129 | remote_addr_exceptions 130 | if remote_addr in ip_exceptions): 131 | return None 132 | 133 | # Don't lock down if the URL matches an exception pattern. 134 | if self.url_exceptions: 135 | url_exceptions = compile_url_exceptions(self.url_exceptions) 136 | else: 137 | url_exceptions = compile_url_exceptions( 138 | getattr(settings, 'LOCKDOWN_URL_EXCEPTIONS', ())) 139 | for pattern in url_exceptions: 140 | if pattern.search(request.path): 141 | return None 142 | 143 | # Don't lock down if the URL resolves to a whitelisted view. 144 | try: 145 | resolved_path = resolve(request.path) 146 | except Resolver404: 147 | pass 148 | else: 149 | if resolved_path.func in getattr( 150 | settings, 'LOCKDOWN_VIEW_EXCEPTIONS', []): 151 | return None 152 | 153 | # Don't lock down if outside of the lockdown dates. 154 | if self.until_date: 155 | until_date = self.until_date 156 | else: 157 | until_date = getattr(settings, "LOCKDOWN_UNTIL_DATE", None) 158 | 159 | if self.after_date: 160 | after_date = self.after_date 161 | else: 162 | after_date = getattr(settings, "LOCKDOWN_AFTER_DATE", None) 163 | 164 | if until_date or after_date: 165 | locked_date = False 166 | if until_date and datetime.datetime.now() < until_date: 167 | locked_date = True 168 | if after_date and datetime.datetime.now() > after_date: 169 | locked_date = True 170 | if not locked_date: 171 | return None 172 | 173 | form_data = request.POST if request.method == 'POST' else None 174 | if self.form: 175 | form_class = self.form 176 | else: 177 | form_class = get_lockdown_form( 178 | getattr(settings, 179 | 'LOCKDOWN_FORM', 180 | 'lockdown.forms.LockdownForm')) 181 | form = form_class(data=form_data, **self.form_kwargs) 182 | 183 | authorized = False 184 | token = session.get(self.session_key) 185 | if hasattr(form, 'authenticate'): 186 | if form.authenticate(token): 187 | authorized = True 188 | elif token is True: 189 | authorized = True 190 | 191 | if authorized and self.logout_key and self.logout_key in request.GET: 192 | if self.session_key in session: 193 | del session[self.session_key] 194 | querystring = request.GET.copy() 195 | del querystring[self.logout_key] 196 | return self.redirect(request) 197 | 198 | # Don't lock down if the user is already authorized for previewing. 199 | if authorized: 200 | return None 201 | 202 | if form.is_valid(): 203 | if hasattr(form, 'generate_token'): 204 | token = form.generate_token() 205 | else: 206 | token = True 207 | session[self.session_key] = token 208 | return self.redirect(request) 209 | 210 | page_data = {'until_date': until_date, 'after_date': after_date} 211 | if not hasattr(form, 'show_form') or form.show_form(): 212 | page_data['form'] = form 213 | 214 | if self.extra_context: 215 | page_data.update(self.extra_context) 216 | 217 | return render(request, 'lockdown/form.html', page_data) 218 | 219 | def redirect(self, request): 220 | """Handle redirects properly.""" 221 | url = request.path 222 | querystring = request.GET.copy() 223 | if self.logout_key and self.logout_key in request.GET: 224 | del querystring[self.logout_key] 225 | if querystring: 226 | url = f'{url}?{querystring.urlencode()}' 227 | return HttpResponseRedirect(url) 228 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | django-lockdown 3 | =============== 4 | 5 | .. image:: https://github.com/Dunedan/django-lockdown/workflows/CI/badge.svg 6 | :target: https://github.com/Dunedan/django-lockdown/actions 7 | :alt: Build Status 8 | .. image:: https://coveralls.io/repos/Dunedan/django-lockdown/badge.svg 9 | :target: https://coveralls.io/r/Dunedan/django-lockdown 10 | :alt: Test Coverage 11 | .. image:: https://img.shields.io/pypi/v/django-lockdown.svg 12 | :target: https://pypi.org/project/django-lockdown/ 13 | :alt: Latest Version 14 | 15 | ``django-lockdown`` is a reusable Django application for locking down an entire 16 | site (or particular views), with customizable date ranges and preview 17 | authorization. 18 | 19 | Installation 20 | ============ 21 | 22 | Install from PyPI with ``easy_install`` or ``pip``:: 23 | 24 | pip install django-lockdown 25 | 26 | To use ``django-lockdown`` in your Django project: 27 | 28 | 1. Add ``'lockdown'`` to your ``INSTALLED_APPS``. 29 | If you want to use one of ``django-lockdowns`` default lock down forms, 30 | you'll additionally have to ensure that you have enabled 31 | ``django.contrib.auth`` as part of to your ``INSTALLED_APPS``. 32 | 33 | 2. To enable admin preview of locked-down sites or views with 34 | passwords, set the `LOCKDOWN_PASSWORDS`_ setting to a tuple of one or 35 | more plain-text passwords. 36 | 37 | 3. Protect the entire site by using middleware, or protect individual views 38 | by applying a decorator to them. 39 | 40 | For more advanced customization of admin preview authorization, see 41 | the `LOCKDOWN_FORM`_ setting. 42 | 43 | Dependencies 44 | ------------ 45 | 46 | ``django-lockdown`` requires `Python`_ 3.9 or later and `Django`_ 4.2 or later. 47 | 48 | As an alternative to CPython `PyPy`_ 3.10 is supported as well. 49 | 50 | .. _Python: https://www.python.org/ 51 | .. _Django: https://www.djangoproject.com/ 52 | .. _PyPy: https://pypy.org/ 53 | 54 | Usage 55 | ===== 56 | 57 | Using the middleware 58 | -------------------- 59 | 60 | To lock down the entire site, add the lockdown middleware to your middlewares:: 61 | 62 | MIDDLEWARE = [ 63 | # ... 64 | 'lockdown.middleware.LockdownMiddleware', 65 | ] 66 | 67 | Optionally, you may also add URL regular expressions to a 68 | `LOCKDOWN_URL_EXCEPTIONS`_ setting. 69 | 70 | Using the decorator 71 | ------------------- 72 | 73 | - Import the decorator:: 74 | 75 | from lockdown.decorators import lockdown 76 | 77 | - Apply the decorator to individual views you want to protect. For example:: 78 | 79 | @lockdown() 80 | def secret_page(request): 81 | # ... 82 | 83 | The decorator accepts seven arguments: 84 | 85 | ``form`` 86 | The form to use for providing an admin preview, rather than the form 87 | referenced by `LOCKDOWN_FORM`_. Note that this must be an actual form class, 88 | not a module reference like the setting. 89 | 90 | ``until_date`` 91 | The date to use rather than the date provided by `LOCKDOWN_UNTIL`_. 92 | 93 | ``after_date`` 94 | The date to use rather than the date provided by `LOCKDOWN_AFTER`_. 95 | 96 | ``logout_key`` 97 | A preview logout key to use, rather than the one provided by 98 | `LOCKDOWN_LOGOUT_KEY`_. 99 | 100 | ``session_key`` 101 | The session key to use, rather than the one provided by 102 | `LOCKDOWN_SESSION_KEY`_. 103 | 104 | ``url_exceptions`` 105 | A list of regular expressions for which matching urls can bypass the lockdown 106 | (rather than using those defined in `LOCKDOWN_URL_EXCEPTIONS`_). 107 | 108 | ``remote_addr_exceptions`` 109 | A list of IP-addresses or IP-subnets for which matching URLs can bypass the 110 | lockdown (rather than using those defined in 111 | `LOCKDOWN_REMOTE_ADDR_EXCEPTIONS`_). 112 | 113 | ``extra_context`` 114 | A dictionary of context data that will be added to the default context data 115 | passed to the template. 116 | 117 | Any further keyword arguments are passed to the admin preview form. The default 118 | form accepts one argument: 119 | 120 | ``passwords`` 121 | A tuple of passwords to use, rather than the ones provided by 122 | `LOCKDOWN_PASSWORDS`_. 123 | 124 | 125 | Settings 126 | ======== 127 | 128 | LOCKDOWN_ENABLED 129 | ---------------- 130 | 131 | An optional boolean value that, if set to False, disables 132 | ``django-lockdown`` globally. Defaults to True (lock down enabled). 133 | 134 | 135 | LOCKDOWN_PASSWORDS 136 | ------------------ 137 | 138 | One or more plain-text passwords which allow the previewing of the site or 139 | views protected by django-lockdown:: 140 | 141 | LOCKDOWN_PASSWORDS = ('letmein', 'beta') 142 | 143 | If this setting is not provided (and the default `LOCKDOWN_FORM`_ is being 144 | used), there will be no admin preview for locked-down pages. 145 | 146 | If a `LOCKDOWN_FORM`_ other than the default is used, this setting has no 147 | effect. 148 | 149 | LOCKDOWN_URL_EXCEPTIONS 150 | ----------------------- 151 | 152 | An optional list/tuple of regular expressions to be matched against incoming 153 | URLs. If a URL matches a regular expression in this list, it will not be 154 | locked. For example:: 155 | 156 | LOCKDOWN_URL_EXCEPTIONS = ( 157 | r'^/about/$', # unlock /about/ 158 | r'\.json$', # unlock JSON API 159 | ) 160 | 161 | LOCKDOWN_VIEW_EXCEPTIONS 162 | ------------------------ 163 | 164 | An optional list of regular expressions to be matched against the 165 | resolved views of incoming requests. If the URL of an incoming request 166 | resolves to one of the views in the list, it will not be locked. 167 | That's useful if you want to lock down a whole site using the middleware, 168 | but want to whitelist some localized URLs. 169 | 170 | For example:: 171 | 172 | from yourapp import one_view_to_unlock, another_view_to_unlock 173 | 174 | LOCKDOWN_VIEW_EXCEPTIONS = [ 175 | one_view_to_unlock, 176 | another_view_to_unlock 177 | ] 178 | 179 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS 180 | ------------------------------- 181 | 182 | An optional list of IP-addresses or IP-subnets to be matched against the 183 | requesting IP-address (from `requests.META['REMOTE_ADDR']`). If the 184 | requesting IP-address is in this list, it will not be locked. For example:: 185 | 186 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS = [ 187 | '127.0.0.1', 188 | '::1', 189 | ] 190 | 191 | LOCKDOWN_TRUSTED_PROXIES 192 | ------------------------------- 193 | 194 | A list of trusted proxy IP-addresses to be used in conjunction with 195 | `LOCKDOWN_REMOTE_ADDR_EXCEPTIONS` when a reverse-proxy or load balancer is used. 196 | If the requesting IP address is from the trusted proxies list the last address from 197 | the `X-Forwared-For` header (from `requests.META['HTTP_X_FORWARDED_FOR']`) will be 198 | checked against `LOCKDOWN_REMOTE_ADDR_EXCEPTIONS` and locked or unlocked accordingly. 199 | 200 | For example:: 201 | 202 | LOCKDOWN_TRUSTED_PROXIES = [ 203 | '172.17.0.1', 204 | ] 205 | 206 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS = [ 207 | '172.17.0.5', 208 | ] 209 | 210 | LOCKDOWN_UNTIL 211 | -------------- 212 | 213 | Used to lock the site down up until a certain date. Set to a 214 | ``datetime.datetime`` object. 215 | 216 | If neither ``LOCKDOWN_UNTIL`` nor `LOCKDOWN_AFTER`_ is provided (the default), 217 | the site or views will always be locked. 218 | 219 | LOCKDOWN_AFTER 220 | -------------- 221 | 222 | Used to lock the site down after a certain date. Set to a ``datetime.datetime`` 223 | object. 224 | 225 | See also: `LOCKDOWN_UNTIL`_. 226 | 227 | LOCKDOWN_LOGOUT_KEY 228 | ------------------- 229 | 230 | A key which, if provided in the query string of a locked URL, will log out the 231 | user from the preview. 232 | 233 | LOCKDOWN_FORM 234 | ------------- 235 | 236 | The default lockdown form allows admin preview by entering a preset 237 | plain-text password (checked, by default, against the `LOCKDOWN_PASSWORDS`_ 238 | setting). To set up more advanced methods of authenticating access to 239 | locked-down pages, set ``LOCKDOWN_FORM`` to the Python dotted path to a Django 240 | ``Form`` subclass. This form will be displayed on the lockout page. If the form 241 | validates when submitted, the user will be allowed access to locked pages:: 242 | 243 | LOCKDOWN_FORM = 'path.to.my.CustomLockdownForm' 244 | 245 | A form for authenticating against ``django.contrib.auth`` users is provided 246 | with django-lockdown (use ``LOCKDOWN_FORM = 'lockdown.forms.AuthForm'``). It 247 | accepts two keyword arguments (in the ``lockdown`` decorator): 248 | 249 | ``staff_only`` 250 | Only allow staff members to preview. Defaults to ``True`` (but the default 251 | can be provided as a `LOCKDOWN_AUTHFORM_STAFF_ONLY`_ setting). 252 | 253 | ``superusers_only`` 254 | Only allow superusers to preview. Defaults to ``False`` (but the default 255 | can be provided as a `LOCKDOWN_AUTHFORM_SUPERUSERS_ONLY`_ setting). 256 | 257 | LOCKDOWN_AUTHFORM_STAFF_ONLY 258 | ---------------------------- 259 | 260 | If using ``lockdown.forms.AuthForm`` and this setting is ``True``, only staff 261 | users will be allowed to preview (True by default). 262 | 263 | Has no effect if not using ``lockdown.forms.AuthForm``. 264 | 265 | LOCKDOWN_AUTHFORM_SUPERUSERS_ONLY 266 | --------------------------------- 267 | 268 | If using ``lockdown.forms.AuthForm`` and this setting is ``True``, only 269 | superusers will be allowed to preview (False by default). Has no effect if not 270 | using ``lockdown.forms.AuthForm``. 271 | 272 | LOCKDOWN_SESSION_KEY 273 | -------------------- 274 | 275 | Once a client is authorized for admin preview, they will continue to 276 | be authorized for the remainder of their browsing session (using 277 | Django's built-in session support). ``LOCKDOWN_SESSION_KEY`` defines 278 | the session key used; the default is ``'lockdown-allow'``. 279 | 280 | 281 | Templates 282 | ========= 283 | 284 | ``django-lockdown`` uses a single template, ``lockdown/form.html``. The 285 | default template displays a simple "coming soon" message and the 286 | preview authorization form, if a password via `LOCKDOWN_PASSWORDS`_ is set. 287 | 288 | If you want to use a different template, you can use Djangos template 289 | `loaders`_ to specify a path inside your project to search for templates, 290 | before searching for templates included in ``django-lockdown``. 291 | 292 | In your overwritten template the lockdown preview form is available in the 293 | template context as ``form``. 294 | 295 | .. _loaders: https://docs.djangoproject.com/en/2.1/ref/templates/api/#template-loaders 296 | -------------------------------------------------------------------------------- /lockdown/tests/tests.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name,too-many-public-methods 2 | 3 | import datetime 4 | 5 | from django.conf import settings as django_settings 6 | from django.contrib.auth.models import \ 7 | User # pylint: disable=imported-auth-user 8 | from django.core.exceptions import ImproperlyConfigured 9 | from django.test import TestCase, override_settings 10 | 11 | from lockdown import middleware 12 | from lockdown.forms import AuthForm 13 | from lockdown.tests.views import a_view 14 | 15 | 16 | class BaseTests(TestCase): 17 | """Base tests for lockdown functionality. 18 | 19 | These base tests are used for testing lockdowns decorator and middleware 20 | functionality. 21 | 22 | Subclasses should provide ``locked_url`` and ``locked_contents`` 23 | attributes. 24 | """ 25 | 26 | locked_url = '/locked/view/' 27 | locked_contents = b'A locked view.' 28 | 29 | def test_lockdown_template_used(self): 30 | """Test if the login form template is used on locked pages.""" 31 | response = self.client.get(self.locked_url) 32 | self.assertTemplateUsed(response, 'lockdown/form.html') 33 | 34 | @override_settings(LOCKDOWN_PASSWORDS=('letmein',)) 35 | def test_form_in_context(self): 36 | """Test if the login form contains a proper password field.""" 37 | response = self.client.get(self.locked_url) 38 | form = response.context['form'] 39 | self.assertIn('password', form.fields) 40 | 41 | @override_settings(LOCKDOWN_ENABLED=False) 42 | def test_global_disable(self): 43 | """Test that a page isn't locked when LOCKDOWN_ENABLED=False.""" 44 | response = self.client.get(self.locked_url) 45 | self.assertEqual(response.content, self.locked_contents) 46 | 47 | @override_settings(LOCKDOWN_URL_EXCEPTIONS=(r'/view/$',)) 48 | def test_url_exceptions(self): 49 | """Test that a page isn't locked when its URL is in the exception list. 50 | 51 | The excepted URLs are determined by the LOCKDOWN_URL_EXCEPTIONS 52 | setting. 53 | """ 54 | response = self.client.get(self.locked_url) 55 | self.assertEqual(response.content, self.locked_contents) 56 | 57 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.1']) 58 | def test_remote_addr_exc_lock(self): 59 | """Test that a page is locked when client IP is not in exception list. 60 | 61 | The excepted IP-addresses are determined by the 62 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS setting. 63 | """ 64 | response = self.client.get(self.locked_url, 65 | REMOTE_ADDR='192.168.0.100') 66 | self.assertNotEqual(response.content, self.locked_contents) 67 | 68 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.1']) 69 | def test_remote_addr_exc_unlock(self): 70 | """Test that a page isn't locked when client IP is in exception list. 71 | 72 | The excepted IP-addresses are determined by the 73 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS setting. 74 | """ 75 | response = self.client.get(self.locked_url, REMOTE_ADDR='192.168.0.1') 76 | self.assertEqual(response.content, self.locked_contents) 77 | 78 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::1']) 79 | def test_remote_addr_exc_unlock_ipv6(self): 80 | """Test that a page isn't locked when client IP is in exception list. 81 | 82 | The excepted IP-addresses are determined by the 83 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS setting. 84 | """ 85 | response = self.client.get( 86 | self.locked_url, 87 | REMOTE_ADDR='fd:0000:0000:0000:0000:0000:0000:0001') 88 | self.assertEqual(response.content, self.locked_contents) 89 | 90 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.0/24']) 91 | def test_remote_addr_exc_unlock_subnet(self): 92 | """Test that a page isn't locked when client IP is in exception list. 93 | 94 | The excepted IP-subnets are determined by the 95 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS setting. 96 | """ 97 | response = self.client.get(self.locked_url, REMOTE_ADDR='192.168.0.1') 98 | self.assertEqual(response.content, self.locked_contents) 99 | 100 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::0/80']) 101 | def test_remote_addr_exc_unlock_ipv6_subnet(self): 102 | """Test that a page isn't locked when client IP is in exception list. 103 | 104 | The excepted IP-subnets are determined by the 105 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS setting. 106 | """ 107 | response = self.client.get( 108 | self.locked_url, 109 | REMOTE_ADDR='fd:0000:0000:0000:0000:0000:0000:0001') 110 | self.assertEqual(response.content, self.locked_contents) 111 | 112 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.1'], 113 | LOCKDOWN_TRUSTED_PROXIES=['127.0.0.1']) 114 | def test_untrusted_proxy_lock(self): 115 | """Test that a page is locked when proxy is not trusted. 116 | 117 | The excepted IP-addresses are determined by the 118 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 119 | """ 120 | response = self.client.get(self.locked_url, 121 | REMOTE_ADDR='172.17.0.1', 122 | HTTP_X_FORWARDED_FOR='192.168.0.1') 123 | self.assertNotEqual(response.content, self.locked_contents) 124 | 125 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::2'], 126 | LOCKDOWN_TRUSTED_PROXIES=['fd::1']) 127 | def test_untrusted_proxy_lock_ipv6(self): 128 | """Test that a page is locked when proxy is not trusted. 129 | 130 | The excepted IP-addresses are determined by the 131 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 132 | """ 133 | response = self.client.get( 134 | self.locked_url, REMOTE_ADDR='2001:db8::ff00:42:8329', 135 | HTTP_X_FORWARDED_FOR='fd:0000:0000:0000:0000:0000:0000:0001') 136 | self.assertNotEqual(response.content, self.locked_contents) 137 | 138 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.0/24'], 139 | LOCKDOWN_TRUSTED_PROXIES=['127.0.0.0/24']) 140 | def test_untrusted_proxy_lock_subnet(self): 141 | """Test that a page is locked when proxy is not trusted. 142 | 143 | The excepted IP-subnets are determined by the 144 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 145 | """ 146 | response = self.client.get(self.locked_url, 147 | REMOTE_ADDR='172.17.0.1', 148 | HTTP_X_FORWARDED_FOR='192.168.0.1') 149 | self.assertNotEqual(response.content, self.locked_contents) 150 | 151 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::2'], 152 | LOCKDOWN_TRUSTED_PROXIES=['fd::1']) 153 | def test_untrusted_proxy_lock_ipv6_subnet(self): 154 | """Test that a page is locked when proxy is not trusted. 155 | 156 | The excepted IP-subnets are determined by the 157 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 158 | """ 159 | response = self.client.get( 160 | self.locked_url, REMOTE_ADDR='2001:db8::ff00:42:8329', 161 | HTTP_X_FORWARDED_FOR='fd:0000:0000:0000:0000:0000:0000:0001') 162 | self.assertNotEqual(response.content, self.locked_contents) 163 | 164 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.1']) 165 | def test_no_trusted_proxy_lock(self): 166 | """Test that a page is locked when x-forwarded-for is used w/o proxies. 167 | 168 | The excepted IP-addresses are determined by the 169 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 170 | """ 171 | response = self.client.get(self.locked_url, 172 | REMOTE_ADDR='10.0.0.1', 173 | HTTP_X_FORWARDED_FOR='192.168.0.1') 174 | self.assertNotEqual(response.content, self.locked_contents) 175 | 176 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::0/80']) 177 | def test_no_trusted_proxy_lock_ipv6(self): 178 | """Test that a page is locked when x-forwarded-for is used w/o proxies. 179 | 180 | The excepted IP-addresses are determined by the 181 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 182 | """ 183 | response = self.client.get( 184 | self.locked_url, 185 | REMOTE_ADDR='2001:db8::ff00:42:8329', 186 | HTTP_X_FORWARDED_FOR='fd:0000:0000:0000:0000:0000:0000:0001') 187 | self.assertNotEqual(response.content, self.locked_contents) 188 | 189 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.0/24']) 190 | def test_no_trusted_proxy_lock_subnet(self): 191 | """Test that a page is locked when x-forwarded-for is used w/o proxies. 192 | 193 | The excepted IP-subnets are determined by the 194 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 195 | """ 196 | response = self.client.get(self.locked_url, 197 | REMOTE_ADDR='10.0.0.1', 198 | HTTP_X_FORWARDED_FOR='192.168.0.100') 199 | self.assertNotEqual(response.content, self.locked_contents) 200 | 201 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::0/80']) 202 | def test_no_trusted_proxy_lock_ipv6_subnet(self): 203 | """Test that a page is locked when x-forwarded-for is used w/o proxies. 204 | 205 | The excepted IP-subnets are determined by the 206 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 207 | """ 208 | response = self.client.get( 209 | self.locked_url, 210 | REMOTE_ADDR='fe::1', 211 | HTTP_X_FORWARDED_FOR='fd::2') 212 | self.assertNotEqual(response.content, self.locked_contents) 213 | 214 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.1'], 215 | LOCKDOWN_TRUSTED_PROXIES=['10.0.0.1']) 216 | def test_trusted_proxy_unlock(self): 217 | """Test that a page isn't locked when client IP is in exception list. 218 | 219 | The excepted IP-addresses are determined by the 220 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 221 | """ 222 | response = self.client.get(self.locked_url, 223 | REMOTE_ADDR='10.0.0.1', 224 | HTTP_X_FORWARDED_FOR='192.168.0.1') 225 | self.assertEqual(response.content, self.locked_contents) 226 | 227 | @override_settings( 228 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=[ 229 | 'fd:0000:0000:0000:0000:0000:0000:0001'], 230 | LOCKDOWN_TRUSTED_PROXIES=['fd:0000:0000:0000:0000:0000:0000:0002']) 231 | def test_trusted_proxy_unlock_ipv6(self): 232 | """Test that a page isn't locked when client IP is in exception list. 233 | 234 | The excepted IP-addresses are determined by the 235 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 236 | """ 237 | response = self.client.get( 238 | self.locked_url, 239 | REMOTE_ADDR='fd:0000:0000:0000:0000:0000:0000:0002', 240 | HTTP_X_FORWARDED_FOR='fd:0000:0000:0000:0000:0000:0000:0001') 241 | self.assertEqual(response.content, self.locked_contents) 242 | 243 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['192.168.0.0/24'], 244 | LOCKDOWN_TRUSTED_PROXIES=['127.0.0.0/24']) 245 | def test_trusted_proxy_unlock_subnet(self): 246 | """Test that a page isn't locked when client IP is in exception list. 247 | 248 | The excepted IP-subnets are determined by the 249 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 250 | """ 251 | response = self.client.get(self.locked_url, 252 | REMOTE_ADDR='127.0.0.1', 253 | HTTP_X_FORWARDED_FOR='192.168.0.1') 254 | self.assertEqual(response.content, self.locked_contents) 255 | 256 | @override_settings(LOCKDOWN_REMOTE_ADDR_EXCEPTIONS=['fd::0/80'], 257 | LOCKDOWN_TRUSTED_PROXIES=['fe::0/80']) 258 | def test_trusted_proxy_unlock_ipv6_subnet(self): 259 | """Test that a page isn't locked when client IP is in exception list. 260 | 261 | The excepted IP-subnets are determined by the 262 | LOCKDOWN_REMOTE_ADDR_EXCEPTIONS and TRUSTED_PROXIES settings. 263 | """ 264 | response = self.client.get( 265 | self.locked_url, 266 | REMOTE_ADDR='fe::2', 267 | HTTP_X_FORWARDED_FOR='fd::1') 268 | self.assertEqual(response.content, self.locked_contents) 269 | 270 | @override_settings(LOCKDOWN_PASSWORDS=('letmein',)) 271 | def test_submit_password(self): 272 | """Test that access to locked content works with a correct password.""" 273 | response = self.client.post(self.locked_url, {'password': 'letmein'}, 274 | follow=True) 275 | self.assertEqual(response.content, self.locked_contents) 276 | 277 | @override_settings(LOCKDOWN_PASSWORDS=('letmein',)) 278 | def test_submit_wrong_password(self): 279 | """Test access to locked content is denied for wrong passwords.""" 280 | response = self.client.post(self.locked_url, {'password': 'imacrook'}) 281 | self.assertContains(response, 'Incorrect password.') 282 | 283 | @override_settings(LOCKDOWN_FORM='lockdown.tests.forms.CustomLockdownForm') 284 | def test_custom_form(self): 285 | """Test if access using a custom lockdown form works.""" 286 | response = self.client.post(self.locked_url, {'answer': '42'}, 287 | follow=True) 288 | self.assertEqual(response.content, self.locked_contents) 289 | 290 | def test_invalid_custom_form(self): 291 | """Test that pointing to an invalid form properly produces an error.""" 292 | # no form configured at all 293 | self.assertRaises(ImproperlyConfigured, 294 | middleware.get_lockdown_form, None) 295 | # invalid module name in the configured form 296 | self.assertRaises(ImproperlyConfigured, 297 | middleware.get_lockdown_form, 'invalidform') 298 | # not existing module for form 299 | self.assertRaises(ImproperlyConfigured, 300 | middleware.get_lockdown_form, 'invalid.form') 301 | # existing module, but no form with that name in the module 302 | self.assertRaises(ImproperlyConfigured, 303 | middleware.get_lockdown_form, 'lockdown.forms.foo') 304 | 305 | def test_locked_until(self): 306 | """Test locking until a certain date.""" 307 | yesterday = datetime.datetime.now() - datetime.timedelta(days=1) 308 | tomorrow = datetime.datetime.now() + datetime.timedelta(days=1) 309 | 310 | with self.settings(LOCKDOWN_UNTIL_DATE=tomorrow): 311 | response = self.client.get(self.locked_url) 312 | self.assertTemplateUsed(response, 'lockdown/form.html') 313 | 314 | with self.settings(LOCKDOWN_UNTIL_DATE=yesterday): 315 | response = self.client.get(self.locked_url) 316 | self.assertEqual(response.content, self.locked_contents) 317 | 318 | def test_locked_after(self): 319 | """Test locking starting at a certain date.""" 320 | yesterday = datetime.datetime.now() - datetime.timedelta(days=1) 321 | tomorrow = datetime.datetime.now() + datetime.timedelta(days=1) 322 | 323 | with self.settings(LOCKDOWN_AFTER_DATE=yesterday): 324 | response = self.client.get(self.locked_url) 325 | self.assertTemplateUsed(response, 'lockdown/form.html') 326 | 327 | with self.settings(LOCKDOWN_AFTER_DATE=tomorrow): 328 | response = self.client.get(self.locked_url) 329 | self.assertEqual(response.content, self.locked_contents) 330 | 331 | def test_locked_until_and_after(self): 332 | """Test locking until a certain date and starting at another date.""" 333 | yesterday = datetime.datetime.now() - datetime.timedelta(days=1) 334 | tomorrow = datetime.datetime.now() + datetime.timedelta(days=1) 335 | 336 | with self.settings(LOCKDOWN_UNTIL_DATE=yesterday, 337 | LOCKDOWN_AFTER_DATE=yesterday): 338 | response = self.client.get(self.locked_url) 339 | self.assertTemplateUsed(response, 'lockdown/form.html') 340 | 341 | with self.settings(LOCKDOWN_UNTIL_DATE=tomorrow, 342 | LOCKDOWN_AFTER_DATE=tomorrow): 343 | response = self.client.get(self.locked_url) 344 | self.assertTemplateUsed(response, 'lockdown/form.html') 345 | 346 | with self.settings(LOCKDOWN_UNTIL_DATE=yesterday, 347 | LOCKDOWN_AFTER_DATE=tomorrow): 348 | response = self.client.get(self.locked_url) 349 | self.assertEqual(response.content, self.locked_contents) 350 | 351 | def test_missing_session_middleware(self): 352 | """Test behavior with missing session middleware. 353 | 354 | When the session middleware isn't present an ImproperlyConfigured error 355 | is expected. 356 | """ 357 | middleware_remove = { 358 | 'remove': [ 359 | 'django.contrib.sessions.middleware.SessionMiddleware', 360 | 'django.contrib.auth.middleware.AuthenticationMiddleware' 361 | ] 362 | } 363 | 364 | with self.modify_settings(MIDDLEWARE=middleware_remove): 365 | self.assertRaises(ImproperlyConfigured, 366 | self.client.get, 367 | self.locked_url) 368 | 369 | 370 | class DecoratorTests(BaseTests): 371 | """Tests for using lockdown via decorators.""" 372 | 373 | def test_overridden_password(self): 374 | """Test that locking works when overriding the password.""" 375 | url = '/overridden/locked/view/' 376 | 377 | response = self.client.post(url, {'password': 'letmein'}, follow=True) 378 | self.assertTemplateUsed(response, 'lockdown/form.html') 379 | 380 | response = self.client.post(url, {'password': 'squirrel'}, follow=True) 381 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 382 | self.assertEqual(response.content, self.locked_contents) 383 | 384 | def test_overridden_url_exceptions(self): 385 | """Test that locking works when overriding the url exceptions.""" 386 | url = '/locked/view/with/exception1/' 387 | response = self.client.post(url, follow=True) 388 | self.assertTemplateUsed(response, 'lockdown/form.html') 389 | 390 | url = '/locked/view/with/exception2/' 391 | response = self.client.post(url, follow=True) 392 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 393 | self.assertEqual(response.content, self.locked_contents) 394 | 395 | def test_overridden_ip_exceptions(self): 396 | """Test that locking works with overwritten remote_addr exceptions.""" 397 | url = '/locked/view/with/ip_exception/' 398 | response = self.client.post(url, REMOTE_ADDR='192.168.0.100', 399 | follow=True) 400 | self.assertTemplateUsed(response, 'lockdown/form.html') 401 | 402 | url = '/locked/view/with/ip_exception/' 403 | response = self.client.post(url, REMOTE_ADDR='192.168.0.1', 404 | follow=True) 405 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 406 | self.assertEqual(response.content, self.locked_contents) 407 | 408 | def test_overridden_ip_exceptions_ipv6(self): 409 | """Test that locking works with overwritten remote_addr exceptions.""" 410 | url = '/locked/view/with/ip_exception_ipv6/' 411 | response = self.client.post( 412 | url, REMOTE_ADDR='fd:0000:0000:0000:0000:0000:0000:0002', 413 | follow=True) 414 | self.assertTemplateUsed(response, 'lockdown/form.html') 415 | 416 | url = '/locked/view/with/ip_exception_ipv6/' 417 | response = self.client.post( 418 | url, REMOTE_ADDR='fd:0000:0000:0000:0000:0000:0000:0001', 419 | follow=True) 420 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 421 | self.assertEqual(response.content, self.locked_contents) 422 | 423 | def test_overridden_ip_exceptions_subnet(self): 424 | """Test that locking works with overwritten remote_addr exceptions.""" 425 | url = '/locked/view/with/ip_exception_subnet/' 426 | response = self.client.post(url, REMOTE_ADDR='172.0.0.1', 427 | follow=True) 428 | self.assertTemplateUsed(response, 'lockdown/form.html') 429 | 430 | url = '/locked/view/with/ip_exception_subnet/' 431 | response = self.client.post(url, REMOTE_ADDR='192.168.0.1', 432 | follow=True) 433 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 434 | self.assertEqual(response.content, self.locked_contents) 435 | 436 | def test_overridden_ip_exceptions_subnet_ipv6(self): 437 | """Test that locking works with overwritten remote_addr exceptions.""" 438 | url = '/locked/view/with/ip_exception_ipv6_subnet/' 439 | response = self.client.post(url, REMOTE_ADDR='2001:db8::ff00:42:8329', 440 | follow=True) 441 | self.assertTemplateUsed(response, 'lockdown/form.html') 442 | 443 | url = '/locked/view/with/ip_exception_ipv6_subnet/' 444 | response = self.client.post(url, REMOTE_ADDR='fd::1', 445 | follow=True) 446 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 447 | self.assertEqual(response.content, self.locked_contents) 448 | 449 | def test_overridden_until_date(self): 450 | """Test that locking works when overriding the until date.""" 451 | url = '/locked/view/until/yesterday/' 452 | response = self.client.post(url, follow=True) 453 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 454 | self.assertEqual(response.content, self.locked_contents) 455 | 456 | url = '/locked/view/until/tomorrow/' 457 | response = self.client.post(url, follow=True) 458 | self.assertTemplateUsed(response, 'lockdown/form.html') 459 | 460 | def test_overridden_after_date(self): 461 | """Test that locking works when overriding the after date.""" 462 | url = '/locked/view/after/yesterday/' 463 | response = self.client.post(url, follow=True) 464 | self.assertTemplateUsed(response, 'lockdown/form.html') 465 | 466 | url = '/locked/view/after/tomorrow/' 467 | response = self.client.post(url, follow=True) 468 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 469 | self.assertEqual(response.content, self.locked_contents) 470 | 471 | def test_overridden_both_dates(self): 472 | """Test that locking works when overriding the after date.""" 473 | url = '/locked/view/until/and/after/' 474 | response = self.client.post(url, follow=True) 475 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 476 | self.assertEqual(response.content, self.locked_contents) 477 | 478 | def test_overridden_extra_context(self): 479 | """Test that locking works when overriding the extra context.""" 480 | url = '/locked/view/with/extra/context/' 481 | response = self.client.get(url) 482 | self.assertIn('foo', response.context) 483 | 484 | 485 | class MiddlewareTests(BaseTests): 486 | """Tests for using lockdown via its middleware.""" 487 | 488 | locked_url = '/a/view/' 489 | locked_contents = b'A view.' 490 | 491 | def setUp(self): 492 | """Additional setup for middleware tests.""" 493 | super().setUp() 494 | self._old_middleware_classes = django_settings.MIDDLEWARE 495 | django_settings.MIDDLEWARE.append( 496 | 'lockdown.middleware.LockdownMiddleware', 497 | ) 498 | 499 | @override_settings(LOCKDOWN_VIEW_EXCEPTIONS=[a_view]) 500 | def test_view_exceptions(self): 501 | """Test that a page isn't locked when its view whitelisted. 502 | 503 | The excepted URLs are determined by the 504 | LOCKDOWN_VIEW_EXCEPTIONS setting. 505 | """ 506 | response = self.client.get(self.locked_url) 507 | self.assertEqual(response.content, self.locked_contents) 508 | 509 | def tearDown(self): 510 | """Additional tear down for middleware tests.""" 511 | django_settings.MIDDLEWARE = self._old_middleware_classes 512 | super().tearDown() 513 | 514 | 515 | class AuthFormTests(TestCase): 516 | """Tests for using the auth form for previewing locked pages.""" 517 | 518 | def test_using_form(self): 519 | """Test unauthorized access to locked page. 520 | 521 | Unauthorized access to a to locked page should show the auth form 522 | """ 523 | url = '/auth/user/locked/view/' 524 | response = self.client.get(url) 525 | self.assertTemplateUsed(response, 'lockdown/form.html') 526 | 527 | form = response.context['form'] 528 | self.assertTrue(isinstance(form, AuthForm)) 529 | 530 | def add_user(self, username='test', password='pw', **kwargs): # nosec 531 | """Add a user used for testing the auth form.""" 532 | user = User(username=username, **kwargs) 533 | user.set_password(password) 534 | user.save() 535 | 536 | def test_inactive_user(self): 537 | """Test access to a locked page with an inactive user.""" 538 | url = '/auth/user/locked/view/' 539 | self.add_user(is_active=False) 540 | 541 | post_data = {'username': 'test', 'password': 'pw'} 542 | response = self.client.post(url, post_data, follow=True) 543 | self.assertTemplateUsed(response, 'lockdown/form.html') 544 | 545 | def test_user(self): 546 | """Test access to a locked page which requires authorization.""" 547 | url = '/auth/user/locked/view/' 548 | self.add_user() 549 | 550 | # Incorrect password. 551 | post_data = {'username': 'test', 'password': 'bad'} 552 | response = self.client.post(url, post_data, follow=True) 553 | self.assertTemplateUsed(response, 'lockdown/form.html') 554 | 555 | # Correct password. 556 | post_data = {'username': 'test', 'password': 'pw'} 557 | response = self.client.post(url, post_data, follow=True) 558 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 559 | 560 | def test_staff(self): 561 | """Test access to a locked page which requires a staff user.""" 562 | url = '/auth/staff/locked/view/' 563 | self.add_user(username='user') 564 | self.add_user(username='staff', is_staff=True) 565 | 566 | # Non-staff member. 567 | post_data = {'username': 'user', 'password': 'pw'} 568 | response = self.client.post(url, post_data, follow=True) 569 | self.assertTemplateUsed(response, 'lockdown/form.html') 570 | 571 | # Incorrect password. 572 | post_data = {'username': 'staff', 'password': 'bad'} 573 | response = self.client.post(url, post_data, follow=True) 574 | self.assertTemplateUsed(response, 'lockdown/form.html') 575 | 576 | # Correct password. 577 | post_data = {'username': 'staff', 'password': 'pw'} 578 | response = self.client.post(url, post_data, follow=True) 579 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 580 | 581 | def test_superuser(self): 582 | """Test access to a locked page which requires a superuser.""" 583 | url = '/auth/superuser/locked/view/' 584 | self.add_user(username='staff', is_staff=True) 585 | self.add_user(username='superuser', is_staff=True, is_superuser=True) 586 | 587 | # Non-superuser. 588 | post_data = {'username': 'staff', 'password': 'pw'} 589 | response = self.client.post(url, post_data, follow=True) 590 | self.assertTemplateUsed(response, 'lockdown/form.html') 591 | 592 | # Incorrect password. 593 | post_data = {'username': 'superuser', 'password': 'bad'} 594 | response = self.client.post(url, post_data, follow=True) 595 | self.assertTemplateUsed(response, 'lockdown/form.html') 596 | 597 | # Correct password. 598 | post_data = {'username': 'superuser', 'password': 'pw'} 599 | response = self.client.post(url, post_data, follow=True) 600 | self.assertTemplateNotUsed(response, 'lockdown/form.html') 601 | 602 | 603 | # Remove the BaseTests class from the module namespace, so it won't get picked 604 | # up by unittest. 605 | del BaseTests 606 | --------------------------------------------------------------------------------