Skip to content

Commit 740140f

Browse files
Redesign the ForbidMiddleware architecture (GH-17)
2 parents d10ec75 + 97ac16a commit 740140f

16 files changed

+582
-463
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# Django Forbid <img src="https://github.com/pysnippet.png" align="right" height="64" />
22

3-
Secure your Django app by controlling the access - grant or deny user access based on device and location, including VPN
4-
detection.
5-
63
[![PyPI](https://img.shields.io/pypi/v/django-forbid.svg)](https://pypi.org/project/django-forbid/)
74
[![Python](https://img.shields.io/pypi/pyversions/django-forbid.svg?logoColor=white)](https://pypi.org/project/django-forbid/)
85
[![Django](https://img.shields.io/pypi/djversions/django-forbid.svg?color=0C4B33&label=django)](https://pypi.org/project/django-forbid/)
96
[![License](https://img.shields.io/pypi/l/django-forbid.svg)](https://github.com/pysnippet/django-forbid/blob/master/LICENSE)
107
[![Tests](https://github.com/pysnippet/django-forbid/actions/workflows/tests.yml/badge.svg)](https://github.com/pysnippet/django-forbid/actions/workflows/tests.yml)
118

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

1415
```shell

src/django_forbid/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.6"
1+
__version__ = "0.0.7"

src/django_forbid/detect.py

Lines changed: 0 additions & 60 deletions
This file was deleted.

src/django_forbid/device.py

Lines changed: 0 additions & 57 deletions
This file was deleted.

src/django_forbid/middleware.py

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
from datetime import datetime
1+
from .skills.forbid_device import ForbidDeviceMiddleware
2+
from .skills.forbid_location import ForbidLocationMiddleware
3+
from .skills.forbid_network import ForbidNetworkMiddleware
24

3-
from django.http import HttpResponseForbidden
4-
from django.shortcuts import redirect
5-
from django.utils.timezone import utc
6-
7-
from .access import grants_access
8-
from .config import Settings
9-
from .detect import detect_vpn
10-
from .device import detect_device
11-
from .device import device_forbidden
5+
__skills__ = (
6+
ForbidDeviceMiddleware,
7+
ForbidLocationMiddleware,
8+
ForbidNetworkMiddleware,
9+
)
1210

1311

1412
class ForbidMiddleware:
@@ -18,35 +16,6 @@ def __init__(self, get_response):
1816
self.get_response = get_response
1917

2018
def __call__(self, request):
21-
address = request.META.get("REMOTE_ADDR")
22-
address = request.META.get("HTTP_X_FORWARDED_FOR", address)
23-
24-
# Detects the user's device and saves it in the session.
25-
if not request.session.get("DEVICE"):
26-
http_ua = request.META.get("HTTP_USER_AGENT")
27-
request.session["DEVICE"] = detect_device(http_ua)
28-
29-
if device_forbidden(request.session.get("DEVICE")):
30-
if Settings.has("OPTIONS.URL.FORBIDDEN_KIT"):
31-
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_KIT"))
32-
return HttpResponseForbidden()
33-
34-
# Checks if the PERIOD attr is set and the user has been granted access.
35-
if Settings.has("OPTIONS.PERIOD") and request.session.has_key("ACCESS"):
36-
acss = datetime.utcnow().replace(tzinfo=utc).timestamp()
37-
38-
# Checks if access is not timed out yet.
39-
if acss - request.session.get("ACCESS") < Settings.get("OPTIONS.PERIOD"):
40-
return detect_vpn(self.get_response, request)
41-
42-
# Checks if access is granted when timeout is reached.
43-
if grants_access(request, address.split(",")[0].strip()):
44-
acss = datetime.utcnow().replace(tzinfo=utc)
45-
request.session["ACCESS"] = acss.timestamp()
46-
return detect_vpn(self.get_response, request)
47-
48-
# Redirects to the FORBIDDEN_LOC URL if set.
49-
if Settings.has("OPTIONS.URL.FORBIDDEN_LOC"):
50-
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_LOC"))
51-
52-
return HttpResponseForbidden()
19+
for skill in __skills__:
20+
self.get_response = skill(self.get_response)
21+
return self.get_response(request)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import re
2+
3+
from device_detector import DeviceDetector
4+
from django.http import HttpResponseForbidden
5+
from django.shortcuts import redirect
6+
7+
from ..config import Settings
8+
9+
10+
def normalize(device_type):
11+
"""Removes the "!" prefix from the device type."""
12+
return device_type[1:]
13+
14+
15+
def forbidden(device_type):
16+
"""Checks if the device type is forbidden."""
17+
return device_type.startswith("!")
18+
19+
20+
def permitted(device_type):
21+
"""Checks if the device type is permitted."""
22+
return not forbidden(device_type)
23+
24+
25+
class ForbidDeviceMiddleware:
26+
"""Checks if the user device is forbidden."""
27+
28+
def __init__(self, get_response):
29+
self.get_response = get_response
30+
31+
def __call__(self, request):
32+
device_aliases = {
33+
"portable media player": "player",
34+
"smart display": "display",
35+
"smart speaker": "speaker",
36+
"feature phone": "phone",
37+
"car browser": "car",
38+
}
39+
40+
device_type = request.session.get("DEVICE")
41+
devices = Settings.get("DEVICES", [])
42+
43+
# Permit all devices if the
44+
# DEVICES setting is empty.
45+
if not devices:
46+
return self.get_response(request)
47+
48+
if not request.session.get("DEVICE"):
49+
http_ua = request.META.get("HTTP_USER_AGENT")
50+
device_detector = DeviceDetector(http_ua)
51+
device_detector = device_detector.parse()
52+
device = device_detector.device_type()
53+
device_type = device_aliases.get(device, device)
54+
request.session["DEVICE"] = device_type
55+
56+
# Creates a regular expression in the following form:
57+
# ^(?=PERMITTED_DEVICES)(?:(?!FORBIDDEN_DEVICES)\w)+$
58+
# where the list of forbidden and permitted devices are
59+
# filtered from the DEVICES setting by the "!" prefix.
60+
permit = r"|".join(filter(permitted, devices))
61+
forbid = r"|".join(map(normalize, filter(forbidden, devices)))
62+
forbid = r"(?!" + forbid + r")" if forbid else ""
63+
regexp = r"^(?=" + permit + r")(?:" + forbid + r"\w)+$"
64+
65+
# Regexp designed to match the permitted devices.
66+
if re.match(regexp, device_type):
67+
return self.get_response(request)
68+
69+
# Redirects to the FORBIDDEN_KIT URL if set.
70+
if Settings.has("OPTIONS.URL.FORBIDDEN_KIT"):
71+
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_KIT"))
72+
73+
return HttpResponseForbidden()

src/django_forbid/access.py renamed to src/django_forbid/skills/forbid_location.py

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from django.conf import settings
22
from django.contrib.gis.geoip2 import GeoIP2
3+
from django.http import HttpResponseForbidden
4+
from django.shortcuts import redirect
35
from geoip2.errors import AddressNotFoundError
46

5-
from .config import Settings
7+
from ..config import Settings
68

79

810
class Rule:
@@ -77,27 +79,46 @@ def create_access(cls, action):
7779
return getattr(cls, action)()
7880

7981

80-
def grants_access(request, ip_address):
81-
"""Checks if the IP address is in the white zone."""
82-
try:
83-
city = Access.geoip.city(ip_address)
84-
85-
# Saves the timezone in the session for
86-
# comparing it with the timezone in the
87-
# POST request sent from user's browser
88-
# to detect if the user is using VPN.
89-
timezone = city.get("time_zone")
90-
request.session["tz"] = timezone
91-
92-
# Creates an instance of the Access class
93-
# and checks if the IP address is granted.
94-
action = Settings.get("OPTIONS.ACTION", "FORBID")
95-
return Factory.create_access(action).grants(city)
96-
except (AddressNotFoundError, Exception):
97-
# This happens when the IP address is not
98-
# in the GeoIP2 database. Usually, this
99-
# happens when the IP address is a local.
100-
return not any([
101-
Settings.has(Access.countries),
102-
Settings.has(Access.territories),
103-
]) or getattr(settings, "DEBUG", False)
82+
class ForbidLocationMiddleware:
83+
"""Checks if the user location is forbidden."""
84+
85+
def __init__(self, get_response):
86+
self.get_response = get_response
87+
88+
def __call__(self, request):
89+
city = dict()
90+
address = request.META.get("REMOTE_ADDR")
91+
address = request.META.get("HTTP_X_FORWARDED_FOR", address)
92+
ip_address = address.split(",")[0].strip()
93+
94+
try:
95+
city = Access.geoip.city(ip_address)
96+
97+
# Creates an instance of the Access class
98+
# and checks if the IP address is granted.
99+
action = Settings.get("OPTIONS.ACTION", "FORBID")
100+
granted = Factory.create_access(action).grants(city)
101+
except (AddressNotFoundError, Exception):
102+
# This happens when the IP address is not
103+
# in the GeoIP2 database. Usually, this
104+
# happens when the IP address is a local.
105+
granted = not any([
106+
Settings.has(Access.countries),
107+
Settings.has(Access.territories),
108+
]) or getattr(settings, "DEBUG", False)
109+
finally:
110+
# Saves the timezone in the session for
111+
# comparing it with the timezone in the
112+
# POST request sent from user's browser
113+
# to detect if the user is using VPN.
114+
timezone = city.get("time_zone", "N/A")
115+
request.session["tz"] = timezone
116+
117+
if granted:
118+
return self.get_response(request)
119+
120+
# Redirects to the FORBIDDEN_LOC URL if set.
121+
if Settings.has("OPTIONS.URL.FORBIDDEN_LOC"):
122+
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_LOC"))
123+
124+
return HttpResponseForbidden()

0 commit comments

Comments
 (0)