├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── baipw ├── __init__.py ├── exceptions.py ├── middleware.py ├── response.py ├── tests │ ├── __init__.py │ ├── response.py │ ├── settings.py │ ├── templates │ │ └── test_template.html │ ├── test_integration.py │ ├── test_middleware.py │ ├── test_response.py │ ├── test_utils.py │ ├── urls.py │ └── utils.py └── utils.py ├── ruff.toml ├── run_tests.py ├── setup.cfg ├── setup.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - "*" 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python 3 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install tox 27 | 28 | - name: Run tox 29 | env: 30 | TOXENV: ruff 31 | run: tox 32 | 33 | test: 34 | continue-on-error: ${{ matrix.experimental }} 35 | strategy: 36 | matrix: 37 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 38 | django-version: [42, 50, 51] 39 | experimental: [false] 40 | 41 | # Allow failures on Django main branch test. 42 | include: 43 | - python-version: "3.13" 44 | django-version: main 45 | experimental: true 46 | 47 | exclude: 48 | - django-version: 50 49 | python-version: "3.9" 50 | - django-version: 51 51 | python-version: "3.9" 52 | 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | 58 | - name: Set up Python ${{ matrix.python-version }} 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: ${{ matrix.python-version }} 62 | 63 | - name: Install dependencies 64 | run: | 65 | python -m pip install --upgrade pip 66 | pip install tox 67 | 68 | - name: Run tox - Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 69 | env: 70 | TOXENV: py-dj${{ matrix.django-version }} 71 | run: tox 72 | 73 | build: 74 | permissions: 75 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 76 | runs-on: ubuntu-latest 77 | needs: 78 | - lint 79 | - test 80 | steps: 81 | - uses: actions/checkout@v4 82 | with: 83 | fetch-depth: 0 84 | - name: Set up Python 85 | uses: actions/setup-python@v5 86 | with: 87 | python-version: 3 88 | - name: Install dependencies 89 | run: python -m pip install --upgrade pip build 90 | - name: Build package 91 | run: python -m build 92 | - name: Save built package 93 | uses: actions/upload-artifact@v4 94 | with: 95 | name: package 96 | path: dist 97 | - name: Publish to PyPi 98 | if: ${{ github.ref_type == 'tag' }} 99 | uses: pypa/gh-action-pypi-publish@release/v1 100 | with: 101 | print_hash: true 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | build/ 3 | dist/ 4 | .tox/ 5 | *.pyc 6 | *.egg-info 7 | *.sqlite3 8 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.8.0 - 10th March 2025 2 | ~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | * Prevent basic auth pages from being indexed 5 | 6 | 0.7.0 - 14th February 2025 7 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 8 | 9 | * Support multiple Authorization headers 10 | * Drop support for older Python / Django versions 11 | * Add official support for Python 3.13 and Django 5.1 12 | 13 | 0.6.0 - 18th June 2024 14 | ~~~~~~~~~~~~~~~~~~~~~~~~ 15 | 16 | * Defer IP detection to the user (breaking) 17 | * Add Django 5.0 compatibility 18 | * Transfer ownership to Torchbox 19 | 20 | 0.5 - 1st September 2022 21 | ~~~~~~~~~~~~~~~~~~~~~~~~ 22 | 23 | * Make default response use "never cache" header. 24 | * Add Django 4.0 support. 25 | 26 | 0.3.4 - 22nd June 2020 27 | ~~~~~~~~~~~~~~~~~~~~~~ 28 | 29 | * Fix potential timing attack if basic authentication is enabled (GHSA-m38j-pmg3-v5x5). 30 | 31 | 0.3.3 - 20th February 2020 32 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 33 | 34 | * Do not include tests in the package. 35 | 36 | 0.3.3a0 - 20th February 2020 37 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 38 | 39 | * Add `BASIC_AUTH_DISABLE_CONSUMING_AUTHORIZATION_HEADER` setting. 40 | 41 | 0.3.2a0 - 5th December 2019 42 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 43 | 44 | * Add Django 3 support. 45 | 46 | 0.3.1 - 18th July 2019 47 | ~~~~~~~~~~~~~~~~~~~~~~ 48 | 49 | * Include HTML and textual files in the package. 50 | * Delete "Authorization" header when it is used by the middleware. 51 | * Use CF-Connecting-IP HTTP header for checking the client's IP address. 52 | * Add `BASIC_AUTH_WHITELISTED_PATHS` setting. 53 | 54 | 0.3a0 - 23rd September 2018 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | * Don't crash on wrong authorization header format. 58 | * Add support for old-fashioned MIDDLEWARE_CLASSES. 59 | * Add overall Django 1.8, 1.9, 1.10 and 1.11 support. 60 | 61 | 0.2.1 - 20th July 2018 62 | ~~~~~~~~~~~~~~~~~~~~~~ 63 | 64 | * Use HttpRequest.get_host instead of HTTP_HOST. 65 | 66 | 0.2 - 7th June 2018 67 | ~~~~~~~~~~~~~~~~~~~ 68 | 69 | * Add HTTP host header whitelist (``BASIC_AUTH_RESPONSE_TEMPLATE``). 70 | * Add the ``BASIC_AUTH_REALM`` setting. 71 | * Add the ``BASIC_AUTH_RESPONSE_TEMPLATE`` setting. 72 | * Add the ``BASIC_AUTH_RESPONSE_CLASS`` setting. 73 | * Add an option to skip the middleware by setting ``_skip_basic_auth_ip_whitelist_middleware_check`` attribute on the request. 74 | 75 | 76 | 0.1 - Initial release 77 | ~~~~~~~~~~~~~~~~~~~~~ 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Torchbox and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.rst 2 | graft baipw 3 | prune baipw/tests 4 | global-exclude __pycache__ 5 | global-exclude *.py[co] 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-basic-auth-ip-whitelist 2 | ============================== 3 | 4 | .. image:: https://github.com/torchbox/django-basic-auth-ip-whitelist/actions/workflows/ci.yml/badge.svg 5 | :alt: GitHub actions CI status 6 | :target: https://github.com/torchbox/django-basic-auth-ip-whitelist/actions/ 7 | .. image:: https://img.shields.io/pypi/v/django-basic-auth-ip-whitelist.svg 8 | :target: https://pypi.org/project/django-basic-auth-ip-whitelist/ 9 | .. image:: https://img.shields.io/pypi/dm/django-basic-auth-ip-whitelist.svg 10 | :target: https://pypi.org/project/django-basic-auth-ip-whitelist/ 11 | 12 | This simple package ships middleware that lets you to set basic authentication 13 | and IP whitelisting via Django settings. 14 | 15 | Use case 16 | -------- 17 | 18 | This package has been created for staging and demo sites that need to be 19 | completely hidden from the Internet behind a password or accessible only to 20 | certain IP networks. 21 | 22 | Do not depend on this package to protect highly valuable information. This 23 | package is at a good way to disable staging sites being discovered by 24 | search engines and Internet users trying to access staging sites. It is 25 | advised that any sensitive information is protected using `Django authentication 26 | system `_. 27 | 28 | Requirements 29 | ------------ 30 | 31 | All supported versions of Python and Django are supported. 32 | 33 | Installation 34 | ------------ 35 | 36 | The package is on 37 | `PyPI `__ so you can 38 | just install it with pip. 39 | 40 | .. code:: sh 41 | 42 | pip install django-basic-auth-ip-whitelist 43 | 44 | Configuration 45 | ------------- 46 | 47 | In your Django settings you can configure the following settings: 48 | 49 | ``BASIC_AUTH_LOGIN`` and ``BASIC_AUTH_PASSWORD`` 50 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 51 | 52 | Credentials that you want to use with your basic authentication. 53 | 54 | ``BASIC_AUTH_WHITELISTED_IP_NETWORKS`` 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | Set a list of network ranges (strings) compatible with Python’s 58 | `ipaddress.ip_network `__ 59 | that you want to be able to access the website without authentication 60 | from. It must be either a string with networks separated by comma or 61 | Python iterable. 62 | 63 | **Warning**: See [Getting IP Address](#getting-ip-address) below for caveats around IP address detection. 64 | 65 | ``BASIC_AUTH_REALM`` 66 | ~~~~~~~~~~~~~~~~~~~~ 67 | 68 | String specifying the realm of the default response. 69 | 70 | Example settings 71 | ~~~~~~~~~~~~~~~~ 72 | 73 | .. code:: python 74 | 75 | MIDDLEWARE += [ 76 | 'baipw.middleware.BasicAuthIPWhitelistMiddleware' 77 | ] 78 | BASIC_AUTH_LOGIN = 'somelogin' 79 | BASIC_AUTH_PASSWORD = 'greatpassword' 80 | BASIC_AUTH_WHITELISTED_IP_NETWORKS = [ 81 | '192.168.0.0/28', 82 | '2001:db00::0/24', 83 | ] 84 | 85 | Advanced customisation 86 | ---------------------- 87 | 88 | Getting IP Address 89 | ~~~~~~~~~~~~~~~~~~ 90 | 91 | By default, ``BasicAuthIPWhitelistMiddleware`` uses ``request.META["REMOTE_ADDR"]`` 92 | as the client's IP, which corresponds to the IP address connecting to Django. 93 | If you have a reverse proxy (eg ``nginx`` in front), this will result in the IP address of 94 | ``nginx``, not the client. 95 | 96 | Correctly determining the IP address can vary between deployments. Guessing incorrectly can 97 | result in security issues. Instead, this library requires you configure this yourselves. 98 | 99 | In most deployments, the ``X-Forwarded-For`` header can be used to correctly determine the 100 | client's IP. We recommend `django-xff `__ to help parse this 101 | header correctly. Because ``django-xff`` overrides ``REMOTE_ADDR`` by default, it is natively 102 | supported by ``BasicAuthIPWhitelistMiddleware``. 103 | 104 | `django-ipware `__ is another popular 105 | library, however may take more customization to implement. 106 | 107 | To fully customize IP address detection, you can set ``BASIC_AUTH_GET_CLIENT_IP_FUNCTION`` to 108 | a function which takes a request and returns a valid IP address: 109 | 110 | .. code:: python 111 | 112 | BASIC_AUTH_GET_CLIENT_IP_FUNCTION = 'utils.ip.get_client_ip' 113 | 114 | 115 | ``BASIC_AUTH_WHITELISTED_HTTP_HOSTS`` 116 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 117 | 118 | Set a list of hosts that your website will be open to without basic 119 | authentication. This is useful if your website is hosted under multiple domains 120 | and you want only one of them to be publicly visible, e.g. by search engines. 121 | 122 | **This is by no means a security feature. Please do not use to secure your 123 | site.** 124 | 125 | .. code:: python 126 | 127 | BASIC_AUTH_WHITELISTED_HTTP_HOSTS = [ 128 | 'your-public-domain.com', 129 | ] 130 | 131 | 132 | ``BASIC_AUTH_WHITELISTED_PATHS`` 133 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 134 | 135 | Set a list of paths that your website will serve without basic authentication. 136 | This can be used to support API integrations for example with third-party 137 | services which don't support basic authentication. 138 | 139 | Paths listed in the setting ``BASIC_AUTH_WHITELISTED_PATHS`` are treated as roots, and any subpath will be whitelisted too. For example: 140 | 141 | .. code:: python 142 | 143 | BASIC_AUTH_WHITELISTED_PATHS = [ 144 | '/api', 145 | ] 146 | 147 | This will open up the path https://mydomain.com/api/, as well as anything 148 | below it, e.g. https://mydomain.com/api/document/1/. 149 | 150 | 151 | ``BASIC_AUTH_RESPONSE_TEMPLATE`` 152 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 153 | 154 | If you want to display a different template on the 401 page, please use this 155 | setting to point at the template. 156 | 157 | .. code:: python 158 | 159 | BASIC_AUTH_RESPONSE_TEMPLATE = '401.html' 160 | 161 | 162 | ``BASIC_AUTH_RESPONSE_CLASS`` 163 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 164 | 165 | If you want to specify custom response class, you can do so with this setting. 166 | Provide the path as a string. 167 | 168 | .. code:: python 169 | 170 | BASIC_AUTH_RESPONSE_CLASS = 'yourmodule.response.CustomUnathorisedResponse' 171 | 172 | 173 | ``BASIC_AUTH_DISABLE_CONSUMING_AUTHORIZATION_HEADER`` 174 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 175 | 176 | Set this setting to True if you want the Authorization HTTP header to not be deleted from the request object after it has been used by this package's middleware. 177 | 178 | .. code:: python 179 | 180 | BASIC_AUTH_DISABLE_CONSUMING_AUTHORIZATION_HEADER = True 181 | 182 | 183 | Skip middleware 184 | ~~~~~~~~~~~~~~~ 185 | 186 | You can skip the middleware by setting 187 | `_skip_basic_auth_ip_whitelist_middleware_check` attribute on the request to 188 | `True`. 189 | 190 | .. code:: python 191 | 192 | request._skip_basic_auth_ip_whitelist_middleware_check = True 193 | 194 | 195 | This may be handy if you have other middleware that you want to have 196 | co-existing different middleware that restrict access to the website. 197 | -------------------------------------------------------------------------------- /baipw/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.8.0" 2 | -------------------------------------------------------------------------------- /baipw/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | 3 | 4 | class Unauthorized(PermissionDenied): 5 | pass 6 | -------------------------------------------------------------------------------- /baipw/middleware.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | from django.conf import settings 4 | from django.utils.module_loading import import_string 5 | 6 | from .exceptions import Unauthorized 7 | from .response import HttpUnauthorizedResponse 8 | from .utils import authorize 9 | 10 | 11 | class BasicAuthIPWhitelistMiddleware: 12 | def __init__(self, get_response=None): 13 | self.get_response = get_response 14 | 15 | def __call__(self, request): 16 | response = None 17 | response = self.process_request(request) 18 | response = response or self.get_response(request) 19 | return response 20 | 21 | def process_request(self, request): 22 | # If this attribute is set, skip the check. 23 | if getattr(request, "_skip_basic_auth_ip_whitelist_middleware_check", False): 24 | return self.get_response(request) 25 | 26 | request._skip_basic_auth_ip_whitelist_middleware_check = True 27 | # Check if http host is whitelisted. 28 | if self._is_http_host_whitelisted(request): 29 | return 30 | # Check if path is whitelisted 31 | if self._is_path_whitelisted(request): 32 | return 33 | # Check if IP is whitelisted 34 | if self._is_ip_whitelisted(request): 35 | return 36 | # Fallback to basic auth if configured. 37 | if self._is_basic_auth_configured(): 38 | return self._basic_auth_response(request) 39 | # Otherwise just deny the access to the website 40 | return self.get_error_response(request) 41 | 42 | @property 43 | def basic_auth_login(self): 44 | return getattr(settings, "BASIC_AUTH_LOGIN", None) 45 | 46 | @property 47 | def basic_auth_password(self): 48 | return getattr(settings, "BASIC_AUTH_PASSWORD", None) 49 | 50 | def get_error_response(self, request): 51 | try: 52 | response_class = import_string(settings.BASIC_AUTH_RESPONSE_CLASS) 53 | except AttributeError: 54 | response_class = HttpUnauthorizedResponse 55 | 56 | response = response_class(request=request) 57 | 58 | # Ensure pages can't be indexed, even if authentication is enabled. 59 | response.headers["X-Robots-Tag"] = "noindex" 60 | 61 | return response 62 | 63 | def _basic_auth_response(self, request): 64 | try: 65 | authorize(request, self.basic_auth_login, self.basic_auth_password) 66 | except Unauthorized: 67 | return self.get_error_response(request) 68 | 69 | def _get_client_ip(self, request): 70 | function_path = getattr( 71 | settings, "BASIC_AUTH_GET_CLIENT_IP_FUNCTION", "baipw.utils.get_client_ip" 72 | ) 73 | return import_string(function_path)(request) 74 | 75 | def _get_whitelisted_networks(self): 76 | networks = getattr(settings, "BASIC_AUTH_WHITELISTED_IP_NETWORKS", []) 77 | # If we get a list, users probably passed a list of strings in 78 | # the settings, probably from the environment. 79 | if isinstance(networks, str): 80 | networks = networks.split(",") 81 | # Otherwise assume that the list is iterable. 82 | for network in networks: 83 | network = network.strip() 84 | if not network: 85 | continue 86 | yield ipaddress.ip_network(network) 87 | 88 | def _get_whitelisted_http_hosts(self): 89 | http_hosts = getattr(settings, "BASIC_AUTH_WHITELISTED_HTTP_HOSTS", []) 90 | # If we get a list, users probably passed a list of strings in 91 | # the settings, probably from the environment. 92 | if isinstance(http_hosts, str): 93 | http_hosts = http_hosts.split(",") 94 | # Otherwise assume that the list is iterable. 95 | for http_host in http_hosts: 96 | http_host = http_host.strip() 97 | if not http_host: 98 | continue 99 | yield http_host 100 | 101 | def _get_whitelisted_paths(self): 102 | paths = getattr(settings, "BASIC_AUTH_WHITELISTED_PATHS", []) 103 | # If we get a list, users probably passed a list of strings in 104 | # the settings, probably from the environment. 105 | if isinstance(paths, str): 106 | paths = paths.split(",") 107 | # Otherwise assume that the list is iterable. 108 | for path in paths: 109 | path = path.strip() 110 | if not path: 111 | continue 112 | yield path 113 | 114 | def _is_http_host_whitelisted(self, request): 115 | request_host = request.get_host() 116 | if not request_host: 117 | return False 118 | return request_host in self._get_whitelisted_http_hosts() 119 | 120 | def _is_path_whitelisted(self, request): 121 | """ 122 | Check if request.path is whitelisted. Subpaths are included. 123 | """ 124 | for path in self._get_whitelisted_paths(): 125 | if request.path.startswith(path): 126 | return True 127 | return False 128 | 129 | def _is_ip_whitelisted(self, request): 130 | """ 131 | Check if IP is on the whitelisted network. 132 | """ 133 | client_ip = self._get_client_ip(request) 134 | if client_ip is None: 135 | return False 136 | ip_address = ipaddress.ip_address(client_ip) 137 | for network in self._get_whitelisted_networks(): 138 | if ip_address in network: 139 | return True 140 | return False 141 | 142 | def _is_basic_auth_configured(self): 143 | """ 144 | Check basic authentication username and password are 145 | configured. 146 | """ 147 | return self.basic_auth_login and self.basic_auth_password 148 | -------------------------------------------------------------------------------- /baipw/response.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import HttpResponse 3 | from django.template.loader import render_to_string 4 | from django.utils.cache import add_never_cache_headers 5 | 6 | DEFAULT_AUTH_TEMPLATE = ( 7 | "Authentication Required

