├── .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 RequiredAuthentication 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 |
--------------------------------------------------------------------------------