Skip to content

Commit 139ea93

Browse files
Implement VPN detection (GH-11)
2 parents f02e844 + fec7be2 commit 139ea93

File tree

9 files changed

+284
-36
lines changed

9 files changed

+284
-36
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ The available ISO 3166 alpha-2 country codes are listed in [here](https://www.ib
7171
ISO continent codes are: `AF` - Africa, `AN` - Antarctica, `AS` - Asia, `EU` - Europe, `NA` - North America, `OC` -
7272
Oceania and `SA` - South America.
7373

74+
### Check access on timeout
75+
7476
Without additional configuration, the middleware will check the user's access on every request. This can slow down the
7577
site. To avoid this, you can use the `FORBID_TIMEOUT` variable to set the cache timeout in seconds. When the timeout
7678
expires, the middleware will check the user's access again.
@@ -80,6 +82,12 @@ expires, the middleware will check the user's access again.
8082
FORBID_TIMEOUT = 60 * 10
8183
```
8284

85+
### Detect usage of a VPN
86+
87+
If you want to detect the usage of a VPN, you can use the `FORBID_VPN` variable. When this variable is set to `True`,
88+
the middleware will check if the user's timezone matches the timezone the IP address belongs to. If the timezones do not
89+
match, the user will be considered in the usage of a VPN and forbidden to access the site.
90+
8391
## Contribute
8492

8593
Any contribution is welcome. If you have any ideas or suggestions, feel free to open an issue or a pull request. And

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.3"
1+
__version__ = "0.0.4"

src/django_forbid/access.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,38 +43,57 @@ def __init__(self):
4343
for territory in getattr(settings, self.territories, []):
4444
self.rules.append(ContinentRule(territory.upper()))
4545

46-
def grants(self, ip_address):
46+
def accessible(self, city):
4747
"""Checks if the IP address is in the white zone."""
48-
city = self.geoip.city(ip_address)
4948
return any(map(lambda rule: rule(city), self.rules))
5049

50+
def grants(self, city):
51+
"""Checks if the IP address is permitted."""
52+
raise NotImplementedError
53+
5154

5255
class PermitAccess(Access):
5356
countries = "WHITELIST_COUNTRIES"
5457
territories = "WHITELIST_TERRITORIES"
5558

56-
def grants(self, ip_address):
59+
def grants(self, city):
5760
"""Checks if the IP address is permitted."""
58-
try:
59-
return not self.rules or super().grants(ip_address)
60-
except AddressNotFoundError:
61-
return getattr(settings, "DEBUG", False)
61+
return not self.rules or self.accessible(city)
6262

6363

6464
class ForbidAccess(Access):
6565
countries = "FORBIDDEN_COUNTRIES"
6666
territories = "FORBIDDEN_TERRITORIES"
6767

68-
def grants(self, ip_address):
68+
def grants(self, city):
6969
"""Checks if the IP address is forbidden."""
70-
try:
71-
return not self.rules or not super().grants(ip_address)
72-
except AddressNotFoundError:
73-
return getattr(settings, "DEBUG", False)
70+
return not self.rules or not self.accessible(city)
7471

7572

76-
def grants_access(ip_address):
73+
def grants_access(request, ip_address):
7774
"""Checks if the IP address is in the white zone."""
78-
if ForbidAccess().grants(ip_address):
79-
return PermitAccess().grants(ip_address)
80-
return False
75+
try:
76+
city = Access.geoip.city(ip_address)
77+
78+
# Saves the timezone in the session for
79+
# comparing it with the timezone in the
80+
# POST request sent from user's browser
81+
# to detect if the user is using VPN.
82+
timezone = city.get("time_zone")
83+
request.session["tz"] = timezone
84+
85+
# First, checks if the IP address is not
86+
# forbidden. If it is, False is returned
87+
# otherwise, checks if the IP address is
88+
# permitted.
89+
if ForbidAccess().grants(city):
90+
return PermitAccess().grants(city)
91+
return False
92+
except (AddressNotFoundError, Exception):
93+
# This happens when the IP address is not
94+
# in the GeoIP2 database. Usually, this
95+
# happens when the IP address is a local.
96+
return not any([
97+
ForbidAccess().rules,
98+
PermitAccess().rules,
99+
]) or getattr(settings, "DEBUG", False)

src/django_forbid/detect.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import json
2+
import re
3+
4+
from django.conf import settings
5+
from django.http import HttpResponse
6+
from django.http import HttpResponseForbidden
7+
from django.shortcuts import redirect
8+
from django.shortcuts import render
9+
10+
11+
def detect_vpn(get_response, request):
12+
response_attributes = ("content", "charset", "status", "reason")
13+
14+
def erase_response_attributes():
15+
for attr in response_attributes:
16+
request.session.pop(attr)
17+
18+
if any([
19+
# The session key is checked to avoid
20+
# redirect loops in development mode.
21+
not request.session.has_key("tz"),
22+
# Checks if FORBID_VPN is False or not set.
23+
not getattr(settings, "FORBID_VPN", False),
24+
# Checks if the request is an AJAX request.
25+
not re.search(
26+
r"\w+\/(?:html|xhtml\+xml|xml)",
27+
request.META.get("HTTP_ACCEPT"),
28+
),
29+
]):
30+
return get_response(request)
31+
32+
if all(map(request.session.has_key, ("tz", *response_attributes))):
33+
# Handles if the user's timezone differs from the
34+
# one determined by GeoIP API. If so, VPN is used.
35+
if request.POST.get("timezone", "N/A") != request.session.get("tz"):
36+
erase_response_attributes()
37+
if hasattr(settings, "FORBIDDEN_URL"):
38+
return redirect(settings.FORBIDDEN_URL)
39+
return HttpResponseForbidden()
40+
41+
# Restores the response from the session.
42+
response = HttpResponse(**{attr: request.session.get(attr) for attr in response_attributes})
43+
if hasattr(response, "headers"):
44+
response.headers = json.loads(request.session.get("headers"))
45+
erase_response_attributes()
46+
return response
47+
48+
# Gets the response and saves attributes in the session to restore it later.
49+
response = get_response(request)
50+
if hasattr(response, "headers"):
51+
# In older versions of Django, HttpResponse does not have headers attribute.
52+
request.session["headers"] = json.dumps(dict(response.headers))
53+
request.session["content"] = response.content.decode(response.charset)
54+
request.session["charset"] = response.charset
55+
request.session["status"] = response.status_code
56+
request.session["reason"] = response.reason_phrase
57+
58+
return render(request, "timezone.html", status=302)

src/django_forbid/middleware.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.utils.timezone import utc
77

88
from .access import grants_access
9+
from .detect import detect_vpn
910

1011

1112
class ForbidMiddleware:
@@ -24,13 +25,13 @@ def __call__(self, request):
2425

2526
# Checks if access is not timed out yet.
2627
if acss - request.session.get("ACCESS") < settings.FORBID_TIMEOUT:
27-
return self.get_response(request)
28+
return detect_vpn(self.get_response, request)
2829

2930
# Checks if access is granted when timeout is reached.
30-
if grants_access(address.split(",")[0].strip()):
31+
if grants_access(request, address.split(",")[0].strip()):
3132
acss = datetime.utcnow().replace(tzinfo=utc)
3233
request.session["ACCESS"] = acss.timestamp()
33-
return self.get_response(request)
34+
return detect_vpn(self.get_response, request)
3435

3536
# Redirects to forbidden page if URL is set.
3637
if hasattr(settings, "FORBIDDEN_URL"):
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<head>
2+
<link rel="icon" href="data:image/x-icon;base64,AAABAAEAAQEAAAEAIAAwAAAAF
3+
gAAACgAAAABAAAAAgAAAAEAIAAAAAAABAAAAMMOAADDDgAAAAAAAAAAAAD/////AAAAAA==">
4+
</head>
5+
<form id="detector" method="post">
6+
{% csrf_token %}
7+
</form>
8+
<script type="text/javascript">
9+
(() => {
10+
const form = document.getElementById("detector");
11+
const input = document.createElement("input");
12+
input.setAttribute("type", "hidden");
13+
input.setAttribute("name", "timezone");
14+
input.setAttribute("value", Intl.DateTimeFormat().resolvedOptions().timeZone);
15+
form.action = window.location.href;
16+
form.appendChild(input);
17+
form.submit();
18+
})();
19+
</script>

tests/conftest.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
from django.conf import settings
21
from pathlib import Path
32

3+
import pytest
4+
from django.conf import settings
5+
from django.http import HttpResponse
6+
47

58
def pytest_configure():
69
settings.configure(
@@ -10,6 +13,17 @@ def pytest_configure():
1013
MIDDLEWARE=[
1114
"django_forbid.middleware.ForbidMiddleware"
1215
],
16+
TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True}],
1317
# The `pathlib.Path` support was added after Django 3.0.
1418
GEOIP_PATH=(Path(__file__).parent / "geoip").as_posix(),
1519
)
20+
21+
22+
@pytest.fixture
23+
def get_response():
24+
"""A dummy view function."""
25+
26+
def get_response_mock(_):
27+
return HttpResponse()
28+
29+
return get_response_mock

tests/test_grants_access.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,100 @@
1+
from django.test import RequestFactory
12
from django.test import override_settings
23

34
from django_forbid.access import grants_access
45

6+
factory = RequestFactory()
7+
request = factory.get("/")
8+
request.session = {}
9+
10+
LOCALHOST = "localhost"
11+
IP_LOCAL1 = "0.0.0.0"
12+
IP_LOCAL2 = "127.0.0.1"
13+
IP_LONDON = "212.102.63.59"
14+
515

616
def test_access_without_configuration():
717
"""If no configuration is provided, access is granted everywhere."""
8-
assert grants_access("doesnt-matter")
18+
assert grants_access(request, LOCALHOST)
19+
20+
21+
@override_settings(FORBID_VPN=True)
22+
def test_access_forbid_vpn():
23+
"""If VPN detection is enabled, access is granted everywhere."""
24+
assert grants_access(request, LOCALHOST)
925

1026

1127
@override_settings(WHITELIST_COUNTRIES=["US"], DEBUG=True)
1228
def test_access_from_localhost_development_mode():
1329
"""In development mode, access is granted from localhost."""
14-
assert grants_access("127.0.0.1")
15-
assert grants_access("localhost")
30+
assert grants_access(request, IP_LOCAL1)
31+
assert grants_access(request, IP_LOCAL2)
32+
assert grants_access(request, LOCALHOST)
1633

1734

1835
@override_settings(WHITELIST_COUNTRIES=["US"])
1936
def test_access_from_localhost_production_mode():
2037
"""In production mode, access is not granted from localhost."""
21-
assert not grants_access("127.0.0.1")
22-
assert not grants_access("localhost")
38+
assert not grants_access(request, IP_LOCAL1)
39+
assert not grants_access(request, IP_LOCAL2)
40+
assert not grants_access(request, LOCALHOST)
2341

2442

2543
@override_settings(WHITELIST_COUNTRIES=["GB"])
2644
def test_access_from_gb_when_gb_in_countries_whitelist():
2745
"""Access is granted from GB when GB is in the counties' whitelist."""
28-
assert grants_access("212.102.63.59")
46+
assert grants_access(request, IP_LONDON)
2947

3048

3149
@override_settings(WHITELIST_COUNTRIES=["US"])
3250
def test_access_from_gb_when_gb_not_in_countries_whitelist():
3351
"""Access is not granted from GB when GB is not in the counties' whitelist."""
34-
assert not grants_access("212.102.63.59")
52+
assert not grants_access(request, IP_LONDON)
3553

3654

3755
@override_settings(WHITELIST_TERRITORIES=["EU"])
3856
def test_access_from_gb_when_eu_in_continent_whitelist():
3957
"""Access is granted from GB when EU is in the continents' whitelist."""
40-
assert grants_access("212.102.63.59")
58+
assert grants_access(request, IP_LONDON)
4159

4260

4361
@override_settings(WHITELIST_TERRITORIES=["US"])
4462
def test_access_from_gb_when_gb_not_in_continent_whitelist():
4563
"""Access is not granted from GB when EU is not in the continents' whitelist."""
46-
assert not grants_access("212.102.63.59")
64+
assert not grants_access(request, IP_LONDON)
4765

4866

4967
@override_settings(FORBIDDEN_COUNTRIES=["GB"])
5068
def test_access_from_gb_when_gb_in_forbidden_countries():
5169
"""Access is not granted from GB when GB is in the forbidden list."""
52-
assert not grants_access("212.102.63.59")
70+
assert not grants_access(request, IP_LONDON)
5371

5472

5573
@override_settings(FORBIDDEN_COUNTRIES=["RU"])
5674
def test_access_from_gb_when_gb_not_in_forbidden_countries():
5775
"""Access is granted from GB when GB is not in the forbidden list."""
58-
assert grants_access("212.102.63.59")
76+
assert grants_access(request, IP_LONDON)
5977

6078

6179
@override_settings(FORBIDDEN_TERRITORIES=["EU"])
6280
def test_access_from_gb_when_eu_in_forbidden_territories():
6381
"""Access is not granted from GB when EU is in the forbidden list."""
64-
assert not grants_access("212.102.63.59")
82+
assert not grants_access(request, IP_LONDON)
6583

6684

6785
@override_settings(FORBIDDEN_TERRITORIES=["AS"])
6886
def test_access_from_gb_when_eu_not_in_forbidden_territories():
6987
"""Access is granted from GB when EU is not in the forbidden list."""
70-
assert grants_access("212.102.63.59")
88+
assert grants_access(request, IP_LONDON)
7189

7290

7391
@override_settings(WHITELIST_TERRITORIES=["EU"], FORBIDDEN_COUNTRIES=["GB"])
7492
def test_mix_config_access_from_gb_when_eu_in_whitelist_but_gb_is_forbidden():
7593
"""Access is not granted from GB when EU is in the whitelist but GB is forbidden."""
76-
assert not grants_access("212.102.63.59")
94+
assert not grants_access(request, IP_LONDON)
7795

7896

7997
@override_settings(WHITELIST_COUNTRIES=["GB"], FORBIDDEN_COUNTRIES=["GB"])
8098
def test_mix_config_access_from_gb_when_gb_in_both_lists():
8199
"""Access is not granted from GB when GB is in both lists."""
82-
assert not grants_access("212.102.63.59")
100+
assert not grants_access(request, IP_LONDON)

0 commit comments

Comments
 (0)