Authentication required

" 8 | ) 9 | 10 | 11 | class HttpUnauthorizedResponse(HttpResponse): 12 | def __init__(self, content=None, request=None, *args, **kwargs): 13 | self._request = request 14 | self._content = content 15 | kwargs.setdefault("content_type", "text/html") 16 | kwargs.setdefault("status", 401) 17 | super().__init__(self.get_response_content(), *args, **kwargs) 18 | self["WWW-Authenticate"] = self.get_www_authenticate_value() 19 | add_never_cache_headers(self) 20 | 21 | def get_www_authenticate_value(self): 22 | value = "Basic" 23 | realm = getattr(settings, "BASIC_AUTH_REALM", "") 24 | if realm: 25 | realm = realm.replace('"', '\\"') 26 | value += ' realm="{realm}"'.format(realm=realm) 27 | return value 28 | 29 | def get_response_content(self): 30 | if self._content: 31 | return self._content 32 | try: 33 | template = settings.BASIC_AUTH_RESPONSE_TEMPLATE 34 | except AttributeError: 35 | return DEFAULT_AUTH_TEMPLATE 36 | return render_to_string(template, {}, request=self._request) 37 | -------------------------------------------------------------------------------- /baipw/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/django-basic-auth-ip-whitelist/2c76d94a70dbd4e43d2661491c18e39aea5ad92c/baipw/tests/__init__.py -------------------------------------------------------------------------------- /baipw/tests/response.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | 4 | class TestResponse(HttpResponse): 5 | def __init__(self, *args, **kwargs): 6 | del kwargs["request"] 7 | super().__init__("Test message. :P", *args, **kwargs) 8 | -------------------------------------------------------------------------------- /baipw/tests/settings.py: -------------------------------------------------------------------------------- 1 | from os.path import abspath, dirname, join 2 | 3 | import django 4 | from django.utils.crypto import get_random_string 5 | 6 | TESTS_PATH = dirname(abspath(__file__)) 7 | 8 | SECRET_KEY = get_random_string(50) 9 | 10 | INSTALLED_APPS = [ 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.sessions", 14 | "django.contrib.messages", 15 | "django.contrib.staticfiles", 16 | ] 17 | 18 | TEMPLATES = [ 19 | { 20 | "BACKEND": "django.template.backends.django.DjangoTemplates", 21 | "DIRS": [join(TESTS_PATH, "templates")], 22 | "APP_DIRS": True, 23 | "OPTIONS": { 24 | "context_processors": [ 25 | "django.template.context_processors.debug", 26 | "django.template.context_processors.request", 27 | "django.contrib.auth.context_processors.auth", 28 | ], 29 | }, 30 | }, 31 | ] 32 | 33 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite3"}} 34 | 35 | ROOT_URLCONF = "baipw.tests.urls" 36 | 37 | if django.VERSION < (1, 9): 38 | # This is to satisfy the django-admin check command. 39 | MIDDLEWARE_CLASSES = [] 40 | -------------------------------------------------------------------------------- /baipw/tests/templates/test_template.html: -------------------------------------------------------------------------------- 1 | This is a test template. 2 | -------------------------------------------------------------------------------- /baipw/tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django import test 3 | 4 | SETTINGS = {} 5 | 6 | if django.VERSION < (1, 10): 7 | SETTINGS["MIDDLEWARE_CLASSES"] = [ 8 | "baipw.middleware.BasicAuthIPWhitelistMiddleware", 9 | "django.middleware.security.SecurityMiddleware", 10 | "django.contrib.sessions.middleware.SessionMiddleware", 11 | "django.middleware.common.CommonMiddleware", 12 | "django.middleware.csrf.CsrfViewMiddleware", 13 | "django.contrib.auth.middleware.AuthenticationMiddleware", 14 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 15 | ] 16 | else: 17 | SETTINGS["MIDDLEWARE"] = [ 18 | "baipw.middleware.BasicAuthIPWhitelistMiddleware", 19 | "django.middleware.security.SecurityMiddleware", 20 | "django.contrib.sessions.middleware.SessionMiddleware", 21 | "django.middleware.common.CommonMiddleware", 22 | "django.middleware.csrf.CsrfViewMiddleware", 23 | "django.contrib.auth.middleware.AuthenticationMiddleware", 24 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 25 | ] 26 | 27 | 28 | @test.override_settings(**SETTINGS) 29 | class TestIntegration(test.TestCase): 30 | def setUp(self): 31 | self.client = test.Client() 32 | 33 | def test_basic_auth_not_configured(self): 34 | response = self.client.get("/") 35 | self.assertEqual(response.status_code, 401) 36 | 37 | @test.override_settings( 38 | BASIC_AUTH_LOGIN="test", 39 | BASIC_AUTH_PASSWORD="test2", 40 | ) 41 | def test_basic_auth_configured(self): 42 | response = self.client.get("/") 43 | self.assertEqual(response.status_code, 401) 44 | -------------------------------------------------------------------------------- /baipw/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import MagicMock 3 | 4 | from django.test import RequestFactory, TestCase, override_settings 5 | 6 | from baipw.middleware import BasicAuthIPWhitelistMiddleware 7 | from baipw.response import HttpUnauthorizedResponse 8 | from baipw.tests.response import TestResponse 9 | 10 | 11 | class TestCaseMixin: 12 | def setUp(self): 13 | self.get_response_mock = MagicMock() 14 | self.middleware = BasicAuthIPWhitelistMiddleware(self.get_response_mock) 15 | self.request = RequestFactory().get("/") 16 | 17 | 18 | class TestMiddleware(TestCaseMixin, TestCase): 19 | def test_no_settings_returns_permission_denied(self): 20 | response = self.middleware(self.request) 21 | self.assertEqual(response.status_code, 401) 22 | self.assertEqual(response.headers["X-Robots-Tag"], "noindex") 23 | 24 | @override_settings( 25 | BASIC_AUTH_LOGIN="testlogin", 26 | BASIC_AUTH_PASSWORD="testpassword", 27 | ) 28 | def test_basic_auth_returns_401(self): 29 | response = self.middleware(self.request) 30 | self.assertEqual(response.status_code, 401) 31 | self.assertEqual(response.headers["X-Robots-Tag"], "noindex") 32 | 33 | @override_settings( 34 | BASIC_AUTH_LOGIN="testlogin", 35 | ) 36 | def test_is_basic_auth_configured_if_only_login_set(self): 37 | self.assertFalse(self.middleware._is_basic_auth_configured()) 38 | 39 | @override_settings( 40 | BASIC_AUTH_PASSWORD="testpassword", 41 | ) 42 | def test_is_basic_auth_configured_if_only_password_set(self): 43 | self.assertFalse(self.middleware._is_basic_auth_configured()) 44 | 45 | @override_settings( 46 | BASIC_AUTH_LOGIN="testlogin", 47 | BASIC_AUTH_PASSWORD="testpassword", 48 | ) 49 | def test_is_basic_auth_configured_if_login_and_password_set(self): 50 | self.assertTrue(self.middleware._is_basic_auth_configured()) 51 | 52 | @override_settings( 53 | BASIC_AUTH_LOGIN="test", 54 | BASIC_AUTH_PASSWORD="test", 55 | ) 56 | def test_skip_basic_auth_ip_whitelist_middleware_check_attribute_set(self): 57 | self.assertFalse( 58 | hasattr(self.request, "_skip_basic_auth_ip_whitelist_middleware_check") 59 | ) 60 | self.middleware(self.request) 61 | self.assertTrue(self.request._skip_basic_auth_ip_whitelist_middleware_check) 62 | 63 | @override_settings( 64 | BASIC_AUTH_LOGIN="test", 65 | BASIC_AUTH_PASSWORD="test", 66 | ) 67 | def test_the_attribute_skips(self): 68 | self.request._skip_basic_auth_ip_whitelist_middleware_check = True 69 | self.middleware(self.request) 70 | self.get_response_mock.assert_called_once_with(self.request) 71 | 72 | @override_settings( 73 | BASIC_AUTH_LOGIN="test", 74 | BASIC_AUTH_PASSWORD="test", 75 | ) 76 | def test_authoritzation_header_consumed_with_correct_credentials(self): 77 | self.request.META["HTTP_AUTHORIZATION"] = "Basic dGVzdDp0ZXN0" 78 | self.middleware(self.request) 79 | self.assertNotIn("HTTP_AUTHORIZATION", self.request.META) 80 | 81 | @override_settings( 82 | BASIC_AUTH_LOGIN="testtest", 83 | BASIC_AUTH_PASSWORD="testtest", 84 | ) 85 | def test_authoritzation_header_consumed_with_incorrect_credentials(self): 86 | self.request.META["HTTP_AUTHORIZATION"] = "Basic dGVzdDp0ZXN0" 87 | self.middleware(self.request) 88 | self.assertNotIn("HTTP_AUTHORIZATION", self.request.META) 89 | 90 | @override_settings( 91 | BASIC_AUTH_LOGIN=None, 92 | BASIC_AUTH_PASSWORD=None, 93 | BASIC_AUTH_WHITELISTED_IP_NETWORKS=["74.150.52.0/24"], 94 | ) 95 | def test_authoritzation_header_not_consumed_when_auth_not_configured(self): 96 | self.request.META["HTTP_AUTHORIZATION"] = "Basic dGVzdDp0ZXN0" 97 | self.request.META["REMOTE_ADDR"] = "74.150.52.64" 98 | self.middleware(self.request) 99 | self.assertIn("HTTP_AUTHORIZATION", self.request.META) 100 | self.assertEqual(self.request.META["HTTP_AUTHORIZATION"], "Basic dGVzdDp0ZXN0") 101 | 102 | @override_settings( 103 | BASIC_AUTH_LOGIN="testtest", 104 | BASIC_AUTH_PASSWORD="testtest", 105 | BASIC_AUTH_DISABLE_CONSUMING_AUTHORIZATION_HEADER=True, 106 | ) 107 | def test_auth_header_not_consumed_with_consumption_disabled(self): 108 | self.request.META["HTTP_AUTHORIZATION"] = "Basic dGVzdDp0ZXN0" 109 | self.middleware(self.request) 110 | self.assertIn("HTTP_AUTHORIZATION", self.request.META) 111 | 112 | @override_settings( 113 | BASIC_AUTH_LOGIN="testtest", 114 | BASIC_AUTH_PASSWORD="testtest", 115 | BASIC_AUTH_DISABLE_CONSUMING_AUTHORIZATION_HEADER=False, 116 | ) 117 | def test_auth_header_consumed_with_consumption_enabled_explicitly(self): 118 | self.request.META["HTTP_AUTHORIZATION"] = "Basic dGVzdDp0ZXN0" 119 | self.middleware(self.request) 120 | self.assertNotIn("HTTP_AUTHORIZATION", self.request.META) 121 | 122 | @override_settings( 123 | BASIC_AUTH_LOGIN="testtest", 124 | BASIC_AUTH_PASSWORD="testtest", 125 | ) 126 | def test_auth_header_consumed_with_consumption_enabled_implicitly(self): 127 | self.request.META["HTTP_AUTHORIZATION"] = "Basic dGVzdDp0ZXN0" 128 | self.middleware(self.request) 129 | self.assertNotIn("HTTP_AUTHORIZATION", self.request.META) 130 | 131 | 132 | class TestIpWhitelisting(TestCaseMixin, TestCase): 133 | def test_get_whitelisted_networks_when_none_set(self): 134 | networks = list(self.middleware._get_whitelisted_networks()) 135 | self.assertEqual(len(networks), 0) 136 | 137 | @override_settings( 138 | BASIC_AUTH_WHITELISTED_IP_NETWORKS=["192.168.0.0/24", "2001:db00::0/24"] 139 | ) 140 | def test_whitelisted_networks_when_set(self): 141 | networks = list(self.middleware._get_whitelisted_networks()) 142 | self.assertEqual(len(networks), 2) 143 | 144 | @override_settings( 145 | BASIC_AUTH_WHITELISTED_IP_NETWORKS=["192.168.0.0/24", "2001:db00::0/24"] 146 | ) 147 | def test_is_ip_whitelisted(self): 148 | self.request.META["REMOTE_ADDR"] = "192.168.0.25" 149 | self.assertTrue(self.middleware._is_ip_whitelisted(self.request)) 150 | self.request.META["REMOTE_ADDR"] = "2001:db00::33" 151 | self.assertTrue(self.middleware._is_ip_whitelisted(self.request)) 152 | 153 | @override_settings( 154 | BASIC_AUTH_WHITELISTED_IP_NETWORKS=["192.168.0.0/24", "2001:db00::0/24"] 155 | ) 156 | def test_is_ip_whitelisted_invalid_ip(self): 157 | self.request.META["REMOTE_ADDR"] = "192.168.1.25" 158 | self.assertFalse(self.middleware._is_ip_whitelisted(self.request)) 159 | self.request.META["REMOTE_ADDR"] = "2002:eb00::33" 160 | self.assertFalse(self.middleware._is_ip_whitelisted(self.request)) 161 | 162 | @override_settings( 163 | BASIC_AUTH_LOGIN="randomlogin", 164 | BASIC_AUTH_PASSWORD="somepassword", 165 | ) 166 | def test_basic_auth_credentials_settings(self): 167 | self.assertEqual(self.middleware.basic_auth_login, "randomlogin") 168 | self.assertEqual(self.middleware.basic_auth_password, "somepassword") 169 | 170 | @override_settings( 171 | BASIC_AUTH_LOGIN="somelogin", 172 | BASIC_AUTH_PASSWORD="somepassword", 173 | BASIC_AUTH_WHITELISTED_IP_NETWORKS=["45.21.123.0/24"], 174 | ) 175 | def test_basic_auth_not_used_if_on_whitelisted_network(self): 176 | self.request.META["REMOTE_ADDR"] = "45.21.123.45" 177 | self.assertTrue(self.middleware._is_basic_auth_configured()) 178 | with mock.patch( 179 | "baipw.middleware.BasicAuthIPWhitelistMiddleware._basic_auth_response" 180 | ) as m: 181 | with mock.patch( 182 | "baipw.middleware.BasicAuthIPWhitelistMiddleware._is_ip_whitelisted" 183 | ) as ip_check_m: 184 | self.middleware(self.request) 185 | # Make sure middleware did not try to evaluate basic auth. 186 | m.assert_not_called() 187 | # But it called the IP check. 188 | ip_check_m.assert_called_once_with(self.request) 189 | 190 | @override_settings( 191 | BASIC_AUTH_GET_CLIENT_IP_FUNCTION=("baipw.tests.utils.custom_get_client_ip"), 192 | ) 193 | def test_get_custom_get_client_ip(self): 194 | with mock.patch("baipw.tests.utils.custom_get_client_ip") as m: 195 | with mock.patch("baipw.utils.get_client_ip") as default_m: 196 | self.middleware._get_client_ip(self.request) 197 | m.self_assert_called_once_with(self.request) 198 | default_m.assert_not_called() 199 | 200 | def test_whitelisted_http_host_setting_when_setting_not_set(self): 201 | self.assertFalse(list(self.middleware._get_whitelisted_http_hosts())) 202 | 203 | 204 | class TestHttpHostWhitelisting(TestCaseMixin, TestCase): 205 | @override_settings(BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["dgg.gg"]) 206 | def test_whitelisted_http_host_setting_when_setting_set(self): 207 | self.assertEqual( 208 | list(self.middleware._get_whitelisted_http_hosts()), 209 | ["dgg.gg"], 210 | ) 211 | 212 | @override_settings(BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["dgg.gg", "kernel.org"]) 213 | def test_whitelisted_http_host_setting_when_setting_set_multiple(self): 214 | self.assertEqual( 215 | set(self.middleware._get_whitelisted_http_hosts()), 216 | {"kernel.org", "dgg.gg"}, 217 | ) 218 | 219 | def test_http_host_whitelist_check_when_settings_empty(self): 220 | self.assertFalse(self.middleware._is_http_host_whitelisted(self.request)) 221 | 222 | @override_settings( 223 | ALLOWED_HOSTS=["kernel.org"], 224 | BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["kernel.org", "dgg.gg"], 225 | ) 226 | def test_http_host_whitelist_passes_check_when_configured_(self): 227 | self.request.META["HTTP_HOST"] = "kernel.org" 228 | self.assertTrue(self.middleware._is_http_host_whitelisted(self.request)) 229 | 230 | @override_settings( 231 | ALLOWED_HOSTS=["google.com"], 232 | BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["kernel.org", "dgg.gg"], 233 | ) 234 | def test_http_host_whitelist_fails_check_when_configured_(self): 235 | self.request.META["HTTP_HOST"] = "google.com" 236 | self.assertFalse(self.middleware._is_http_host_whitelisted(self.request)) 237 | 238 | @override_settings(BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["kernel.org", "dgg.gg"]) 239 | def test_http_host_whitelist_fails_check_with_no_host(self): 240 | response = self.middleware(self.request) 241 | self.assertEqual(response.status_code, 401) 242 | 243 | @override_settings( 244 | ALLOWED_HOSTS=["www.example.com"], 245 | BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["kernel.org", "dgg.gg"], 246 | ) 247 | def test_http_host_whitelist_fails_check_with_wrong_host(self): 248 | self.request.META["HTTP_HOST"] = "www.example.com" 249 | response = self.middleware(self.request) 250 | self.assertEqual(response.status_code, 401) 251 | 252 | @override_settings( 253 | BASIC_AUTH_LOGIN="somelogin", 254 | BASIC_AUTH_PASSWORD="somepassword", 255 | BASIC_AUTH_WHITELISTED_IP_NETWORKS=["45.21.123.0/24"], 256 | BASIC_AUTH_WHITELISTED_HTTP_HOSTS=["kernel.org", "dgg.gg"], 257 | ALLOWED_HOSTS=["dgg.gg"], 258 | ) 259 | def test_http_host_whitelist_has_precedence_over_basic_auth(self): 260 | self.request.META["HTTP_HOST"] = "dgg.gg" 261 | # It does not raise. 262 | self.middleware(self.request) 263 | 264 | 265 | class TestPathWhitelisting(TestCaseMixin, TestCase): 266 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/"]) 267 | def test_whitelisted_path_setting_when_setting_set(self): 268 | self.assertEqual( 269 | list(self.middleware._get_whitelisted_paths()), 270 | ["ham/"], 271 | ) 272 | 273 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"]) 274 | def test_whitelisted_path_setting_when_setting_set_multiple(self): 275 | self.assertEqual( 276 | set(self.middleware._get_whitelisted_paths()), 277 | {"ham/", "eggs/"}, 278 | ) 279 | 280 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS="ham/,eggs/") 281 | def test_whitelisted_path_setting_when_multiple_set_as_string(self): 282 | self.assertEqual( 283 | set(self.middleware._get_whitelisted_paths()), 284 | {"ham/", "eggs/"}, 285 | ) 286 | 287 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"]) 288 | def test_path_whitelist_passes_exact_check_when_configured(self): 289 | self.request.path = "ham/" 290 | self.assertTrue(self.middleware._is_path_whitelisted(self.request)) 291 | 292 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"]) 293 | def test_path_whitelist_passes_child_check_when_configured(self): 294 | self.request.path = "ham/bacon/spam/" 295 | self.assertTrue(self.middleware._is_path_whitelisted(self.request)) 296 | 297 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"]) 298 | def test_path_whitelist_fails_check_when_configured(self): 299 | self.request.path = "spam/" 300 | self.assertFalse(self.middleware._is_path_whitelisted(self.request)) 301 | 302 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"]) 303 | def test_path_whitelist_fails_check_for_parent_path(self): 304 | response = self.middleware(self.request) 305 | self.assertEqual(response.status_code, 401) 306 | 307 | @override_settings(BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"]) 308 | def test_path_whitelist_fails_check_with_wrong_path(self): 309 | self.request.path = "spam/" 310 | response = self.middleware(self.request) 311 | self.assertEqual(response.status_code, 401) 312 | 313 | @override_settings( 314 | BASIC_AUTH_LOGIN="somelogin", 315 | BASIC_AUTH_PASSWORD="somepassword", 316 | BASIC_AUTH_WHITELISTED_PATHS=["ham/", "eggs/"], 317 | ) 318 | def test_path_whitelist_has_precedence_over_basic_auth(self): 319 | self.request.path = "ham/" 320 | try: 321 | self.middleware(self.request) 322 | except Exception: 323 | self.fail( 324 | "self.middleware() raised an error unexpectedly for a whitelisted path" 325 | ) 326 | 327 | def test_path_whitelist_check_when_settings_empty(self): 328 | self.request.path = "spam/" 329 | self.assertFalse(self.middleware._is_path_whitelisted(self.request)) 330 | 331 | 332 | class TestResponseClass(TestCaseMixin, TestCase): 333 | def test_get_response_class_when_none_set(self): 334 | self.assertIsInstance( 335 | self.middleware.get_error_response(self.request), HttpUnauthorizedResponse 336 | ) 337 | 338 | @override_settings(BASIC_AUTH_RESPONSE_CLASS="baipw.tests.response.TestResponse") 339 | def test_get_response_class_when_set(self): 340 | self.assertIsInstance( 341 | self.middleware.get_error_response(self.request), TestResponse 342 | ) 343 | 344 | @override_settings( 345 | BASIC_AUTH_LOGIN="testlogin", 346 | BASIC_AUTH_PASSWORD="testpassword", 347 | BASIC_AUTH_RESPONSE_CLASS="baipw.tests.response.TestResponse", 348 | ) 349 | def test_middleware_when_custom_response_set(self): 350 | response = self.middleware(self.request) 351 | self.assertIsInstance(response, TestResponse) 352 | self.assertEqual(response.content, b"Test message. :P") 353 | -------------------------------------------------------------------------------- /baipw/tests/test_response.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.test import RequestFactory, TestCase, override_settings 3 | 4 | from baipw.response import DEFAULT_AUTH_TEMPLATE, HttpUnauthorizedResponse 5 | 6 | 7 | class TestHttpUnauthorizedResponse(TestCase): 8 | def setUp(self): 9 | self.request = RequestFactory().get("/") 10 | 11 | def test_default_realm(self): 12 | response = HttpUnauthorizedResponse(request=self.request) 13 | self.assertEqual(response["WWW-Authenticate"], "Basic") 14 | 15 | @override_settings(BASIC_AUTH_REALM="Custom realm") 16 | def test_custom_realm(self): 17 | response = HttpUnauthorizedResponse(request=self.request) 18 | self.assertEqual(response["WWW-Authenticate"], 'Basic realm="Custom realm"') 19 | 20 | @override_settings(BASIC_AUTH_REALM='"Custom realm"') 21 | def test_custom_realm_with_doublequotes(self): 22 | response = HttpUnauthorizedResponse(request=self.request) 23 | self.assertEqual( 24 | response["WWW-Authenticate"], 'Basic realm="\\"Custom realm\\""' 25 | ) 26 | 27 | def test_default_template(self): 28 | response = HttpUnauthorizedResponse(request=self.request) 29 | self.assertEqual(response.get_response_content(), DEFAULT_AUTH_TEMPLATE) 30 | 31 | @override_settings( 32 | BASIC_AUTH_RESPONSE_TEMPLATE="test_template.html", 33 | ) 34 | def test_custom_template(self): 35 | response = HttpUnauthorizedResponse(request=self.request) 36 | self.assertEqual( 37 | response.get_response_content().strip(), "This is a test template." 38 | ) 39 | self.assertEqual(response["Content-Type"], "text/html") 40 | 41 | def test_never_cache_headers_set(self): 42 | response = HttpUnauthorizedResponse(request=self.request) 43 | cache_control_args = [v.strip() for v in response["cache-control"].split(",")] 44 | if django.VERSION >= (3, 0): 45 | self.assertCountEqual( 46 | cache_control_args, 47 | ["max-age=0", "no-cache", "no-store", "must-revalidate", "private"], 48 | ) 49 | else: 50 | self.assertCountEqual( 51 | cache_control_args, 52 | ["max-age=0", "no-cache", "no-store", "must-revalidate"], 53 | ) 54 | -------------------------------------------------------------------------------- /baipw/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from django.test import RequestFactory, TestCase 4 | 5 | from baipw.exceptions import Unauthorized 6 | from baipw.utils import authorize 7 | 8 | 9 | class TestAuthorize(TestCase): 10 | def setUp(self): 11 | self.request = RequestFactory().get("/") 12 | 13 | def test_authorise_with_no_password(self): 14 | with self.assertRaises(Unauthorized) as e: 15 | authorize(self.request, "somelogin", "somepassword") 16 | self.assertEqual( 17 | str(e.exception), 18 | "Missing Authorization header.", 19 | ) 20 | 21 | def test_authorise_with_wrong_credentials(self): 22 | self.request.META["HTTP_AUTHORIZATION"] = "Basic {}".format( 23 | base64.b64encode("somelogin:somepassword".encode("utf-8")).decode("utf-8") 24 | ) 25 | with self.assertRaises(Unauthorized) as e: 26 | authorize(self.request, "somelogin", "wrongpassword") 27 | self.assertEqual( 28 | str(e.exception), "Basic authentication credentials are invalid." 29 | ) 30 | 31 | def test_authorise_with_correct_credentials(self): 32 | self.request.META["HTTP_AUTHORIZATION"] = "Basic {}".format( 33 | base64.b64encode("somelogin:correctpassword".encode("utf-8")).decode( 34 | "utf-8" 35 | ) 36 | ) 37 | self.assertTrue(authorize(self.request, "somelogin", "correctpassword")) 38 | 39 | def test_authorise_with_invalid_header(self): 40 | self.request.META["HTTP_AUTHORIZATION"] = "Basic" 41 | with self.assertRaises(Unauthorized) as e: 42 | authorize(self.request, "somelogin", "wrongpassword") 43 | self.assertEqual(str(e.exception), "Invalid Authorization header.") 44 | 45 | def test_multiple_header_values(self): 46 | credentials = base64.b64encode( 47 | "somelogin:correctpassword".encode("utf-8") 48 | ).decode("utf-8") 49 | self.request.META["HTTP_AUTHORIZATION"] = ( 50 | f"Basic {credentials},Basic {credentials}" 51 | ) 52 | self.assertTrue(authorize(self.request, "somelogin", "correctpassword")) 53 | -------------------------------------------------------------------------------- /baipw/tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /baipw/tests/utils.py: -------------------------------------------------------------------------------- 1 | def custom_get_client_ip(): 2 | pass 3 | -------------------------------------------------------------------------------- /baipw/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from django.conf import settings 4 | from django.utils.crypto import constant_time_compare 5 | 6 | from .exceptions import Unauthorized 7 | 8 | 9 | def get_client_ip(request): 10 | """ 11 | Get the client's IP address 12 | 13 | Note: This is the address connecting to Django, which is likely incorrect. 14 | """ 15 | return request.META.get("REMOTE_ADDR") 16 | 17 | 18 | def authorize(request, configured_username, configured_password): 19 | """ 20 | Match authorization header present in the request against 21 | configured username and password. 22 | """ 23 | # Use request.META instead of request.headers to make it 24 | # compatible with Django versions below 2.2. 25 | authentication_header = request.META.get("HTTP_AUTHORIZATION") 26 | 27 | if authentication_header is None: 28 | raise Unauthorized("Missing Authorization header.") 29 | 30 | disable_consumption = getattr( 31 | settings, 32 | "BASIC_AUTH_DISABLE_CONSUMING_AUTHORIZATION_HEADER", 33 | False, 34 | ) 35 | if not disable_consumption: 36 | # Delete "Authorization" header so other authentication 37 | # mechanisms do not try to use it. 38 | request.META.pop("HTTP_AUTHORIZATION") 39 | 40 | for authentication in authentication_header.split(","): 41 | authentication_tuple = authentication.split(" ", 1) 42 | if len(authentication_tuple) != 2: 43 | raise Unauthorized("Invalid Authorization header.") 44 | auth_method = authentication_tuple[0] 45 | auth = authentication_tuple[1] 46 | if "basic" != auth_method.lower(): 47 | raise Unauthorized("Invalid Authorization header.") 48 | auth = base64.b64decode(auth.strip()).decode("utf-8") 49 | username, password = auth.split(":", 1) 50 | username_valid = constant_time_compare(username, configured_username) 51 | password_valid = constant_time_compare(password, configured_password) 52 | if username_valid and password_valid: 53 | return True 54 | 55 | raise Unauthorized("Basic authentication credentials are invalid.") 56 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | select = ["E", "F", "I", "W", "N", "B", "A", "C4", "T20", "DJ"] 3 | ignore = ["E501", "N818"] 4 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.core.management import execute_from_command_line 8 | 9 | os.environ["DJANGO_SETTINGS_MODULE"] = "baipw.tests.settings" 10 | 11 | 12 | def runtests(): 13 | execute_from_command_line([sys.argv[0], "check"]) 14 | # Makemigrations does not return proper error 15 | # code on Django < 1.10. 16 | if django.VERSION >= (1, 10): 17 | execute_from_command_line( 18 | [ 19 | sys.argv[0], 20 | "makemigrations", 21 | "--noinput", 22 | "--check", 23 | ] 24 | ) 25 | execute_from_command_line([sys.argv[0], "test"] + sys.argv[1:]) 26 | 27 | 28 | if __name__ == "__main__": 29 | runtests() 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-basic-auth-ip-whitelist 3 | version = attr: baipw.__version__ 4 | description = Hide your Django site behind basic authentication mechanism with IP whitelisting support. 5 | long_description = file: README.rst 6 | long_description_content_type = text/x-rst 7 | author = Torchbox 8 | author_email = hello@torchbox.com 9 | url = https://github.com/torchbox/django-basic-auth-ip-whitelist 10 | license = BSD 3-Clause License 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | License :: OSI Approved 14 | License :: OSI Approved :: BSD License 15 | Programming Language :: Python :: 3.9 16 | Programming Language :: Python :: 3.10 17 | Programming Language :: Python :: 3.11 18 | Programming Language :: Python :: 3.12 19 | Topic :: Internet :: WWW/HTTP 20 | Framework :: Django 21 | Framework :: Django :: 4.2 22 | Framework :: Django :: 5.0 23 | Framework :: Django :: 5.1 24 | keywords = 25 | django 26 | basic 27 | authentication 28 | auth 29 | ip 30 | whitelist 31 | whitelisting 32 | http 33 | 34 | [options] 35 | packages = find: 36 | install_requires = 37 | Django>=4.2 38 | python_requires = >=3.9 39 | 40 | [options.extras_require] 41 | lint = 42 | ruff==0.9.6 43 | 44 | [options.packages.find] 45 | exclude = 46 | baipw.tests* 47 | 48 | [bdist_wheel] 49 | python-tag = py3 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39}-dj42 4 | py{310,311,312,313}-dj{42,50,51,main} 5 | ruff 6 | 7 | [testenv] 8 | deps = 9 | dj42: Django>=4.2,<4.3 10 | dj50: Django>=5,<5.1 11 | dj51: Django>=5.1,<5.2 12 | djmain: https://github.com/django/django/archive/main.tar.gz 13 | commands = 14 | python run_tests.py 15 | 16 | [testenv:ruff] 17 | extras = lint 18 | basepython = python3 19 | changedir = {toxinidir} 20 | usedevelop = false 21 | commands = 22 | ruff format --check 23 | ruff check 24 | --------------------------------------------------------------------------------