From 90be795cf2cad10d63c856dafa2e625e8b57e8a8 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 3 Apr 2023 21:46:08 +0400 Subject: [PATCH 01/23] GH-10: Implement the detector for request --- src/django_forbid/access.py | 10 ++++--- src/django_forbid/detect.py | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 src/django_forbid/detect.py diff --git a/src/django_forbid/access.py b/src/django_forbid/access.py index df3e5a3..4c51075 100644 --- a/src/django_forbid/access.py +++ b/src/django_forbid/access.py @@ -34,8 +34,9 @@ class Access: # Hold the instance of GeoIP2. geoip = GeoIP2() - def __init__(self): + def __init__(self, request): self.rules = [] + self.request = request for country in getattr(settings, self.countries, []): self.rules.append(CountryRule(country.upper())) @@ -46,6 +47,7 @@ def __init__(self): def grants(self, ip_address): """Checks if the IP address is in the white zone.""" city = self.geoip.city(ip_address) + self.request.session["tz"] = city.get("time_zone") return any(map(lambda rule: rule(city), self.rules)) @@ -73,8 +75,8 @@ def grants(self, ip_address): return getattr(settings, "DEBUG", False) -def grants_access(ip_address): +def grants_access(request, ip_address): """Checks if the IP address is in the white zone.""" - if ForbidAccess().grants(ip_address): - return PermitAccess().grants(ip_address) + if ForbidAccess(request).grants(ip_address): + return PermitAccess(request).grants(ip_address) return False diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py new file mode 100644 index 0000000..fba70bd --- /dev/null +++ b/src/django_forbid/detect.py @@ -0,0 +1,52 @@ +import json + +from django.conf import settings +from django.http import HttpResponse +from django.shortcuts import redirect +from django.shortcuts import render + + +def detect(get_response, request): + response = get_response(request) + + # Checks if VPN detection is disabled. + if not getattr(settings, "FORBID_VPN", False): + return response + + # Usually, this happens when CSRF is invalid. + if response.status_code == 403: + if hasattr(settings, "FORBIDDEN_URL"): + return redirect(settings.FORBIDDEN_URL) + return response + + if all(map(request.session.has_key, ( + "tz", + "content", + "charset", + "headers", + "status_code", + "reason_phrase", + ))) and request.POST.get("timezone", "N/A") == request.session.get("tz"): + # Restores the response from the session. + response = HttpResponse( + content=request.session.get("content"), + charset=request.session.get("charset"), + status=request.session.get("status_code"), + reason=request.session.get("reason_phrase"), + headers=json.loads(request.session.get("headers")), + ) + request.session.pop("content") + request.session.pop("charset") + request.session.pop("headers") + request.session.pop("status_code") + request.session.pop("reason_phrase") + return response + + # Saves the response attributes in the session to restore it later. + request.session["content"] = response.content.decode(response.charset) + request.session["headers"] = json.dumps(dict(response.headers)) + request.session["charset"] = response.charset + request.session["status_code"] = response.status_code + request.session["reason_phrase"] = response.reason_phrase + + return render(request, "timezone.html") From 425e41f2eef76f821ca1e207cee1adad4ae1019d Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 3 Apr 2023 21:47:49 +0400 Subject: [PATCH 02/23] GH-10: Implement CSRF protected JS based timezone handler --- src/django_forbid/templates/timezone.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/django_forbid/templates/timezone.html diff --git a/src/django_forbid/templates/timezone.html b/src/django_forbid/templates/timezone.html new file mode 100644 index 0000000..8f7f877 --- /dev/null +++ b/src/django_forbid/templates/timezone.html @@ -0,0 +1,15 @@ +
+ {% csrf_token %} +
+ \ No newline at end of file From 65bc5b43ad89396ccad1b7b70e6dcd7761f0cde2 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Mon, 3 Apr 2023 21:48:11 +0400 Subject: [PATCH 03/23] GH-10: Connect detector to the middleware --- src/django_forbid/middleware.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/django_forbid/middleware.py b/src/django_forbid/middleware.py index f35ba6b..55a6424 100644 --- a/src/django_forbid/middleware.py +++ b/src/django_forbid/middleware.py @@ -6,6 +6,7 @@ from django.utils.timezone import utc from .access import grants_access +from .detect import detect class ForbidMiddleware: @@ -24,13 +25,13 @@ def __call__(self, request): # Checks if access is not timed out yet. if acss - request.session.get("ACCESS") < settings.FORBID_TIMEOUT: - return self.get_response(request) + return detect(self.get_response, request) # Checks if access is granted when timeout is reached. - if grants_access(address.split(",")[0].strip()): + if grants_access(request, address.split(",")[0].strip()): acss = datetime.utcnow().replace(tzinfo=utc) request.session["ACCESS"] = acss.timestamp() - return self.get_response(request) + return detect(self.get_response, request) # Redirects to forbidden page if URL is set. if hasattr(settings, "FORBIDDEN_URL"): From a821e5bb10501c5fc476688a2d6943ea525a20b4 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan <44609997+ArtyomVancyan@users.noreply.github.com> Date: Tue, 4 Apr 2023 17:18:43 +0400 Subject: [PATCH 04/23] Upgrade the version to `0.0.4` --- src/django_forbid/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/django_forbid/__init__.py b/src/django_forbid/__init__.py index 27fdca4..81f0fde 100644 --- a/src/django_forbid/__init__.py +++ b/src/django_forbid/__init__.py @@ -1 +1 @@ -__version__ = "0.0.3" +__version__ = "0.0.4" From e229500b24f614181efaf0aa890b8e526f154741 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan <44609997+ArtyomVancyan@users.noreply.github.com> Date: Wed, 5 Apr 2023 15:25:01 +0400 Subject: [PATCH 05/23] GH-10: Provide request object to the function --- tests/test_grants_access.py | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/test_grants_access.py b/tests/test_grants_access.py index 667ec12..0f55300 100644 --- a/tests/test_grants_access.py +++ b/tests/test_grants_access.py @@ -3,80 +3,80 @@ from django_forbid.access import grants_access -def test_access_without_configuration(): +def test_access_without_configuration(request): """If no configuration is provided, access is granted everywhere.""" - assert grants_access("doesnt-matter") + assert grants_access(request, "doesnt-matter") @override_settings(WHITELIST_COUNTRIES=["US"], DEBUG=True) -def test_access_from_localhost_development_mode(): +def test_access_from_localhost_development_mode(request): """In development mode, access is granted from localhost.""" - assert grants_access("127.0.0.1") - assert grants_access("localhost") + assert grants_access(request, "127.0.0.1") + assert grants_access(request, "localhost") @override_settings(WHITELIST_COUNTRIES=["US"]) -def test_access_from_localhost_production_mode(): +def test_access_from_localhost_production_mode(request): """In production mode, access is not granted from localhost.""" - assert not grants_access("127.0.0.1") - assert not grants_access("localhost") + assert not grants_access(request, "127.0.0.1") + assert not grants_access(request, "localhost") @override_settings(WHITELIST_COUNTRIES=["GB"]) -def test_access_from_gb_when_gb_in_countries_whitelist(): +def test_access_from_gb_when_gb_in_countries_whitelist(request): """Access is granted from GB when GB is in the counties' whitelist.""" - assert grants_access("212.102.63.59") + assert grants_access(request, "212.102.63.59") @override_settings(WHITELIST_COUNTRIES=["US"]) -def test_access_from_gb_when_gb_not_in_countries_whitelist(): +def test_access_from_gb_when_gb_not_in_countries_whitelist(request): """Access is not granted from GB when GB is not in the counties' whitelist.""" - assert not grants_access("212.102.63.59") + assert not grants_access(request, "212.102.63.59") @override_settings(WHITELIST_TERRITORIES=["EU"]) -def test_access_from_gb_when_eu_in_continent_whitelist(): +def test_access_from_gb_when_eu_in_continent_whitelist(request): """Access is granted from GB when EU is in the continents' whitelist.""" - assert grants_access("212.102.63.59") + assert grants_access(request, "212.102.63.59") @override_settings(WHITELIST_TERRITORIES=["US"]) -def test_access_from_gb_when_gb_not_in_continent_whitelist(): +def test_access_from_gb_when_gb_not_in_continent_whitelist(request): """Access is not granted from GB when EU is not in the continents' whitelist.""" - assert not grants_access("212.102.63.59") + assert not grants_access(request, "212.102.63.59") @override_settings(FORBIDDEN_COUNTRIES=["GB"]) -def test_access_from_gb_when_gb_in_forbidden_countries(): +def test_access_from_gb_when_gb_in_forbidden_countries(request): """Access is not granted from GB when GB is in the forbidden list.""" - assert not grants_access("212.102.63.59") + assert not grants_access(request, "212.102.63.59") @override_settings(FORBIDDEN_COUNTRIES=["RU"]) -def test_access_from_gb_when_gb_not_in_forbidden_countries(): +def test_access_from_gb_when_gb_not_in_forbidden_countries(request): """Access is granted from GB when GB is not in the forbidden list.""" - assert grants_access("212.102.63.59") + assert grants_access(request, "212.102.63.59") @override_settings(FORBIDDEN_TERRITORIES=["EU"]) -def test_access_from_gb_when_eu_in_forbidden_territories(): +def test_access_from_gb_when_eu_in_forbidden_territories(request): """Access is not granted from GB when EU is in the forbidden list.""" - assert not grants_access("212.102.63.59") + assert not grants_access(request, "212.102.63.59") @override_settings(FORBIDDEN_TERRITORIES=["AS"]) -def test_access_from_gb_when_eu_not_in_forbidden_territories(): +def test_access_from_gb_when_eu_not_in_forbidden_territories(request): """Access is granted from GB when EU is not in the forbidden list.""" - assert grants_access("212.102.63.59") + assert grants_access(request, "212.102.63.59") @override_settings(WHITELIST_TERRITORIES=["EU"], FORBIDDEN_COUNTRIES=["GB"]) -def test_mix_config_access_from_gb_when_eu_in_whitelist_but_gb_is_forbidden(): +def test_mix_config_access_from_gb_when_eu_in_whitelist_but_gb_is_forbidden(request): """Access is not granted from GB when EU is in the whitelist but GB is forbidden.""" - assert not grants_access("212.102.63.59") + assert not grants_access(request, "212.102.63.59") @override_settings(WHITELIST_COUNTRIES=["GB"], FORBIDDEN_COUNTRIES=["GB"]) -def test_mix_config_access_from_gb_when_gb_in_both_lists(): +def test_mix_config_access_from_gb_when_gb_in_both_lists(request): """Access is not granted from GB when GB is in both lists.""" - assert not grants_access("212.102.63.59") + assert not grants_access(request, "212.102.63.59") From a4a37b7c711f3370674b729190a43341b9b17f1b Mon Sep 17 00:00:00 2001 From: Artyom Vancyan <44609997+ArtyomVancyan@users.noreply.github.com> Date: Thu, 6 Apr 2023 23:16:03 +0400 Subject: [PATCH 06/23] GH-10: Mock request with session storage --- tests/test_grants_access.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/test_grants_access.py b/tests/test_grants_access.py index 0f55300..d03bd01 100644 --- a/tests/test_grants_access.py +++ b/tests/test_grants_access.py @@ -1,82 +1,87 @@ +from django.test import RequestFactory from django.test import override_settings from django_forbid.access import grants_access +factory = RequestFactory() +request = factory.get("/") +request.session = {} -def test_access_without_configuration(request): + +def test_access_without_configuration(): """If no configuration is provided, access is granted everywhere.""" assert grants_access(request, "doesnt-matter") @override_settings(WHITELIST_COUNTRIES=["US"], DEBUG=True) -def test_access_from_localhost_development_mode(request): +def test_access_from_localhost_development_mode(): """In development mode, access is granted from localhost.""" assert grants_access(request, "127.0.0.1") assert grants_access(request, "localhost") @override_settings(WHITELIST_COUNTRIES=["US"]) -def test_access_from_localhost_production_mode(request): +def test_access_from_localhost_production_mode(): """In production mode, access is not granted from localhost.""" assert not grants_access(request, "127.0.0.1") assert not grants_access(request, "localhost") @override_settings(WHITELIST_COUNTRIES=["GB"]) -def test_access_from_gb_when_gb_in_countries_whitelist(request): +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, "212.102.63.59") @override_settings(WHITELIST_COUNTRIES=["US"]) -def test_access_from_gb_when_gb_not_in_countries_whitelist(request): +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, "212.102.63.59") @override_settings(WHITELIST_TERRITORIES=["EU"]) -def test_access_from_gb_when_eu_in_continent_whitelist(request): +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, "212.102.63.59") @override_settings(WHITELIST_TERRITORIES=["US"]) -def test_access_from_gb_when_gb_not_in_continent_whitelist(request): +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, "212.102.63.59") @override_settings(FORBIDDEN_COUNTRIES=["GB"]) -def test_access_from_gb_when_gb_in_forbidden_countries(request): +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, "212.102.63.59") @override_settings(FORBIDDEN_COUNTRIES=["RU"]) -def test_access_from_gb_when_gb_not_in_forbidden_countries(request): +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, "212.102.63.59") @override_settings(FORBIDDEN_TERRITORIES=["EU"]) -def test_access_from_gb_when_eu_in_forbidden_territories(request): +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, "212.102.63.59") @override_settings(FORBIDDEN_TERRITORIES=["AS"]) -def test_access_from_gb_when_eu_not_in_forbidden_territories(request): +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, "212.102.63.59") @override_settings(WHITELIST_TERRITORIES=["EU"], FORBIDDEN_COUNTRIES=["GB"]) -def test_mix_config_access_from_gb_when_eu_in_whitelist_but_gb_is_forbidden(request): +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, "212.102.63.59") @override_settings(WHITELIST_COUNTRIES=["GB"], FORBIDDEN_COUNTRIES=["GB"]) -def test_mix_config_access_from_gb_when_gb_in_both_lists(request): +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, "212.102.63.59") From 5505df3f0a14552c0ce29080b10c7d642f2a465d Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 7 Apr 2023 22:00:14 +0400 Subject: [PATCH 07/23] GH-10: Fix infinite redirecting issue for development mode --- src/django_forbid/detect.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index fba70bd..2b33408 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -9,8 +9,14 @@ def detect(get_response, request): response = get_response(request) - # Checks if VPN detection is disabled. - if not getattr(settings, "FORBID_VPN", False): + # Checks if VPN detection is disabled + # or if the `tz` session is not set. + if any([ + # The session key is checked to avoid + # redirect loops in development mode. + not request.session.has_key("tz"), + not getattr(settings, "FORBID_VPN", False), + ]): return response # Usually, this happens when CSRF is invalid. From e14ddbb6abaef0b2fc582ebfaf0080b65cdff46a Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Fri, 7 Apr 2023 22:10:27 +0400 Subject: [PATCH 08/23] GH-10: Fix the setting of the timezone session --- src/django_forbid/access.py | 50 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/django_forbid/access.py b/src/django_forbid/access.py index 4c51075..095d159 100644 --- a/src/django_forbid/access.py +++ b/src/django_forbid/access.py @@ -34,9 +34,8 @@ class Access: # Hold the instance of GeoIP2. geoip = GeoIP2() - def __init__(self, request): + def __init__(self): self.rules = [] - self.request = request for country in getattr(settings, self.countries, []): self.rules.append(CountryRule(country.upper())) @@ -44,39 +43,54 @@ def __init__(self, request): for territory in getattr(settings, self.territories, []): self.rules.append(ContinentRule(territory.upper())) - def grants(self, ip_address): + def accessable(self, city): """Checks if the IP address is in the white zone.""" - city = self.geoip.city(ip_address) - self.request.session["tz"] = city.get("time_zone") return any(map(lambda rule: rule(city), self.rules)) + def grants(self, city): + """Checks if the IP address is permitted.""" + raise NotImplementedError + class PermitAccess(Access): countries = "WHITELIST_COUNTRIES" territories = "WHITELIST_TERRITORIES" - def grants(self, ip_address): + def grants(self, city): """Checks if the IP address is permitted.""" - try: - return not self.rules or super().grants(ip_address) - except AddressNotFoundError: - return getattr(settings, "DEBUG", False) + return not self.rules or self.accessable(city) class ForbidAccess(Access): countries = "FORBIDDEN_COUNTRIES" territories = "FORBIDDEN_TERRITORIES" - def grants(self, ip_address): + def grants(self, city): """Checks if the IP address is forbidden.""" - try: - return not self.rules or not super().grants(ip_address) - except AddressNotFoundError: - return getattr(settings, "DEBUG", False) + return not self.rules or not self.accessable(city) def grants_access(request, ip_address): """Checks if the IP address is in the white zone.""" - if ForbidAccess(request).grants(ip_address): - return PermitAccess(request).grants(ip_address) - return False + 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 + + # 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 + except AddressNotFoundError: + # This happens when the IP address is not + # in the GeoIP2 database. Usually, this + # happens when the IP address is a local. + return getattr(settings, "DEBUG", False) From 7f7d3c018808e8076482835f44c41007ce1044c9 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 8 Apr 2023 18:52:19 +0400 Subject: [PATCH 09/23] GH-10: Fix the basic workflow to satisfy the tests --- src/django_forbid/access.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/django_forbid/access.py b/src/django_forbid/access.py index 095d159..e3cd519 100644 --- a/src/django_forbid/access.py +++ b/src/django_forbid/access.py @@ -89,8 +89,11 @@ def grants_access(request, ip_address): if ForbidAccess().grants(city): return PermitAccess().grants(city) return False - except AddressNotFoundError: + 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 getattr(settings, "DEBUG", False) + return not any([ + ForbidAccess().rules, + PermitAccess().rules, + ]) or getattr(settings, "DEBUG", False) From 32bf5e2b852b5393e9d642b2799b9d00efdb4abb Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 8 Apr 2023 18:57:29 +0400 Subject: [PATCH 10/23] GH-10: Set redirect status on the template --- src/django_forbid/detect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 2b33408..1befc3c 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -55,4 +55,4 @@ def detect(get_response, request): request.session["status_code"] = response.status_code request.session["reason_phrase"] = response.reason_phrase - return render(request, "timezone.html") + return render(request, "timezone.html", status=302) From 0b14ebeb114d678cf8df474431d61baa1bf8be10 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 8 Apr 2023 18:57:55 +0400 Subject: [PATCH 11/23] GH-10: Add check for AJAX requests --- src/django_forbid/detect.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 1befc3c..48ab2a8 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -1,4 +1,5 @@ import json +import re from django.conf import settings from django.http import HttpResponse @@ -15,7 +16,13 @@ def detect(get_response, request): # 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 the request is an AJAX request. + not re.search( + r"\w+\/(?:html|xhtml\+xml|xml)", + request.META.get("HTTP_ACCEPT"), + ), ]): return response From 8d8c2506d1f3e571cd2dde5d391431e0a0c63932 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 8 Apr 2023 19:39:25 +0400 Subject: [PATCH 12/23] GH-10: Fix 'Method Not Allowed' and favicon 404 issues --- src/django_forbid/detect.py | 56 ++++++++++------------- src/django_forbid/templates/timezone.html | 4 ++ 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 48ab2a8..7511292 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -8,10 +8,6 @@ def detect(get_response, request): - response = get_response(request) - - # Checks if VPN detection is disabled - # or if the `tz` session is not set. if any([ # The session key is checked to avoid # redirect loops in development mode. @@ -24,42 +20,38 @@ def detect(get_response, request): request.META.get("HTTP_ACCEPT"), ), ]): - return response - - # Usually, this happens when CSRF is invalid. - if response.status_code == 403: - if hasattr(settings, "FORBIDDEN_URL"): - return redirect(settings.FORBIDDEN_URL) - return response - - if all(map(request.session.has_key, ( - "tz", - "content", - "charset", - "headers", - "status_code", - "reason_phrase", - ))) and request.POST.get("timezone", "N/A") == request.session.get("tz"): + return get_response(request) + + # Ensures the request does not come + # from the `timezone.html` template. + if not request.POST.get("timezone"): + response = get_response(request) + + # Usually, this happens when CSRF is invalid. + if response.status_code == 403: + if hasattr(settings, "FORBIDDEN_URL"): + return redirect(settings.FORBIDDEN_URL) + return response + + response_attributes = ("content", "charset", "headers", "status", "reason") + if all(map(request.session.has_key, ("tz", *response_attributes))) and \ + request.POST.get("timezone", "N/A") == request.session.get("tz"): # Restores the response from the session. response = HttpResponse( - content=request.session.get("content"), - charset=request.session.get("charset"), - status=request.session.get("status_code"), - reason=request.session.get("reason_phrase"), + **{attr: request.session.get(attr) for attr in response_attributes if attr != "headers"}, headers=json.loads(request.session.get("headers")), ) - request.session.pop("content") - request.session.pop("charset") - request.session.pop("headers") - request.session.pop("status_code") - request.session.pop("reason_phrase") + # Erases the response attributes. + for attr in response_attributes: + request.session.pop(attr) return response - # Saves the response attributes in the session to restore it later. + # Gets the response and saves attributes in the session to restore it later. + response = get_response(request) request.session["content"] = response.content.decode(response.charset) request.session["headers"] = json.dumps(dict(response.headers)) request.session["charset"] = response.charset - request.session["status_code"] = response.status_code - request.session["reason_phrase"] = response.reason_phrase + request.session["status"] = response.status_code + request.session["reason"] = response.reason_phrase return render(request, "timezone.html", status=302) diff --git a/src/django_forbid/templates/timezone.html b/src/django_forbid/templates/timezone.html index 8f7f877..6242038 100644 --- a/src/django_forbid/templates/timezone.html +++ b/src/django_forbid/templates/timezone.html @@ -1,3 +1,7 @@ + + +
{% csrf_token %}
From 4e5cb57866dbebb53a48874adeed79103238ba56 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 8 Apr 2023 19:45:19 +0400 Subject: [PATCH 13/23] Fix a typo --- src/django_forbid/access.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/django_forbid/access.py b/src/django_forbid/access.py index e3cd519..16dcc35 100644 --- a/src/django_forbid/access.py +++ b/src/django_forbid/access.py @@ -43,7 +43,7 @@ def __init__(self): for territory in getattr(settings, self.territories, []): self.rules.append(ContinentRule(territory.upper())) - def accessable(self, city): + def accessible(self, city): """Checks if the IP address is in the white zone.""" return any(map(lambda rule: rule(city), self.rules)) @@ -58,7 +58,7 @@ class PermitAccess(Access): def grants(self, city): """Checks if the IP address is permitted.""" - return not self.rules or self.accessable(city) + return not self.rules or self.accessible(city) class ForbidAccess(Access): @@ -67,7 +67,7 @@ class ForbidAccess(Access): def grants(self, city): """Checks if the IP address is forbidden.""" - return not self.rules or not self.accessable(city) + return not self.rules or not self.accessible(city) def grants_access(request, ip_address): From 59aace6e64067d45d6e8b6d49fbcb28e07d826fd Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sat, 8 Apr 2023 21:07:25 +0400 Subject: [PATCH 14/23] GH-10: Handle forbidding when VPN is used --- src/django_forbid/detect.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 7511292..c01baae 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -3,6 +3,7 @@ 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 @@ -22,20 +23,15 @@ def detect(get_response, request): ]): return get_response(request) - # Ensures the request does not come - # from the `timezone.html` template. - if not request.POST.get("timezone"): - response = get_response(request) - - # Usually, this happens when CSRF is invalid. - if response.status_code == 403: + response_attributes = ("content", "charset", "headers", "status", "reason") + if all(map(request.session.has_key, ("tz", *response_attributes))): + # Handles if the user's timezone differs from the + # one determined by GeoIP API. If so, VPN is used. + if request.POST.get("timezone", "N/A") != request.session.get("tz"): if hasattr(settings, "FORBIDDEN_URL"): return redirect(settings.FORBIDDEN_URL) - return response + return HttpResponseForbidden() - response_attributes = ("content", "charset", "headers", "status", "reason") - if all(map(request.session.has_key, ("tz", *response_attributes))) and \ - request.POST.get("timezone", "N/A") == request.session.get("tz"): # Restores the response from the session. response = HttpResponse( **{attr: request.session.get(attr) for attr in response_attributes if attr != "headers"}, From cd876a89750fc39ed38e714bc8ffa01d7aca19a1 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 15:25:55 +0400 Subject: [PATCH 15/23] GH-10: Rename the detector function --- src/django_forbid/detect.py | 2 +- src/django_forbid/middleware.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index c01baae..10d585b 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -8,7 +8,7 @@ from django.shortcuts import render -def detect(get_response, request): +def detect_vpn(get_response, request): if any([ # The session key is checked to avoid # redirect loops in development mode. diff --git a/src/django_forbid/middleware.py b/src/django_forbid/middleware.py index 55a6424..cd7b0f1 100644 --- a/src/django_forbid/middleware.py +++ b/src/django_forbid/middleware.py @@ -6,7 +6,7 @@ from django.utils.timezone import utc from .access import grants_access -from .detect import detect +from .detect import detect_vpn class ForbidMiddleware: @@ -25,13 +25,13 @@ def __call__(self, request): # Checks if access is not timed out yet. if acss - request.session.get("ACCESS") < settings.FORBID_TIMEOUT: - return detect(self.get_response, request) + 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(self.get_response, request) + return detect_vpn(self.get_response, request) # Redirects to forbidden page if URL is set. if hasattr(settings, "FORBIDDEN_URL"): From 4fab76c165803235b8a6bee926d2e04903683d03 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 15:28:21 +0400 Subject: [PATCH 16/23] GH-10: Implement a `get_response` fixture --- tests/conftest.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 427ce53..cbc4c61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ -from django.conf import settings from pathlib import Path +import pytest +from django.conf import settings +from django.http import HttpResponse + def pytest_configure(): settings.configure( @@ -13,3 +16,13 @@ def pytest_configure(): # The `pathlib.Path` support was added after Django 3.0. GEOIP_PATH=(Path(__file__).parent / "geoip").as_posix(), ) + + +@pytest.fixture +def get_response(): + """A dummy view function.""" + + def get_response_mock(_): + return HttpResponse() + + return get_response_mock From ab5fba62dfb899be637e52c7e958d42988c0cc9e Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 15:30:53 +0400 Subject: [PATCH 17/23] GH-10: Add test case where `FORBID_VPN` is set --- tests/test_grants_access.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_grants_access.py b/tests/test_grants_access.py index d03bd01..8ae11df 100644 --- a/tests/test_grants_access.py +++ b/tests/test_grants_access.py @@ -13,6 +13,12 @@ def test_access_without_configuration(): assert grants_access(request, "doesnt-matter") +@override_settings(FORBID_VPN=True) +def test_access_forbid_vpn(): + """If VPN detection is enabled, access is granted everywhere.""" + assert grants_access(request, "doesnt-matter") + + @override_settings(WHITELIST_COUNTRIES=["US"], DEBUG=True) def test_access_from_localhost_development_mode(): """In development mode, access is granted from localhost.""" From 3f9bcc5a5761f78d4165e2a70571dda81dd1b1d7 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 15:33:15 +0400 Subject: [PATCH 18/23] GH-10: Enable templates in test context --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index cbc4c61..2aab0d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ def pytest_configure(): MIDDLEWARE=[ "django_forbid.middleware.ForbidMiddleware" ], + TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True}], # The `pathlib.Path` support was added after Django 3.0. GEOIP_PATH=(Path(__file__).parent / "geoip").as_posix(), ) From e6e60f5ed204f3c04cfd28f2a79235ffa1ebadaf Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 20:31:39 +0400 Subject: [PATCH 19/23] GH-10: Erase saved response attributes when VPN is detected --- src/django_forbid/detect.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 10d585b..85c102a 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -9,6 +9,12 @@ def detect_vpn(get_response, request): + response_attributes = ("content", "charset", "headers", "status", "reason") + + def erase_response_attributes(): + for attr in response_attributes: + request.session.pop(attr) + if any([ # The session key is checked to avoid # redirect loops in development mode. @@ -23,11 +29,11 @@ def detect_vpn(get_response, request): ]): return get_response(request) - response_attributes = ("content", "charset", "headers", "status", "reason") if all(map(request.session.has_key, ("tz", *response_attributes))): # Handles if the user's timezone differs from the # 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) return HttpResponseForbidden() @@ -37,9 +43,7 @@ def detect_vpn(get_response, request): **{attr: request.session.get(attr) for attr in response_attributes if attr != "headers"}, headers=json.loads(request.session.get("headers")), ) - # Erases the response attributes. - for attr in response_attributes: - request.session.pop(attr) + erase_response_attributes() return response # Gets the response and saves attributes in the session to restore it later. From 921b0c0451b13062ad3dcbffafc2374b3092bf53 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 20:34:09 +0400 Subject: [PATCH 20/23] Declare constants for IP addresses used in the tests --- tests/test_grants_access.py | 39 ++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/test_grants_access.py b/tests/test_grants_access.py index 8ae11df..d3ce67b 100644 --- a/tests/test_grants_access.py +++ b/tests/test_grants_access.py @@ -7,87 +7,94 @@ request = factory.get("/") request.session = {} +LOCALHOST = "localhost" +IP_LOCAL1 = "0.0.0.0" +IP_LOCAL2 = "127.0.0.1" +IP_LONDON = "212.102.63.59" + def test_access_without_configuration(): """If no configuration is provided, access is granted everywhere.""" - assert grants_access(request, "doesnt-matter") + assert grants_access(request, LOCALHOST) @override_settings(FORBID_VPN=True) def test_access_forbid_vpn(): """If VPN detection is enabled, access is granted everywhere.""" - assert grants_access(request, "doesnt-matter") + assert grants_access(request, LOCALHOST) @override_settings(WHITELIST_COUNTRIES=["US"], DEBUG=True) def test_access_from_localhost_development_mode(): """In development mode, access is granted from localhost.""" - assert grants_access(request, "127.0.0.1") - assert grants_access(request, "localhost") + assert grants_access(request, IP_LOCAL1) + assert grants_access(request, IP_LOCAL2) + assert grants_access(request, LOCALHOST) @override_settings(WHITELIST_COUNTRIES=["US"]) def test_access_from_localhost_production_mode(): """In production mode, access is not granted from localhost.""" - assert not grants_access(request, "127.0.0.1") - assert not grants_access(request, "localhost") + assert not grants_access(request, IP_LOCAL1) + assert not grants_access(request, IP_LOCAL2) + assert not grants_access(request, LOCALHOST) @override_settings(WHITELIST_COUNTRIES=["GB"]) 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, "212.102.63.59") + assert grants_access(request, IP_LONDON) @override_settings(WHITELIST_COUNTRIES=["US"]) 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, "212.102.63.59") + assert not grants_access(request, IP_LONDON) @override_settings(WHITELIST_TERRITORIES=["EU"]) 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, "212.102.63.59") + assert grants_access(request, IP_LONDON) @override_settings(WHITELIST_TERRITORIES=["US"]) 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, "212.102.63.59") + assert not grants_access(request, IP_LONDON) @override_settings(FORBIDDEN_COUNTRIES=["GB"]) 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, "212.102.63.59") + assert not grants_access(request, IP_LONDON) @override_settings(FORBIDDEN_COUNTRIES=["RU"]) 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, "212.102.63.59") + assert grants_access(request, IP_LONDON) @override_settings(FORBIDDEN_TERRITORIES=["EU"]) 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, "212.102.63.59") + assert not grants_access(request, IP_LONDON) @override_settings(FORBIDDEN_TERRITORIES=["AS"]) 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, "212.102.63.59") + 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, "212.102.63.59") + 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, "212.102.63.59") + assert not grants_access(request, IP_LONDON) From b375b96da19b88e1f4ca447274c0947cba03e06c Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 20:35:19 +0400 Subject: [PATCH 21/23] GH-10: Add tests for VPN detector --- tests/test_vpn_detection.py | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/test_vpn_detection.py diff --git a/tests/test_vpn_detection.py b/tests/test_vpn_detection.py new file mode 100644 index 0000000..f5b8d82 --- /dev/null +++ b/tests/test_vpn_detection.py @@ -0,0 +1,111 @@ +from django.test import RequestFactory +from django.test import override_settings + +from django_forbid.access import grants_access +from django_forbid.detect import detect_vpn + +LOCALHOST = "localhost" +IP_LONDON = "212.102.63.59" +IP_ZURICH = "146.70.99.178" + + +class SessionStore(dict): + def has_key(self, key): + return key in self + + +class WSGIRequest: + def __init__(self, accept): + self.accept = accept + self.session = SessionStore() + + def get(self): + request = RequestFactory().get("/") + request.session = self.session + request.META["HTTP_ACCEPT"] = self.accept + return request + + def post(self, data): + request = RequestFactory().post("/", data) + request.session = self.session + request.META["HTTP_ACCEPT"] = self.accept + return request + + +class Detector: + def __init__(self, get_response, ajax=False): + access = "*/*" if ajax else "text/html" + self.request = WSGIRequest(access) + self.get_response = get_response + + def request_resource(self, ip_address=""): + """Sends a request to the server to access a resource""" + request = self.request.get() + grants_access(request, ip_address) + return detect_vpn(self.get_response, request) + + def request_access(self): + """Simulates the request sent by the user browser to the server""" + request = self.request.post({"timezone": "Europe/London"}) + return detect_vpn(self.get_response, request) + + +@override_settings(FORBID_VPN=True) +def test_detect_when_using_localhost(get_response): + """It should give access to the user when using localhost""" + detector = Detector(get_response) + response = detector.request_resource(LOCALHOST) + assert response.status_code == 200 + + +@override_settings(FORBID_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) + response = detector.request_resource(LOCALHOST) + assert response.status_code == 200 + + +@override_settings(FORBID_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) + response = detector.request_resource(IP_LONDON) + assert response.status_code == 302 + response = detector.request_access() + assert response.status_code == 200 + + +@override_settings(FORBID_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) + response = detector.request_resource(IP_ZURICH) + assert response.status_code == 302 + response = detector.request_access() + assert response.status_code == 403 + + +@override_settings(FORBID_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) + response = detector.request_resource(IP_ZURICH) + assert response.status_code == 302 + response = detector.request_access() + assert response.status_code == 403 + + # Turn off VPN - back to London + detector = Detector(get_response) + response = detector.request_resource(IP_LONDON) + assert response.status_code == 302 + response = detector.request_access() + assert response.status_code == 200 + + +@override_settings(FORBID_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) + response = detector.request_resource(IP_LONDON) + assert response.status_code == 200 From 3b8e7fbd3e972d2039ea5806d77a0f5bc0a77a09 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 21:00:24 +0400 Subject: [PATCH 22/23] GH-10: Handle `headers` for older versions of Django --- src/django_forbid/detect.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/django_forbid/detect.py b/src/django_forbid/detect.py index 85c102a..13dd59b 100644 --- a/src/django_forbid/detect.py +++ b/src/django_forbid/detect.py @@ -9,7 +9,7 @@ def detect_vpn(get_response, request): - response_attributes = ("content", "charset", "headers", "status", "reason") + response_attributes = ("content", "charset", "status", "reason") def erase_response_attributes(): for attr in response_attributes: @@ -39,17 +39,18 @@ def erase_response_attributes(): return HttpResponseForbidden() # Restores the response from the session. - response = HttpResponse( - **{attr: request.session.get(attr) for attr in response_attributes if attr != "headers"}, - headers=json.loads(request.session.get("headers")), - ) + response = HttpResponse(**{attr: request.session.get(attr) for attr in response_attributes}) + if hasattr(response, "headers"): + response.headers = json.loads(request.session.get("headers")) erase_response_attributes() return response # 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. + request.session["headers"] = json.dumps(dict(response.headers)) request.session["content"] = response.content.decode(response.charset) - request.session["headers"] = json.dumps(dict(response.headers)) request.session["charset"] = response.charset request.session["status"] = response.status_code request.session["reason"] = response.reason_phrase From fec7be260e94ecc8a62cc786dd97fcf34503f6e7 Mon Sep 17 00:00:00 2001 From: Artyom Vancyan Date: Sun, 9 Apr 2023 21:33:59 +0400 Subject: [PATCH 23/23] GH-10: Add a subsection for VPN detection --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 0ac8dbe..35029e5 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ The available ISO 3166 alpha-2 country codes are listed in [here](https://www.ib 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. @@ -80,6 +82,12 @@ expires, the middleware will check the user's access again. 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. + ## Contribute Any contribution is welcome. If you have any ideas or suggestions, feel free to open an issue or a pull request. And