diff --git a/.gitignore b/.gitignore index 35c3613..42ee7be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea .tox .pytest_cache -*.egg-info \ No newline at end of file +*.egg-info +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md index 35029e5..cf304f8 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Django app for forbidding access to some countries. [![PyPI](https://img.shields.io/pypi/v/django-forbid.svg)](https://pypi.org/project/django-forbid/) -[![Django](https://img.shields.io/badge/django-%3E%3D2.1-blue.svg)](https://pypi.org/project/django-forbid/) -[![Python](https://img.shields.io/pypi/pyversions/django-forbid.svg)](https://pypi.org/project/django-forbid/) +[![Python](https://img.shields.io/pypi/pyversions/django-forbid.svg?logoColor=white)](https://pypi.org/project/django-forbid/) +[![Django](https://img.shields.io/pypi/djversions/django-forbid.svg?color=0C4B33&label=django)](https://pypi.org/project/django-forbid/) [![License](https://img.shields.io/pypi/l/django-forbid.svg)](https://github.com/pysnippet/django-forbid/blob/master/LICENSE) [![Tests](https://github.com/pysnippet/django-forbid/actions/workflows/tests.yml/badge.svg)](https://github.com/pysnippet/django-forbid/actions/workflows/tests.yml) @@ -42,51 +42,40 @@ configuration. ## Usage After connecting the Django Forbid to your project, you can define the set of desired zones to be forbidden or allowed. -And there are four setting variables for describing any of your specific needs: - -- `WHITELIST_COUNTRIES` and `WHITELIST_TERRITORIES` - Correspondingly, the list of countries and territories that are - allowed to access the site. -- `FORBIDDEN_COUNTRIES` and `FORBIDDEN_TERRITORIES` - Correspondingly, the list of countries and territories that are - forbidden to access the site. - -Forbidden countries and territories have a higher priority than allowed ones. If a country or territory is in both -lists, then the user will be forbidden. And if the user is not allowed to access the resource, it will be redirected to -the `FORBIDDEN_URL` page if the variable is set in your Django project's settings. - -```python -# Only US, GB, and EU countries are allowed to access the site. -WHITELIST_COUNTRIES = ['US', 'GB'] -WHITELIST_TERRITORIES = ['EU'] -``` - -Needs can be different, so you can use any combination of these variables to describe your special needs. +All you need is to set the `DJANGO_FORBID` variable in your project's settings. It should be a dictionary with the +following keys: + +- `COUNTRIES` - list of countries to permit or forbid access to +- `TERRITORIES` - list of territories to permit or forbid access to +- `OPTIONS` - a dictionary for additional settings + - `ACTION` - whether to `PERMIT` or `FORBID` access to the listed zones (default is `FORBID`) + - `PERIOD` - time in seconds to check for access again, 0 means on each request + - `VPN` - use VPN detection and forbid access to VPN users + - `URL` - set of URLs to redirect to when the user is located in a forbidden country or using a VPN + - `FORBIDDEN_LOC` - the URL to redirect to when the user is located in a forbidden country + - `FORBIDDEN_VPN` - the URL to redirect to when the user is using a VPN ```python -# Forbid access for African countries and Russia, Belarus, and North Korea. -FORBIDDEN_COUNTRIES = ['RU', 'BY', 'KP'] -FORBIDDEN_TERRITORIES = ['AF'] +DJANGO_FORBID = { + 'COUNTRIES': ['US', 'GB'], + 'TERRITORIES': ['EU'], + 'OPTIONS': { + 'ACTION': 'PERMIT', + 'PERIOD': 300, + 'VPN': True, + 'URL': { + 'FORBIDDEN_LOC': 'forbidden_country', + 'FORBIDDEN_VPN': 'forbidden_network', + }, + }, +} ``` The available ISO 3166 alpha-2 country codes are listed in [here](https://www.iban.com/country-codes). And the available ISO continent codes are: `AF` - Africa, `AN` - Antarctica, `AS` - Asia, `EU` - Europe, `NA` - North America, `OC` - Oceania and `SA` - South America. -### Check access on timeout - -Without additional configuration, the middleware will check the user's access on every request. This can slow down the -site. To avoid this, you can use the `FORBID_TIMEOUT` variable to set the cache timeout in seconds. When the timeout -expires, the middleware will check the user's access again. - -```python -# Check the user's access every 10 minutes. -FORBID_TIMEOUT = 60 * 10 -``` - -### Detect usage of a VPN - -If you want to detect the usage of a VPN, you can use the `FORBID_VPN` variable. When this variable is set to `True`, -the middleware will check if the user's timezone matches the timezone the IP address belongs to. If the timezones do not -match, the user will be considered in the usage of a VPN and forbidden to access the site. +_None of the settings are required. If you don't specify any settings, the middleware will not do anything._ ## Contribute diff --git a/setup.cfg b/setup.cfg index 6c30c2b..20d04b1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,8 +16,15 @@ license = MIT license_file = LICENSE platforms = unix, linux, osx, win32 classifiers = - Framework :: Django Operating System :: OS Independent + Framework :: Django + Framework :: Django :: 2.1 + Framework :: Django :: 2.2 + Framework :: Django :: 3.1 + Framework :: Django :: 3.2 + Framework :: Django :: 4.0 + Framework :: Django :: 4.1 + Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 diff --git a/src/django_forbid/__init__.py b/src/django_forbid/__init__.py index 81f0fde..b1a19e3 100644 --- a/src/django_forbid/__init__.py +++ b/src/django_forbid/__init__.py @@ -1 +1 @@ -__version__ = "0.0.4" +__version__ = "0.0.5" diff --git a/src/django_forbid/access.py b/src/django_forbid/access.py index 16dcc35..022b9e9 100644 --- a/src/django_forbid/access.py +++ b/src/django_forbid/access.py @@ -2,6 +2,8 @@ from django.contrib.gis.geoip2 import GeoIP2 from geoip2.errors import AddressNotFoundError +from .config import Settings + class Rule: # Key in the geoip2 city object. @@ -26,10 +28,8 @@ class ContinentRule(Rule): class Access: - # Variables in the settings module. - # Subclasses should override this. - countries = None - territories = None + countries = "COUNTRIES" + territories = "TERRITORIES" # Hold the instance of GeoIP2. geoip = GeoIP2() @@ -37,11 +37,13 @@ class Access: def __init__(self): self.rules = [] - for country in getattr(settings, self.countries, []): - self.rules.append(CountryRule(country.upper())) + if Settings.has(self.countries): + for country in Settings.get(self.countries): + self.rules.append(CountryRule(country.upper())) - for territory in getattr(settings, self.territories, []): - self.rules.append(ContinentRule(territory.upper())) + if Settings.has(self.territories): + for territory in Settings.get(self.territories): + self.rules.append(ContinentRule(territory.upper())) def accessible(self, city): """Checks if the IP address is in the white zone.""" @@ -53,23 +55,28 @@ def grants(self, city): class PermitAccess(Access): - countries = "WHITELIST_COUNTRIES" - territories = "WHITELIST_TERRITORIES" - def grants(self, city): """Checks if the IP address is permitted.""" return not self.rules or self.accessible(city) class ForbidAccess(Access): - countries = "FORBIDDEN_COUNTRIES" - territories = "FORBIDDEN_TERRITORIES" - def grants(self, city): """Checks if the IP address is forbidden.""" return not self.rules or not self.accessible(city) +class Factory: + """Creates an instance of the Access class.""" + + FORBID = ForbidAccess + PERMIT = PermitAccess + + @classmethod + def create_access(cls, action): + return getattr(cls, action)() + + def grants_access(request, ip_address): """Checks if the IP address is in the white zone.""" try: @@ -82,18 +89,15 @@ def grants_access(request, ip_address): timezone = city.get("time_zone") request.session["tz"] = timezone - # First, checks if the IP address is not - # forbidden. If it is, False is returned - # otherwise, checks if the IP address is - # permitted. - if ForbidAccess().grants(city): - return PermitAccess().grants(city) - return False + # Creates an instance of the Access class + # and checks if the IP address is granted. + action = Settings.get("OPTIONS.ACTION", "FORBID") + return Factory.create_access(action).grants(city) except (AddressNotFoundError, Exception): # This happens when the IP address is not # in the GeoIP2 database. Usually, this # happens when the IP address is a local. return not any([ - ForbidAccess().rules, - PermitAccess().rules, + Settings.has(Access.countries), + Settings.has(Access.territories), ]) or getattr(settings, "DEBUG", False) diff --git a/src/django_forbid/config.py b/src/django_forbid/config.py new file mode 100644 index 0000000..ccbf939 --- /dev/null +++ b/src/django_forbid/config.py @@ -0,0 +1,27 @@ +from django.conf import settings + + +class Settings: + """A helper class to access settings in a more convenient way.""" + + @classmethod + def _get(cls, item): + result = getattr(settings, "DJANGO_FORBID", {}) + for attr in item.split("."): + result = result[attr] + return result + + @classmethod + def has(cls, item): + try: + cls._get(item) + return True + except KeyError: + return False + + @classmethod + def get(cls, item, default=None): + try: + return cls._get(item) + except KeyError: + return default diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 13dd59b..83d8cb8 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -1,12 +1,13 @@ import json import re -from django.conf import settings from django.http import HttpResponse from django.http import HttpResponseForbidden from django.shortcuts import redirect from django.shortcuts import render +from .config import Settings + def detect_vpn(get_response, request): response_attributes = ("content", "charset", "status", "reason") @@ -19,8 +20,8 @@ def erase_response_attributes(): # The session key is checked to avoid # redirect loops in development mode. not request.session.has_key("tz"), - # Checks if FORBID_VPN is False or not set. - not getattr(settings, "FORBID_VPN", False), + # Checks if VPN is False or not set. + not Settings.get("OPTIONS.VPN", False), # Checks if the request is an AJAX request. not re.search( r"\w+\/(?:html|xhtml\+xml|xml)", @@ -34,8 +35,9 @@ def erase_response_attributes(): # one determined by GeoIP API. If so, VPN is used. if request.POST.get("timezone", "N/A") != request.session.get("tz"): erase_response_attributes() - if hasattr(settings, "FORBIDDEN_URL"): - return redirect(settings.FORBIDDEN_URL) + # Redirects to the FORBIDDEN_VPN URL if set. + if Settings.has("OPTIONS.URL.FORBIDDEN_VPN"): + return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_VPN")) return HttpResponseForbidden() # Restores the response from the session. @@ -48,7 +50,7 @@ def erase_response_attributes(): # Gets the response and saves attributes in the session to restore it later. response = get_response(request) if hasattr(response, "headers"): - # In older versions of Django, HttpResponse does not have headers attribute. + # In older versions of Django, HttpResponse does not have headers. request.session["headers"] = json.dumps(dict(response.headers)) request.session["content"] = response.content.decode(response.charset) request.session["charset"] = response.charset diff --git a/src/django_forbid/middleware.py b/src/django_forbid/middleware.py index cd7b0f1..f6a211b 100644 --- a/src/django_forbid/middleware.py +++ b/src/django_forbid/middleware.py @@ -1,11 +1,11 @@ from datetime import datetime -from django.conf import settings from django.http import HttpResponseForbidden from django.shortcuts import redirect from django.utils.timezone import utc from .access import grants_access +from .config import Settings from .detect import detect_vpn @@ -19,12 +19,12 @@ def __call__(self, request): address = request.META.get("REMOTE_ADDR") address = request.META.get("HTTP_X_FORWARDED_FOR", address) - # Checks if the timeout variable is set and the user has been granted access. - if hasattr(settings, "FORBID_TIMEOUT") and request.session.has_key("ACCESS"): + # Checks if the PERIOD attr is set and the user has been granted access. + if Settings.has("OPTIONS.PERIOD") and request.session.has_key("ACCESS"): acss = datetime.utcnow().replace(tzinfo=utc).timestamp() # Checks if access is not timed out yet. - if acss - request.session.get("ACCESS") < settings.FORBID_TIMEOUT: + if acss - request.session.get("ACCESS") < Settings.get("OPTIONS.PERIOD"): return detect_vpn(self.get_response, request) # Checks if access is granted when timeout is reached. @@ -33,8 +33,8 @@ def __call__(self, request): request.session["ACCESS"] = acss.timestamp() return detect_vpn(self.get_response, request) - # Redirects to forbidden page if URL is set. - if hasattr(settings, "FORBIDDEN_URL"): - return redirect(settings.FORBIDDEN_URL) + # Redirects to the FORBIDDEN_LOC URL if set. + if Settings.has("OPTIONS.URL.FORBIDDEN_LOC"): + return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_LOC")) return HttpResponseForbidden() diff --git a/tests/test_grants_access.py b/tests/test_grants_access.py index d3ce67b..eb73a5b 100644 --- a/tests/test_grants_access.py +++ b/tests/test_grants_access.py @@ -18,13 +18,13 @@ def test_access_without_configuration(): assert grants_access(request, LOCALHOST) -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_access_forbid_vpn(): """If VPN detection is enabled, access is granted everywhere.""" assert grants_access(request, LOCALHOST) -@override_settings(WHITELIST_COUNTRIES=["US"], DEBUG=True) +@override_settings(DJANGO_FORBID={"COUNTRIES": ["US"], "OPTIONS": {"ACTION": "PERMIT"}}, DEBUG=True) def test_access_from_localhost_development_mode(): """In development mode, access is granted from localhost.""" assert grants_access(request, IP_LOCAL1) @@ -32,7 +32,7 @@ def test_access_from_localhost_development_mode(): assert grants_access(request, LOCALHOST) -@override_settings(WHITELIST_COUNTRIES=["US"]) +@override_settings(DJANGO_FORBID={"COUNTRIES": ["US"], "OPTIONS": {"ACTION": "PERMIT"}}) def test_access_from_localhost_production_mode(): """In production mode, access is not granted from localhost.""" assert not grants_access(request, IP_LOCAL1) @@ -40,61 +40,49 @@ def test_access_from_localhost_production_mode(): assert not grants_access(request, LOCALHOST) -@override_settings(WHITELIST_COUNTRIES=["GB"]) +@override_settings(DJANGO_FORBID={"COUNTRIES": ["GB"], "OPTIONS": {"ACTION": "PERMIT"}}) def test_access_from_gb_when_gb_in_countries_whitelist(): """Access is granted from GB when GB is in the counties' whitelist.""" assert grants_access(request, IP_LONDON) -@override_settings(WHITELIST_COUNTRIES=["US"]) +@override_settings(DJANGO_FORBID={"COUNTRIES": ["US"], "OPTIONS": {"ACTION": "PERMIT"}}) def test_access_from_gb_when_gb_not_in_countries_whitelist(): """Access is not granted from GB when GB is not in the counties' whitelist.""" assert not grants_access(request, IP_LONDON) -@override_settings(WHITELIST_TERRITORIES=["EU"]) +@override_settings(DJANGO_FORBID={"TERRITORIES": ["EU"], "OPTIONS": {"ACTION": "PERMIT"}}) def test_access_from_gb_when_eu_in_continent_whitelist(): """Access is granted from GB when EU is in the continents' whitelist.""" assert grants_access(request, IP_LONDON) -@override_settings(WHITELIST_TERRITORIES=["US"]) +@override_settings(DJANGO_FORBID={"TERRITORIES": ["US"], "OPTIONS": {"ACTION": "PERMIT"}}) def test_access_from_gb_when_gb_not_in_continent_whitelist(): """Access is not granted from GB when EU is not in the continents' whitelist.""" assert not grants_access(request, IP_LONDON) -@override_settings(FORBIDDEN_COUNTRIES=["GB"]) +@override_settings(DJANGO_FORBID={"COUNTRIES": ["GB"], "OPTIONS": {"ACTION": "FORBID"}}) def test_access_from_gb_when_gb_in_forbidden_countries(): """Access is not granted from GB when GB is in the forbidden list.""" assert not grants_access(request, IP_LONDON) -@override_settings(FORBIDDEN_COUNTRIES=["RU"]) +@override_settings(DJANGO_FORBID={"COUNTRIES": ["RU"], "OPTIONS": {"ACTION": "FORBID"}}) def test_access_from_gb_when_gb_not_in_forbidden_countries(): """Access is granted from GB when GB is not in the forbidden list.""" assert grants_access(request, IP_LONDON) -@override_settings(FORBIDDEN_TERRITORIES=["EU"]) +@override_settings(DJANGO_FORBID={"TERRITORIES": ["EU"], "OPTIONS": {"ACTION": "FORBID"}}) def test_access_from_gb_when_eu_in_forbidden_territories(): """Access is not granted from GB when EU is in the forbidden list.""" assert not grants_access(request, IP_LONDON) -@override_settings(FORBIDDEN_TERRITORIES=["AS"]) +@override_settings(DJANGO_FORBID={"TERRITORIES": ["AS"], "OPTIONS": {"ACTION": "FORBID"}}) def test_access_from_gb_when_eu_not_in_forbidden_territories(): """Access is granted from GB when EU is not in the forbidden list.""" assert grants_access(request, IP_LONDON) - - -@override_settings(WHITELIST_TERRITORIES=["EU"], FORBIDDEN_COUNTRIES=["GB"]) -def test_mix_config_access_from_gb_when_eu_in_whitelist_but_gb_is_forbidden(): - """Access is not granted from GB when EU is in the whitelist but GB is forbidden.""" - assert not grants_access(request, IP_LONDON) - - -@override_settings(WHITELIST_COUNTRIES=["GB"], FORBIDDEN_COUNTRIES=["GB"]) -def test_mix_config_access_from_gb_when_gb_in_both_lists(): - """Access is not granted from GB when GB is in both lists.""" - assert not grants_access(request, IP_LONDON) diff --git a/tests/test_vpn_detection.py b/tests/test_vpn_detection.py index f5b8d82..827651a 100644 --- a/tests/test_vpn_detection.py +++ b/tests/test_vpn_detection.py @@ -50,7 +50,7 @@ def request_access(self): return detect_vpn(self.get_response, request) -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_detect_when_using_localhost(get_response): """It should give access to the user when using localhost""" detector = Detector(get_response) @@ -58,7 +58,7 @@ def test_detect_when_using_localhost(get_response): assert response.status_code == 200 -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_detect_when_using_localhost_ajax(get_response): """It should give access to the user when request is done by AJAX""" detector = Detector(get_response, True) @@ -66,7 +66,7 @@ def test_detect_when_using_localhost_ajax(get_response): assert response.status_code == 200 -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_detect_when_using_nonlocal_ip(get_response): """User should pass through two requests to access the resource""" detector = Detector(get_response) @@ -76,7 +76,7 @@ def test_detect_when_using_nonlocal_ip(get_response): assert response.status_code == 200 -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_detect_when_using_vpn(get_response): """User should be forbidden to access the resource when using VPN""" detector = Detector(get_response) @@ -86,7 +86,7 @@ def test_detect_when_using_vpn(get_response): assert response.status_code == 403 -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_detect_when_turns_off_vpn_after_using(get_response): """User should get access to the resource when VPN is turned off""" detector = Detector(get_response) @@ -103,7 +103,7 @@ def test_detect_when_turns_off_vpn_after_using(get_response): assert response.status_code == 200 -@override_settings(FORBID_VPN=True) +@override_settings(DJANGO_FORBID={"OPTIONS": {"VPN": True}}) def test_detect_when_using_nonlocal_ip_ajax(get_response): """It should give access to the user when request is done by AJAX""" detector = Detector(get_response, True)