Skip to content

Commit

Permalink
Redesign the ForbidMiddleware architecture (GH-17)
Browse files Browse the repository at this point in the history
  • Loading branch information
ArtyomVancyan authored May 16, 2023
2 parents d10ec75 + 97ac16a commit 740140f
Show file tree
Hide file tree
Showing 16 changed files with 582 additions and 463 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# Django Forbid <img src="https://github.com/pysnippet.png" align="right" height="64" />

Secure your Django app by controlling the access - grant or deny user access based on device and location, including VPN
detection.

[![PyPI](https://img.shields.io/pypi/v/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)

Django Forbid aims to make website access managed and secure for the maintainers. It provides a middleware to grant or
deny user access based on device and/or location. It also supports VPN detection for banning users who want to lie about
their country and geolocation. Also, users can use only the VPN detection feature or disable it.

## Install

```shell
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.6"
__version__ = "0.0.7"
60 changes: 0 additions & 60 deletions src/django_forbid/detect.py

This file was deleted.

57 changes: 0 additions & 57 deletions src/django_forbid/device.py

This file was deleted.

53 changes: 11 additions & 42 deletions src/django_forbid/middleware.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
from datetime import datetime
from .skills.forbid_device import ForbidDeviceMiddleware
from .skills.forbid_location import ForbidLocationMiddleware
from .skills.forbid_network import ForbidNetworkMiddleware

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
from .device import detect_device
from .device import device_forbidden
__skills__ = (
ForbidDeviceMiddleware,
ForbidLocationMiddleware,
ForbidNetworkMiddleware,
)


class ForbidMiddleware:
Expand All @@ -18,35 +16,6 @@ def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
address = request.META.get("REMOTE_ADDR")
address = request.META.get("HTTP_X_FORWARDED_FOR", address)

# Detects the user's device and saves it in the session.
if not request.session.get("DEVICE"):
http_ua = request.META.get("HTTP_USER_AGENT")
request.session["DEVICE"] = detect_device(http_ua)

if device_forbidden(request.session.get("DEVICE")):
if Settings.has("OPTIONS.URL.FORBIDDEN_KIT"):
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_KIT"))
return HttpResponseForbidden()

# 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.get("OPTIONS.PERIOD"):
return detect_vpn(self.get_response, request)

# Checks if access is granted when timeout is reached.
if grants_access(request, address.split(",")[0].strip()):
acss = datetime.utcnow().replace(tzinfo=utc)
request.session["ACCESS"] = acss.timestamp()
return detect_vpn(self.get_response, request)

# 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()
for skill in __skills__:
self.get_response = skill(self.get_response)
return self.get_response(request)
73 changes: 73 additions & 0 deletions src/django_forbid/skills/forbid_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import re

from device_detector import DeviceDetector
from django.http import HttpResponseForbidden
from django.shortcuts import redirect

from ..config import Settings


def normalize(device_type):
"""Removes the "!" prefix from the device type."""
return device_type[1:]


def forbidden(device_type):
"""Checks if the device type is forbidden."""
return device_type.startswith("!")


def permitted(device_type):
"""Checks if the device type is permitted."""
return not forbidden(device_type)


class ForbidDeviceMiddleware:
"""Checks if the user device is forbidden."""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
device_aliases = {
"portable media player": "player",
"smart display": "display",
"smart speaker": "speaker",
"feature phone": "phone",
"car browser": "car",
}

device_type = request.session.get("DEVICE")
devices = Settings.get("DEVICES", [])

# Permit all devices if the
# DEVICES setting is empty.
if not devices:
return self.get_response(request)

if not request.session.get("DEVICE"):
http_ua = request.META.get("HTTP_USER_AGENT")
device_detector = DeviceDetector(http_ua)
device_detector = device_detector.parse()
device = device_detector.device_type()
device_type = device_aliases.get(device, device)
request.session["DEVICE"] = device_type

# Creates a regular expression in the following form:
# ^(?=PERMITTED_DEVICES)(?:(?!FORBIDDEN_DEVICES)\w)+$
# where the list of forbidden and permitted devices are
# filtered from the DEVICES setting by the "!" prefix.
permit = r"|".join(filter(permitted, devices))
forbid = r"|".join(map(normalize, filter(forbidden, devices)))
forbid = r"(?!" + forbid + r")" if forbid else ""
regexp = r"^(?=" + permit + r")(?:" + forbid + r"\w)+$"

# Regexp designed to match the permitted devices.
if re.match(regexp, device_type):
return self.get_response(request)

# Redirects to the FORBIDDEN_KIT URL if set.
if Settings.has("OPTIONS.URL.FORBIDDEN_KIT"):
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_KIT"))

return HttpResponseForbidden()
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from django.conf import settings
from django.contrib.gis.geoip2 import GeoIP2
from django.http import HttpResponseForbidden
from django.shortcuts import redirect
from geoip2.errors import AddressNotFoundError

from .config import Settings
from ..config import Settings


class Rule:
Expand Down Expand Up @@ -77,27 +79,46 @@ 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:
city = Access.geoip.city(ip_address)

# Saves the timezone in the session for
# comparing it with the timezone in the
# POST request sent from user's browser
# to detect if the user is using VPN.
timezone = city.get("time_zone")
request.session["tz"] = timezone

# 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([
Settings.has(Access.countries),
Settings.has(Access.territories),
]) or getattr(settings, "DEBUG", False)
class ForbidLocationMiddleware:
"""Checks if the user location is forbidden."""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
city = dict()
address = request.META.get("REMOTE_ADDR")
address = request.META.get("HTTP_X_FORWARDED_FOR", address)
ip_address = address.split(",")[0].strip()

try:
city = Access.geoip.city(ip_address)

# Creates an instance of the Access class
# and checks if the IP address is granted.
action = Settings.get("OPTIONS.ACTION", "FORBID")
granted = 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.
granted = not any([
Settings.has(Access.countries),
Settings.has(Access.territories),
]) or getattr(settings, "DEBUG", False)
finally:
# Saves the timezone in the session for
# comparing it with the timezone in the
# POST request sent from user's browser
# to detect if the user is using VPN.
timezone = city.get("time_zone", "N/A")
request.session["tz"] = timezone

if granted:
return self.get_response(request)

# 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 740140f

Please sign in to comment.