├── .coverage ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── RELEASE.md ├── adminrestrict ├── .coverage ├── __init__.py ├── admin.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── addadminip.py │ │ └── removeadminip.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── signals.py ├── test_settings.py ├── test_urls.py └── tests.py ├── setup.cfg └── setup.py /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robromano/django-adminrestrict/b453084f50d29426b41b9909789b87198af553c5/.coverage -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-20.04 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | # Django 1.11 21 | - django-version: "1.11.29" 22 | python-version: "3.5" 23 | - django-version: "1.11.29" 24 | python-version: "3.6" 25 | - django-version: "1.11.29" 26 | python-version: "3.7" 27 | # Django 2.0 28 | - django-version: "2.0.13" 29 | python-version: "3.5" 30 | - django-version: "2.0.13" 31 | python-version: "3.6" 32 | - django-version: "2.0.13" 33 | python-version: "3.7" 34 | # Django 2.1 35 | - django-version: "2.1.15" 36 | python-version: "3.5" 37 | - django-version: "2.1.15" 38 | python-version: "3.6" 39 | - django-version: "2.1.15" 40 | python-version: "3.7" 41 | # Django 2.2 42 | - django-version: "2.2.8" 43 | python-version: "3.5" 44 | - django-version: "2.2.8" 45 | python-version: "3.6" 46 | - django-version: "2.2.8" 47 | python-version: "3.7" 48 | - django-version: "2.2.8" 49 | python-version: "3.8" 50 | - django-version: "2.2.8" 51 | python-version: "3.9" 52 | # Django 3.0 53 | - django-version: "3.0.14" 54 | python-version: "3.6" 55 | - django-version: "3.0.14" 56 | python-version: "3.7" 57 | - django-version: "3.0.14" 58 | python-version: "3.8" 59 | - django-version: "3.0.14" 60 | python-version: "3.9" 61 | # Django 3.1 62 | - django-version: "3.1.14" 63 | python-version: "3.6" 64 | - django-version: "3.1.14" 65 | python-version: "3.7" 66 | - django-version: "3.1.14" 67 | python-version: "3.8" 68 | - django-version: "3.1.14" 69 | python-version: "3.9" 70 | # Django 3.2 71 | - django-version: "3.2.14" 72 | python-version: "3.6" 73 | - django-version: "3.2.14" 74 | python-version: "3.7" 75 | - django-version: "3.2.14" 76 | python-version: "3.8" 77 | - django-version: "3.2.14" 78 | python-version: "3.9" 79 | - django-version: "3.2.14" 80 | python-version: "3.10" 81 | # Django 4.0 82 | - django-version: "4.0.9" 83 | python-version: "3.8" 84 | - django-version: "4.0.9" 85 | python-version: "3.9" 86 | - django-version: "4.0.9" 87 | python-version: "3.10" 88 | # Django 4.1 89 | - django-version: "4.1.6" 90 | python-version: "3.8" 91 | - django-version: "4.1.6" 92 | python-version: "3.9" 93 | - django-version: "4.1.6" 94 | python-version: "3.10" 95 | 96 | steps: 97 | - uses: actions/checkout@v3 98 | - name: Set up Python ${{ matrix.python-version }} 99 | uses: actions/setup-python@v4 100 | with: 101 | python-version: ${{ matrix.python-version }} 102 | - name: Install dependencies 103 | run: | 104 | python -m pip install --upgrade pip 105 | python -m pip install flake8 coverage 106 | - name: Install Django version 107 | run: | 108 | python -m pip install "Django~=${{ matrix.django-version }}" 109 | - name: Lint with flake8 110 | run: | 111 | # stop the build if there are Python syntax errors or undefined names 112 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 113 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 114 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 115 | - name: Test with django-admin 116 | run: | 117 | echo "Python ${{ matrix.python-version }} -> Django ${{ matrix.django-version }}" 118 | PYTHONPATH=$PYTHONPATH:$PWD coverage run `which django-admin` test adminrestrict --settings=adminrestrict.test_settings 119 | - name: Upload coverage to Codecov 120 | uses: codecov/codecov-action@v3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | build 4 | dist 5 | .hg 6 | .DS_Store 7 | examples/db/ 8 | examples/logs/ 9 | examples/media/ 10 | examples/static/ 11 | examples/example/local_settings.py 12 | *~ 13 | 14 | # virtualenvs 15 | env 16 | venv 17 | .env 18 | 19 | # ide folders 20 | .vscode 21 | .idea 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014-2016 Robert Romano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst 2 | recursive-include adminrestrict *.py 3 | include Makefile 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean check-manifest viewdoc 2 | clean: 3 | if [ -e .long-description.html ]; then rm .long-description.html ; fi 4 | check-manifest: 5 | check-manifest 6 | viewdoc: 7 | viewdoc 8 | releasetest: 9 | python setup.py register -r pypitest 10 | python setup.py sdist upload -r pypitest 11 | release: 12 | python setup.py register -r pypi 13 | python setup.py sdist upload -r pypi 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Admin Restrict 2 | 3 | [![Build](https://github.com/robromano/django-adminrestrict/actions/workflows/python-package.yml/badge.svg?branch=master)](https://github.com/robromano/django-adminrestrict/actions/workflows/python-package.yml) 4 | [![coverage-status-image]][codecov] 5 | [![pypi-version]][pypi] 6 | 7 | **Restrict admin pages using simple IP address rules.** 8 | 9 | ## Overview 10 | 11 | ``django-adminrestrict`` secures access to the Django admin pages. It works 12 | by blocking requests for the admin page path unless the requests come from 13 | specific IP addresses, address ranges or domains that you specify in 14 | a model. 15 | 16 | 17 | ## Requirements 18 | 19 | ``django-adminrestrict`` requires Django 1.4 or later. The 20 | application is intended improve the security around the Django admin 21 | login pages. 22 | 23 | ## Installation 24 | 25 | Download and install ``django-adminrestrict`` using **one** of the following methods: 26 | 27 | ### pip 28 | 29 | You can install the latest stable package running this command: 30 | 31 | $ pip install django-adminrestrict 32 | 33 | ### Setuptools 34 | 35 | You can install the latest stable package running: 36 | 37 | $ easy_install django-adminrestrict 38 | 39 | ## Python 3.x Only 40 | 41 | `adminrestrict` requires Python 3.x and no longer supports Python 2.x. 42 | 43 | ## Development 44 | 45 | You can contribute to this project forking it from github and sending pull requests. 46 | 47 | 48 | ## Configuration 49 | 50 | First of all, you must add this project to your list of ``INSTALLED_APPS`` in 51 | ``settings.py`` 52 | 53 | INSTALLED_APPS = ( 54 | 'django.contrib.admin', 55 | 'django.contrib.auth', 56 | 'django.contrib.contenttypes', 57 | 'django.contrib.sessions', 58 | 'django.contrib.sites', 59 | ... 60 | 'adminrestrict', 61 | ... 62 | ) 63 | 64 | Next, install the ``AdminPagesRestrictMiddleware`` middleware: 65 | 66 | MIDDLEWARE_CLASSES = ( 67 | 'django.middleware.common.CommonMiddleware', 68 | 'django.contrib.sessions.middleware.SessionMiddleware', 69 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 70 | 'adminrestrict.middleware.AdminPagesRestrictMiddleware', 71 | ) 72 | 73 | 74 | Create the appropriate tables in your database that are necessary for operation. 75 | 76 | For django(<1.7), run ``python manage.py syncdb``. 77 | 78 | For django(>=1.7), run ``python manage.py makemigrations adminrestrict; python manage.py migrate``. 79 | 80 | IMPORTANT: When the package is configured in your project, an empty table called `AllowedIP` 81 | will be created in your database. If this table is empty or has one record with 82 | a "\*" the package will not restrict any IPs. If you want to add specific restrictions 83 | please go to the next section. 84 | 85 | ## Usage 86 | 87 | Using ``django-adminrestrict`` is extremely simple. Once you install the application 88 | and the middleware, all you need to do is update the allowed IP addresses `AllowedIP` 89 | section of the admin pages. 90 | 91 | ### Adding allowed IP addresses 92 | 93 | Login to the admin pages and browse to the Adminrestrict app, and 94 | start creating recorded in the `AllowedIP` table. Just type in the IP 95 | addresses and save them. These will be single IPv4 addresses that are 96 | permitted to access the pages. 97 | 98 | 99 | ### Managing allowed IP addresses from command line 100 | 101 | Use the management commands to add and remove allowed IP addresses from the command line: 102 | 103 | ``python manage.py addadminip 10.10.10.10`` 104 | 105 | ``python manage.py removeadminip 10.10.10.10`` 106 | 107 | 108 | ### Adding allowed IP addresses with wildcards 109 | 110 | Create a `AllowedIP` entries ending with a "\*" to any IPs that start 111 | with the specified pattern. For example, adding `192.*` would allow 112 | addreses starting matching 192.*.*.* to access the admin pages. 113 | 114 | ### Adding allowed IP addresses using CIDR ranges 115 | 116 | Create a `AllowedIP` entries denoted in CIDR notation, to indicate a range 117 | of IP addresses that would be allowed to login/access the admin pages. 118 | For example, a CIDR range with a suffix indicating the number of bits 119 | of the prefix, such as `192.0.2.0/24` for IPv4 or `2001:0db8:85a3:0000::/64` for IPv6 would indicate an 120 | entire subnet allowed to access the admin pages. 121 | ### Adding allowed IP addresses using domain names 122 | 123 | Create `AllowedIP` records with domain names starting with a lower-case or upper-case character. These domain names' corresponding IP addresses 124 | will be allowed to access the admin pages. Recommended use case: dynamic 125 | DNS domain names. 126 | 127 | ### Adding * to disable all restrictions 128 | 129 | Create a single `AllowedIP` record with "\*" as the IP address, to 130 | temporarily disable restrictions. In this way, you do not have to 131 | modify settings.py and remove the middleware if you need to disable. 132 | 133 | Having at least one `AllowedIP` record with * as the IP address 134 | effectively disables all restrictions. 135 | 136 | ## Advanced Settings 137 | 138 | There are a few advanced settings that can be engaged by adding them 139 | to your project's `settings.py` file: 140 | 141 | `ADMINRESTRICT_BLOCK_GET=True` will block all GET requests to admin urls. By default, `adminrestrict` only blocks the POST method to block logins only, which is usually sufficient, because GET will redirect to the login page anyway. 142 | 143 | `ADMINRESTRICT_ENABLE_CACHE=True` will cause `adminrestrict` to cache some of the IP addresses retrieved from the AllowedIP model to reduce read query load on your database. When any update gets made to AllowedIP models, the cache is auto-refreshed. 144 | 145 | `ADMINRESTRICT_DENIED_MSG="Custom denied msg."` will let you set the response body of the 403 HTTP 146 | result when a request is denied. By default, the message is **"Access to admin is denied."** 147 | 148 | `ADMINRESTRICT_ALLOW_PRIVATE_IP=True` will allow all private IP addresses to access 149 | the admin pages, regardless of whether the request IP matches any pattern or IP address 150 | in the AllowedIP model. Note: private IP addresses are those which comply with [RFC1918](https://tools.ietf.org/html/rfc1918). 151 | 152 | `ADMINRESTRICT_PRIVATE_IP_PREFIXES` will allow overriding the default list of private IP prefixes that 153 | is used to identify an IP address as a private IP address. Defaults to `('10.', '172.', '192.', '127.')` 154 | 155 | [build-status-image]: https://secure.travis-ci.org/robromano/django-adminrestrict.svg?branch=master 156 | [travis]: https://travis-ci.org/robromano/django-adminrestrict?branch=master 157 | [pypi-version]: https://badge.fury.io/py/django-adminrestrict.svg 158 | [pypi]: https://pypi.org/project/django-adminrestrict/ 159 | [coverage-status-image]: https://img.shields.io/codecov/c/github/robromano/django-adminrestrict/master.svg 160 | [codecov]: https://codecov.io/github/robromano/django-adminrestrict?branch=master 161 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## v3.1 - Django 4 Support + Python 3 Only 4 | 5 | * No longer support Python 2.x. 6 | * Async support for Django async added 7 | * Django 4 support added 8 | 9 | Release date 2022-07-31 10 | 11 | ## v3.0 - New options: block GETs also, allow private IPs, and support for domain names 12 | 13 | * New feature (setting `ADMINRESTRICT_BLOCK_GET = True`) to enable this middleware to filter access for ALL accesses to admin page URLs (GET or POST). 14 | * New feature (setting `ADMINRESTRICT_DENIED_MSG = "custom msg"`) to allow custom body for 403 denied responses. 15 | * New feature (setting `ADMINRESTRICT_ALLOW_PRIVATE_IP = True`) to allow all RFC1918 addresses to access admin pages regardless of entries in AllowedIP table 16 | * New feature to support allowing access in the AllowedIP table via CIDR ranges. 17 | * New feature to support allowing access in the AllowedIP table via domain names. Use case: dynamic DNS domain names. 18 | 19 | Release date 2020-12-11 20 | -------------------------------------------------------------------------------- /adminrestrict/.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robromano/django-adminrestrict/b453084f50d29426b41b9909789b87198af553c5/adminrestrict/.coverage -------------------------------------------------------------------------------- /adminrestrict/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | __version__ = __import__('pkg_resources').get_distribution( 3 | 'django-adminrestrict' 4 | ).version 5 | except: 6 | __version__ = '3.0' 7 | 8 | 9 | def get_version(): 10 | return __version__ 11 | -------------------------------------------------------------------------------- /adminrestrict/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | adminrestrict app model admin definitions. 3 | """ 4 | 5 | __author__ = "Robert Romano" 6 | __copyright__ = "Copyright 2021 Robert C. Romano" 7 | 8 | 9 | from django.contrib import admin 10 | from adminrestrict.models import AllowedIP 11 | 12 | import adminrestrict.signals 13 | 14 | class AllowedIPAdmin(admin.ModelAdmin): 15 | list_display = ('ip_address',) 16 | 17 | admin.site.register(AllowedIP, AllowedIPAdmin) 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /adminrestrict/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robromano/django-adminrestrict/b453084f50d29426b41b9909789b87198af553c5/adminrestrict/management/__init__.py -------------------------------------------------------------------------------- /adminrestrict/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robromano/django-adminrestrict/b453084f50d29426b41b9909789b87198af553c5/adminrestrict/management/commands/__init__.py -------------------------------------------------------------------------------- /adminrestrict/management/commands/addadminip.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from adminrestrict.models import AllowedIP 3 | 4 | 5 | class Command(BaseCommand): 6 | help = 'Add a new IP address to the Admin Allowed IP table' 7 | 8 | def add_arguments(self, parser): 9 | parser.add_argument('ip_address', type=str) 10 | 11 | def handle(self, *args, **options): 12 | ip_address = options['ip_address'] 13 | ip = AllowedIP(ip_address=ip_address) 14 | ip.save() 15 | print('IP Address {0} has been added to allowed list'.format(ip_address)) 16 | -------------------------------------------------------------------------------- /adminrestrict/management/commands/removeadminip.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from adminrestrict.models import AllowedIP 3 | 4 | 5 | class Command(BaseCommand): 6 | help = 'Remove an IP address from the Admin Allowed IP table' 7 | 8 | def add_arguments(self, parser): 9 | parser.add_argument('ip_address', type=str) 10 | 11 | def handle(self, *args, **options): 12 | ip_address = options['ip_address'] 13 | result = AllowedIP.objects.filter(ip_address=ip_address).delete() 14 | num = result[0] 15 | if num: 16 | print('IP Address {0} has been removed from the allowed list'.format(ip_address)) 17 | else: 18 | print('IP Address {0} was not found in allowed list'.format(ip_address)) 19 | -------------------------------------------------------------------------------- /adminrestrict/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | adminrestrict middleware 3 | """ 4 | 5 | __author__ = "Robert Romano" 6 | __copyright__ = "Copyright 2020 Robert C. Romano" 7 | 8 | 9 | import django 10 | import logging 11 | import re 12 | import socket 13 | import sys 14 | import ipaddress 15 | 16 | 17 | if django.VERSION[:2] >= (1, 10): 18 | from django.urls import reverse 19 | else: 20 | from django.core.urlresolvers import reverse 21 | 22 | from django.conf import settings 23 | from django.http import HttpResponseForbidden 24 | 25 | # MiddlewareMixin is only available (and useful) in Django 1.10 and 26 | # newer versions 27 | try: 28 | from django.utils.deprecation import MiddlewareMixin 29 | parent_class = MiddlewareMixin 30 | except ImportError as e: 31 | parent_class = object 32 | 33 | 34 | from adminrestrict.models import AllowedIP 35 | 36 | 37 | def is_valid_ip(ip_address): 38 | """ 39 | Check Validity of an IP address 40 | """ 41 | valid = False 42 | for family in (socket.AF_INET, socket.AF_INET6): 43 | try: 44 | socket.inet_pton(family, ip_address.strip()) 45 | valid = True 46 | break 47 | except: 48 | continue 49 | return valid 50 | 51 | 52 | def get_ip_address_for_fqdn(fqdn): 53 | try: 54 | return socket.gethostbyname(fqdn) 55 | except: 56 | return None 57 | 58 | 59 | def valid_fqdn(dn): 60 | if dn.endswith('.'): 61 | dn = dn[:-1] 62 | if len(dn) < 1 or len(dn) > 253: 63 | return False 64 | ldh_re = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", 65 | re.IGNORECASE) 66 | return all(ldh_re.match(x) for x in dn.split('.')) 67 | 68 | 69 | def is_private_rfc_1918_ip(ip: str): 70 | """Returns true if IP is determined to be a private RFC1918 address.""" 71 | private_ip_prefixes = getattr(settings, 'ADMINRESTRICT_PRIVATE_IP_PREFIXES', 72 | ('10.', '172.', '192.', '127.')) 73 | return ip.startswith(private_ip_prefixes) 74 | 75 | 76 | def get_ip_address_from_request(request): 77 | """ 78 | Makes the best attempt to get the client's real IP or return the loopback 79 | """ 80 | ip_address = '' 81 | x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '') 82 | if x_forwarded_for and ',' not in x_forwarded_for: 83 | if not is_private_rfc_1918_ip(x_forwarded_for) and is_valid_ip(x_forwarded_for): 84 | ip_address = x_forwarded_for.strip() 85 | else: 86 | ips = [ip.strip() for ip in x_forwarded_for.split(',')] 87 | for ip in ips: 88 | if is_private_rfc_1918_ip(ip): 89 | continue 90 | elif not is_valid_ip(ip): 91 | continue 92 | else: 93 | ip_address = ip 94 | break 95 | if not ip_address: 96 | x_real_ip = request.META.get('HTTP_X_REAL_IP', '') 97 | if x_real_ip: 98 | if not is_private_rfc_1918_ip(x_real_ip) and is_valid_ip(x_real_ip): 99 | ip_address = x_real_ip.strip() 100 | if not ip_address: 101 | remote_addr = request.META.get('REMOTE_ADDR', '') 102 | if remote_addr: 103 | if not is_private_rfc_1918_ip(remote_addr) and is_valid_ip(remote_addr): 104 | ip_address = remote_addr.strip() 105 | if is_private_rfc_1918_ip(remote_addr) and is_valid_ip(remote_addr): 106 | ip_address = remote_addr.strip() 107 | if not ip_address: 108 | ip_address = '127.0.0.1' 109 | return ip_address 110 | 111 | 112 | class AdminPagesRestrictMiddleware(parent_class): 113 | """ 114 | A middleware that restricts login attempts to admin pages to 115 | restricted IP addresses only. Everyone else gets 403. 116 | """ 117 | 118 | _invalidate_cache = True 119 | 120 | def __init__(self, get_response=None): 121 | super().__init__(get_response) 122 | self.disallow_get = getattr(settings, 'ADMINRESTRICT_BLOCK_GET', 123 | False) 124 | self.denied_msg = getattr(settings, 'ADMINRESTRICT_DENIED_MSG', 125 | "Access to admin is denied.") 126 | self.allow_private_ip = getattr(settings, 'ADMINRESTRICT_ALLOW_PRIVATE_IP', 127 | False) 128 | 129 | self.cache = {} 130 | self.allow_always = True 131 | self.logger = logging.getLogger(__name__) 132 | self.ipaddress_module_loaded = 'ipaddress' in sys.modules 133 | 134 | def request_ip_is_allowed(self, request): 135 | """ Returns True if the request IP is allowed based on records in 136 | the AllowedIP table, False otherwise.""" 137 | 138 | if self.allow_always: 139 | return True 140 | 141 | request_ip = get_ip_address_from_request(request) 142 | 143 | # If the settings to allow RFC1918 private IPs is set, 144 | # check if request ip is a private IP and allow if so 145 | if self.ipaddress_module_loaded and self.allow_private_ip: 146 | try: 147 | ip = ipaddress.ip_address(request_ip) 148 | if ip.is_private: 149 | return True 150 | except ValueError as e: 151 | logging.error(e) 152 | 153 | # If the request_ip is in the AllowedIP the access 154 | # is granted 155 | if self.caching_enabled() and self.cache.get(request_ip, False): 156 | return True 157 | elif AllowedIP.objects.filter(ip_address=request_ip).count() == 1: 158 | return True 159 | 160 | # Check CIDR ranges if any first 161 | if self.ipaddress_module_loaded: 162 | for cidr_range in AllowedIP.objects.filter(ip_address__regex=r"\/\d+$"): 163 | try: 164 | net = ipaddress.ip_network(cidr_range.ip_address) 165 | ip = ipaddress.ip_address(str(request_ip)) 166 | if ip in net: 167 | return True 168 | except ValueError as e: 169 | logging.error(e) 170 | 171 | # We check regular expressions defining ranges 172 | # of IPs. If any range contains the request_ip 173 | # the access is granted 174 | for regex_ip_range in AllowedIP.objects.filter(ip_address__endswith="*"): 175 | if re.match(regex_ip_range.ip_address.replace("*", ".*"), request_ip): 176 | return True 177 | 178 | for domain in AllowedIP.objects.filter(ip_address__regex=r"^[a-zA-Z]"): 179 | if valid_fqdn(domain.ip_address) and \ 180 | request_ip == get_ip_address_for_fqdn(domain.ip_address): 181 | return True 182 | 183 | # Otherwise access is not granted 184 | return False 185 | 186 | def caching_enabled(self): 187 | return getattr(settings, 'ADMINRESTRICT_ENABLE_CACHE', 188 | False) 189 | 190 | def update_allow_always(self): 191 | # AllowedIP table empty means access is always granted 192 | # AllowedIP table has one entry with just '*' means access is always granted 193 | self.allow_always = AllowedIP.objects.count() == 0 or \ 194 | AllowedIP.objects.filter(ip_address="*").count() == 1 195 | 196 | def refresh_cache(self): 197 | if self.caching_enabled() and AdminPagesRestrictMiddleware._invalidate_cache: 198 | self.cache = {} 199 | for ip in AllowedIP.objects.all(): 200 | self.cache[ip] = True 201 | self.update_allow_always() 202 | AdminPagesRestrictMiddleware._invalidate_cache = False 203 | elif self.allow_always: 204 | self.update_allow_always() 205 | 206 | def process_request(self, request): 207 | """ 208 | Check if the request is made form an allowed IP 209 | """ 210 | self.refresh_cache() 211 | 212 | # Section adjusted to restrict login to ?edit 213 | # (sing cms-toolbar-login)into DjangoCMS login. 214 | restricted_request_uri = request.path.startswith( 215 | reverse( 216 | 'admin:index') or "cms-toolbar-login" in request.build_absolute_uri() 217 | ) 218 | 219 | if restricted_request_uri and request.method == 'GET': 220 | if self.request_ip_is_allowed(request): 221 | return None 222 | 223 | if self.disallow_get: 224 | return HttpResponseForbidden(self.denied_msg) 225 | else: 226 | return None 227 | 228 | if restricted_request_uri and request.method == 'POST': 229 | if not self.request_ip_is_allowed(request): 230 | return HttpResponseForbidden(self.denied_msg) 231 | else: 232 | return None 233 | -------------------------------------------------------------------------------- /adminrestrict/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-12-10 03:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='AllowedIP', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('ip_address', models.CharField(max_length=512)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /adminrestrict/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robromano/django-adminrestrict/b453084f50d29426b41b9909789b87198af553c5/adminrestrict/migrations/__init__.py -------------------------------------------------------------------------------- /adminrestrict/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | adminretrict models 3 | """ 4 | 5 | __author__ = "Robert Romano" 6 | __copyright__ = "Copyright 2021 Robert C. Romano" 7 | 8 | 9 | from django.db import models 10 | from django.conf import settings 11 | 12 | 13 | class AllowedIP(models.Model): 14 | """ 15 | Represents a whitelisted IP address who can access admin pages. 16 | """ 17 | ip_address = models.CharField(max_length=512, primary_key=True) 18 | 19 | def __str__(self): 20 | return '%s' % self.ip_address 21 | -------------------------------------------------------------------------------- /adminrestrict/signals.py: -------------------------------------------------------------------------------- 1 | """ 2 | adminrestrict signals 3 | """ 4 | 5 | __author__ = "Robert Romano" 6 | __copyright__ = "Copyright 2020 Robert C. Romano" 7 | 8 | from django.db.models.signals import post_save, post_delete 9 | from django.dispatch import receiver 10 | 11 | from adminrestrict.models import AllowedIP 12 | from adminrestrict.middleware import AdminPagesRestrictMiddleware 13 | 14 | 15 | @receiver(post_save, sender=AllowedIP) 16 | def allowed_ip_saved(sender, instance, created, **kwargs): 17 | AdminPagesRestrictMiddleware._invalidate_cache = True 18 | 19 | 20 | @receiver(post_delete, sender=AllowedIP) 21 | def allowed_ip_deleted(sender, instance, using, **kwargs): 22 | AdminPagesRestrictMiddleware._invalidate_cache = True 23 | -------------------------------------------------------------------------------- /adminrestrict/test_settings.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION[:2] >= (1, 3): 4 | DATABASES = { 5 | 'default': { 6 | 'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:', 8 | } 9 | } 10 | else: 11 | DATABASE_ENGINE = 'sqlite3' 12 | 13 | if django.VERSION[:2] >= (1, 8): 14 | TEMPLATES = [ 15 | { 16 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 17 | 'DIRS': [], 18 | 'OPTIONS': { 19 | 'context_processors': [ 20 | 'django.template.context_processors.debug', 21 | 'django.template.context_processors.request', 22 | 'django.contrib.auth.context_processors.auth', 23 | 'django.contrib.messages.context_processors.messages', 24 | ], 25 | 'loaders':[ 26 | ('django.template.loaders.cached.Loader', [ 27 | 'django.template.loaders.filesystem.Loader', 28 | 'django.template.loaders.app_directories.Loader', 29 | ]), 30 | ], 31 | }, 32 | }, 33 | ] 34 | 35 | SITE_ID = 1 36 | 37 | MIDDLEWARE_CLASSES = [ 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 41 | 'adminrestrict.middleware.AdminPagesRestrictMiddleware' 42 | ] 43 | 44 | if django.VERSION[0] >= 3 or (django.VERSION[0] == 2 and django.VERSION[1] >= 2): 45 | MIDDLEWARE_CLASSES.append('django.contrib.messages.middleware.MessageMiddleware') 46 | 47 | MIDDLEWARE = tuple(MIDDLEWARE_CLASSES) 48 | 49 | ROOT_URLCONF = 'adminrestrict.test_urls' 50 | 51 | INSTALLED_APPS = [ 52 | 'django.contrib.auth', 53 | 'django.contrib.contenttypes', 54 | 'django.contrib.sessions', 55 | 'django.contrib.sites', 56 | 'django.contrib.messages', 57 | 'django.contrib.admin', 58 | 'adminrestrict', 59 | ] 60 | 61 | 62 | SECRET_KEY = 'too-secret-for-test' 63 | 64 | LOGIN_REDIRECT_URL = '/admin' 65 | -------------------------------------------------------------------------------- /adminrestrict/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django import VERSION 3 | 4 | if VERSION[0] < 2: 5 | from django.conf.urls import url, include 6 | try: 7 | from django.conf.urls import patterns 8 | urlpatterns = patterns('', 9 | url(r'^admin/', include(admin.site.urls)), 10 | ) 11 | except ImportError: 12 | urlpatterns = [ 13 | url(r'^admin/', include(admin.site.urls)) 14 | ] 15 | else: 16 | if VERSION[0] >= 4: 17 | from django.urls import include, re_path 18 | urlpatterns = [ 19 | re_path('admin/', admin.site.urls) 20 | ] 21 | else: 22 | from django.conf.urls import url, include 23 | from django.urls import path 24 | urlpatterns = [ 25 | path('admin/', admin.site.urls) 26 | ] 27 | -------------------------------------------------------------------------------- /adminrestrict/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | adminrestrict tests 3 | """ 4 | 5 | __author__ = "Robert Romano" 6 | __copyright__ = "Copyright 2021 Robert C. Romano" 7 | 8 | import logging 9 | import sys 10 | from unittest import skipUnless 11 | 12 | from django import VERSION as DJANGO_VERSION 13 | from django.test import TestCase 14 | from django.contrib.auth.models import User 15 | from django.core.management import call_command 16 | 17 | try: 18 | from django.core.urlresolvers import reverse 19 | except ImportError as e: 20 | from django.urls import reverse 21 | 22 | from adminrestrict.models import AllowedIP 23 | 24 | 25 | class BasicTests(TestCase): 26 | def setUp(self): 27 | logging.disable(logging.ERROR) 28 | self.user = User.objects.create_user(username="foo", password="bar") 29 | 30 | def test_disallow_get(self): 31 | a = AllowedIP.objects.create(ip_address="10.10.0.1") 32 | with self.settings(ADMINRESTRICT_BLOCK_GET=True): 33 | resp = self.client.get("/admin/") 34 | self.assertEqual(resp.status_code, 403) 35 | a.delete() 36 | 37 | def test_allow_get_initial_page(self): 38 | a = AllowedIP.objects.create(ip_address="10.10.0.1") 39 | resp = self.client.get("/admin/") 40 | self.assertIn(resp.status_code, [200, 302]) 41 | a.delete() 42 | 43 | def test_get_redirected(self): 44 | admin_url = reverse('admin:index') 45 | a = AllowedIP.objects.create(ip_address="10.10.0.1") 46 | resp = self.client.get(admin_url) 47 | if DJANGO_VERSION < (1, 7, 0): 48 | self.assertEqual(resp.status_code, 200) 49 | else: 50 | self.assertEqual(resp.status_code, 302) 51 | a.delete() 52 | 53 | def test_allow_all_if_empty(self): 54 | resp = self.client.post( 55 | "/admin/", data={'username': "foo", 'password': "bar"}) 56 | self.assertIn(resp.status_code, [200, 302]) 57 | 58 | def test_allowed_ip(self): 59 | a = AllowedIP.objects.create(ip_address="4.4.4.4") 60 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 61 | follow=True, REMOTE_ADDR="4.4.4.4") 62 | self.assertEqual(resp.status_code, 200) 63 | a.delete() 64 | 65 | def test_allowed_wildcard(self): 66 | a = AllowedIP.objects.create(ip_address="127.0*") 67 | resp = self.client.post( 68 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 69 | self.assertEqual(resp.status_code, 200) 70 | a.delete() 71 | 72 | def test_blocked_no_wildcard_match(self): 73 | a = AllowedIP.objects.create(ip_address="16*") 74 | resp = self.client.post( 75 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 76 | self.assertEqual(resp.status_code, 403) 77 | a.delete() 78 | 79 | def test_default_denied_msg(self): 80 | DENIED_MSG = b"Access to admin is denied." 81 | a = AllowedIP.objects.create(ip_address="16*") 82 | resp = self.client.post( 83 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 84 | self.assertEqual(resp.status_code, 403) 85 | self.assertEqual(resp.content, DENIED_MSG) 86 | a.delete() 87 | 88 | def test_custom_denied_msg(self): 89 | DENIED_MSG = b"denied!" 90 | a = AllowedIP.objects.create(ip_address="16*") 91 | with self.settings(ADMINRESTRICT_DENIED_MSG=DENIED_MSG): 92 | resp = self.client.post( 93 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 94 | self.assertEqual(resp.status_code, 403) 95 | self.assertEqual(resp.content, DENIED_MSG) 96 | a.delete() 97 | 98 | def test_allow_all(self): 99 | a = AllowedIP.objects.create(ip_address="*") 100 | resp = self.client.post( 101 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 102 | self.assertEqual(resp.status_code, 200) 103 | a.delete() 104 | 105 | @skipUnless(sys.version_info > (3, 0), "Python3 only") 106 | def test_allowed_cidr_range(self): 107 | a = AllowedIP.objects.create(ip_address="127.0.0.0/24") 108 | resp = self.client.post( 109 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 110 | self.assertEqual(resp.status_code, 200) 111 | a.delete() 112 | 113 | @skipUnless(sys.version_info > (3, 0), "Python3 only") 114 | def test_bad_cidr_range(self): 115 | a = AllowedIP.objects.create(ip_address="127.0.0.0/9100") 116 | resp = self.client.post( 117 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 118 | self.assertEqual(resp.status_code, 403) 119 | a.delete() 120 | 121 | def test_allow_private_ip(self): 122 | a = AllowedIP.objects.create(ip_address="8.8.8.8") 123 | with self.settings(ADMINRESTRICT_ALLOW_PRIVATE_IP=True): 124 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 125 | follow=True, REMOTE_ADDR="192.168.1.1") 126 | self.assertEqual(resp.status_code, 200) 127 | a.delete() 128 | 129 | def test_disallow_custom_private_ip(self): 130 | a = AllowedIP.objects.create(ip_address="8.8.8.8") 131 | with self.settings(ADMINRESTRICT_PRIVATE_IP_PREFIXES=('11.', '172.', '192.', '127.')): 132 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 133 | follow=True, REMOTE_ADDR="11.0.0.1") 134 | self.assertEqual(resp.status_code, 403) 135 | a.delete() 136 | 137 | def test_allow_custom_private_ip(self): 138 | a = AllowedIP.objects.create(ip_address="10.10.0.1") 139 | with self.settings(ADMINRESTRICT_PRIVATE_IP_PREFIXES=('11.', '172.', '192.', '127.')): 140 | with self.settings(ADMINRESTRICT_ALLOW_PRIVATE_IP=True): 141 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 142 | follow=True, HTTP_X_FORWARDED_FOR="11.0.0.1,10.10.1.1") 143 | self.assertEqual(resp.status_code, 200) 144 | a.delete() 145 | 146 | def test_allow_domain_lookup(self): 147 | a = AllowedIP.objects.create(ip_address="ns4.zdns.google.") 148 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 149 | follow=True, REMOTE_ADDR="216.239.38.114") 150 | self.assertEqual(resp.status_code, 200) 151 | a.delete() 152 | 153 | def test_allow_deny_ip_using_cache(self): 154 | with self.settings(ADMINRESTRICT_ENABLE_CACHE=True): 155 | a = AllowedIP.objects.create(ip_address="8.8.8.8") 156 | resp = self.client.post( 157 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 158 | self.assertEqual(resp.status_code, 403) 159 | a.delete() 160 | a = AllowedIP.objects.create(ip_address="*") 161 | resp = self.client.post( 162 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 163 | self.assertEqual(resp.status_code, 200) 164 | a.delete() 165 | a = AllowedIP.objects.create(ip_address="127*") 166 | resp = self.client.post( 167 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 168 | self.assertEqual(resp.status_code, 200) 169 | a.delete() 170 | a = AllowedIP.objects.create(ip_address="8.*") 171 | resp = self.client.post( 172 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 173 | self.assertEqual(resp.status_code, 403) 174 | 175 | def test_add_first_restriction(self): 176 | resp = self.client.post( 177 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 178 | self.assertEqual(resp.status_code, 200) 179 | 180 | AllowedIP.objects.create(ip_address="8.8.8.8") 181 | resp = self.client.post( 182 | "/admin/", data={'username': "foo", 'password': "bar"}, follow=True) 183 | self.assertEqual(resp.status_code, 403) 184 | 185 | def test_combined(self): 186 | AllowedIP.objects.create(ip_address="4.4.4.4") 187 | AllowedIP.objects.create(ip_address="a*") 188 | AllowedIP.objects.create(ip_address="168*") 189 | AllowedIP.objects.create(ip_address="ns4.zdns.google.") 190 | 191 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 192 | follow=True, REMOTE_ADDR="4.4.4.4") 193 | self.assertEqual(resp.status_code, 200) 194 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 195 | follow=True, REMOTE_ADDR="168.0.0.1") 196 | self.assertEqual(resp.status_code, 200) 197 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 198 | follow=True, REMOTE_ADDR="8.8.8.8") 199 | self.assertEqual(resp.status_code, 403) 200 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 201 | follow=True, REMOTE_ADDR="216.239.38.114") 202 | self.assertEqual(resp.status_code, 200) 203 | 204 | AllowedIP.objects.all().delete() 205 | 206 | def test_ip6(self): 207 | AllowedIP.objects.create(ip_address="::1") 208 | AllowedIP.objects.create( 209 | ip_address="2001:0db8:85a3:0000:0000:8a2e:0370:7334") 210 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 211 | follow=True, REMOTE_ADDR="::1") 212 | self.assertEqual(resp.status_code, 200) 213 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 214 | follow=True, REMOTE_ADDR="2001:0db8:85a3:0000:0000:8a2e:0370:7334") 215 | self.assertEqual(resp.status_code, 200) 216 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 217 | follow=True, REMOTE_ADDR="2001:0db8:85a4:0000:0000:8a2e:0370:7334") 218 | self.assertEqual(resp.status_code, 403) 219 | 220 | def test_ip6_cidr(self): 221 | AllowedIP.objects.create(ip_address="2001:0db8:85a3:0000::/64") 222 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 223 | follow=True, REMOTE_ADDR="2001:0db8:85a3:0000:0000:8a2e:0370:7334") 224 | self.assertEqual(resp.status_code, 200) 225 | resp = self.client.post("/admin/", data={'username': "foo", 'password': "bar"}, 226 | follow=True, REMOTE_ADDR="2001:0db8:85a4:0000:0000:8a2e:0370:7334") 227 | self.assertEqual(resp.status_code, 403) 228 | 229 | async def test_async_middleware(self): 230 | resp = await self.async_client.get("/admin/") 231 | self.assertIn(resp.status_code, [200, 302]) 232 | 233 | 234 | class ManagementTests(TestCase): 235 | def setUp(self): 236 | logging.disable(logging.ERROR) 237 | 238 | def test_allow_command(self): 239 | self.assertFalse(AllowedIP.objects.filter( 240 | ip_address='10.10.10.1').exists()) 241 | call_command('addadminip', '10.10.10.1') 242 | self.assertTrue(AllowedIP.objects.filter( 243 | ip_address='10.10.10.1').exists()) 244 | resp = self.client.post("/admin/") 245 | self.assertEqual(resp.status_code, 403) 246 | 247 | def test_remove_command(self): 248 | AllowedIP.objects.create(ip_address="4.4.4.4") 249 | AllowedIP.objects.create(ip_address="10.10.10.1") 250 | self.assertTrue(AllowedIP.objects.filter( 251 | ip_address='10.10.10.1').exists()) 252 | call_command('removeadminip', '10.10.10.1') 253 | self.assertFalse(AllowedIP.objects.filter( 254 | ip_address='10.10.10.1').exists()) 255 | resp = self.client.post("/admin/") 256 | self.assertEqual(resp.status_code, 403) 257 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from setuptools import setup, find_packages 6 | 7 | # The directory containing this file 8 | HERE = os.path.realpath(os.path.join(__file__, '..')) 9 | 10 | # The text of the README file 11 | README = open(os.path.join(HERE,"README.md")).read() 12 | 13 | VERSION = '3.1' 14 | 15 | setup( 16 | name='django-adminrestrict', 17 | version=VERSION, 18 | description="Restrict admin pages using simple IP address rules", 19 | long_description=README, 20 | long_description_content_type="text/markdown", 21 | keywords='authentication, django, security', 22 | author='Robert Romano', 23 | author_email='rromano@example.com', 24 | url='https://github.com/robromano/django-adminrestrict', 25 | license='MIT', 26 | package_dir={'adminrestrict': 'adminrestrict'}, 27 | include_package_data=True, 28 | packages=find_packages(), 29 | classifiers=[ 30 | 'Development Status :: 5 - Production/Stable', 31 | 'Environment :: Web Environment', 32 | 'Framework :: Django', 33 | 'Intended Audience :: Developers', 34 | 'Intended Audience :: System Administrators', 35 | 'License :: OSI Approved :: MIT License', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 3', 39 | 'Topic :: Internet :: Log Analysis', 40 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware', 41 | 'Topic :: Security', 42 | 'Topic :: System :: Logging', 43 | ], 44 | zip_safe=False, 45 | ) 46 | --------------------------------------------------------------------------------