Skip to content

Commit

Permalink
Improve the settings definitions (GH-13)
Browse files Browse the repository at this point in the history
  • Loading branch information
ArtyomVancyan authored Apr 14, 2023
2 parents 139ea93 + 53a96bc commit 8ba44be
Show file tree
Hide file tree
Showing 10 changed files with 125 additions and 107 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.idea
.tox
.pytest_cache
*.egg-info
*.egg-info
__pycache__
67 changes: 28 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/django_forbid/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.4"
__version__ = "0.0.5"
50 changes: 27 additions & 23 deletions src/django_forbid/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -26,22 +28,22 @@ 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()

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."""
Expand All @@ -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:
Expand All @@ -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)
27 changes: 27 additions & 0 deletions src/django_forbid/config.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 8 additions & 6 deletions src/django_forbid/detect.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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)",
Expand All @@ -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.
Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions src/django_forbid/middleware.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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.
Expand All @@ -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()
Loading

0 comments on commit 8ba44be

Please sign in to comment.