Skip to content

Commit

Permalink
Add a feature for specifying location by states (GH-34)
Browse files Browse the repository at this point in the history
  • Loading branch information
ArtyomVancyan authored Jun 17, 2023
2 parents c06f469 + a3dbd21 commit 86439f5
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 8 deletions.
2 changes: 1 addition & 1 deletion docs/integration/settings/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ DJANGO_FORBID = {
```

In this example, the Django Forbid will permit access to users using the listed devices and forbid entry to users
worldwide except for the US, UK, and EU countries. It will also forbid access to the users who use VPN to lie about
worldwide except for the US, GB, and EU countries. It will also forbid access to the users who use VPN to lie about
their geolocation. The settings also define the URLs to redirect to when access is forbidden.
6 changes: 6 additions & 0 deletions docs/integration/settings/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ The list of countries to permit or forbid access to. The list accepts country co
the ones starting with the `!` prefix are forbidden. The list of all codes can be
found [here](https://www.iban.com/country-codes).

Also, you can permit or forbid access to a certain state of the country by specifying the state code after the country
code is joined with a `:` symbol. For example, `!US:TX` will forbid access to Texas by permitting the rest of the
mentioned country states. With the same principle, the `US:CA` will permit access to California only. The list of all US
state codes can be found [here](https://wikipedia.org/wiki/List_of_states_and_territories_of_the_United_States). This
declaration method can be used with all countries having states/districts.

## Territories

- Key: `TERRITORIES`
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.1.4"
__version__ = "0.1.5"
52 changes: 48 additions & 4 deletions src/django_forbid/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,59 @@ def permitted(cls, attribute_type):
"""Checks if the type is permitted."""
return not cls.forbidden(attribute_type)

@staticmethod
def getattr(attribute_type):
"""Checks if the type has an attribute and parses it."""
matching_attribute = re.match(r"^!(\w+):\w+$", attribute_type)
return matching_attribute.group(1) if matching_attribute else None

def grants(self, attribute_type):
# Creates a regular expression in the following form:
# ^(?=PERMITTED_ATTRIBUTES)(?:(?!FORBIDDEN_ATTRIBUTES)\w)+$
# where the list of forbidden and permitted attributes are
# filtered from the ATTRIBUTES setting by the "!" prefix.
# ^(?=PERMITTED_ATTRIBUTES)(?:(?!FORBIDDEN_ATTRIBUTES)\w+(?::\w+)?)$
# where the list of forbidden and permitted attributes is determined
# by filtering the particular setting attributes by the "!" prefix.
permit = r"|".join(filter(self.permitted, self.attributes))
permit = r"\w+" if any(filter(self.getattr, self.attributes)) else permit
forbid = r"|".join(map(self.normalize, filter(self.forbidden, self.attributes)))
forbid = r"(?!" + forbid + r")" if forbid else ""
regexp = r"^(?=" + permit + r")(?:" + forbid + r"\w)+$"
regexp = r"^(?=" + permit + r")(?:" + forbid + r"\w+(?::\w+)?)$"

# Regexp designed to match the permitted attributes.
return re.match(regexp, attribute_type)


continents_codes = {
'AF': ['AS'], 'AL': ['EU'], 'AQ': ['AN'], 'DZ': ['AF'], 'AS': ['OC'], 'AD': ['EU'], 'AO': ['AF'], 'AG': ['NA'],
'AZ': ['EU', 'AS'], 'AR': ['SA'], 'AU': ['OC'], 'AT': ['EU'], 'BS': ['NA'], 'BH': ['AS'], 'BD': ['AS'],
'AM': ['EU', 'AS'], 'BB': ['NA'], 'BE': ['EU'], 'BM': ['NA'], 'BT': ['AS'], 'BO': ['SA'], 'BA': ['EU'],
'BW': ['AF'], 'BV': ['AN'], 'BR': ['SA'], 'BZ': ['NA'], 'IO': ['AS'], 'SB': ['OC'], 'VG': ['NA'], 'BN': ['AS'],
'BG': ['EU'], 'MM': ['AS'], 'BI': ['AF'], 'BY': ['EU'], 'KH': ['AS'], 'CM': ['AF'], 'CA': ['NA'], 'CV': ['AF'],
'KY': ['NA'], 'CF': ['AF'], 'LK': ['AS'], 'TD': ['AF'], 'CL': ['SA'], 'CN': ['AS'], 'TW': ['AS'], 'CX': ['AS'],
'CC': ['AS'], 'CO': ['SA'], 'KM': ['AF'], 'YT': ['AF'], 'CG': ['AF'], 'CD': ['AF'], 'CK': ['OC'], 'CR': ['NA'],
'HR': ['EU'], 'CU': ['NA'], 'CY': ['EU', 'AS'], 'CZ': ['EU'], 'BJ': ['AF'], 'DK': ['EU'], 'DM': ['NA'],
'DO': ['NA'], 'EC': ['SA'], 'SV': ['NA'], 'GQ': ['AF'], 'ET': ['AF'], 'ER': ['AF'], 'EE': ['EU'], 'FO': ['EU'],
'FK': ['SA'], 'GS': ['AN'], 'FJ': ['OC'], 'FI': ['EU'], 'AX': ['EU'], 'FR': ['EU'], 'GF': ['SA'], 'PF': ['OC'],
'TF': ['AN'], 'DJ': ['AF'], 'GA': ['AF'], 'GE': ['EU', 'AS'], 'GM': ['AF'], 'PS': ['AS'], 'DE': ['EU'],
'GH': ['AF'], 'GI': ['EU'], 'KI': ['OC'], 'GR': ['EU'], 'GL': ['NA'], 'GD': ['NA'], 'GP': ['NA'], 'GU': ['OC'],
'GT': ['NA'], 'GN': ['AF'], 'GY': ['SA'], 'HT': ['NA'], 'HM': ['AN'], 'VA': ['EU'], 'HN': ['NA'], 'HK': ['AS'],
'HU': ['EU'], 'IS': ['EU'], 'IN': ['AS'], 'ID': ['AS'], 'IR': ['AS'], 'IQ': ['AS'], 'IE': ['EU'], 'IL': ['AS'],
'IT': ['EU'], 'CI': ['AF'], 'JM': ['NA'], 'JP': ['AS'], 'KZ': ['EU', 'AS'], 'JO': ['AS'], 'KE': ['AF'],
'KP': ['AS'], 'KR': ['AS'], 'KW': ['AS'], 'KG': ['AS'], 'LA': ['AS'], 'LB': ['AS'], 'LS': ['AF'], 'LV': ['EU'],
'LR': ['AF'], 'LY': ['AF'], 'LI': ['EU'], 'LT': ['EU'], 'LU': ['EU'], 'MO': ['AS'], 'MG': ['AF'], 'MW': ['AF'],
'MY': ['AS'], 'MV': ['AS'], 'ML': ['AF'], 'MT': ['EU'], 'MQ': ['NA'], 'MR': ['AF'], 'MU': ['AF'], 'MX': ['NA'],
'MC': ['EU'], 'MN': ['AS'], 'MD': ['EU'], 'ME': ['EU'], 'MS': ['NA'], 'MA': ['AF'], 'MZ': ['AF'], 'OM': ['AS'],
'NA': ['AF'], 'NR': ['OC'], 'NP': ['AS'], 'NL': ['EU'], 'AN': ['NA'], 'CW': ['NA'], 'AW': ['NA'], 'SX': ['NA'],
'BQ': ['NA'], 'NC': ['OC'], 'VU': ['OC'], 'NZ': ['OC'], 'NI': ['NA'], 'NE': ['AF'], 'NG': ['AF'], 'NU': ['OC'],
'NF': ['OC'], 'NO': ['EU'], 'MP': ['OC'], 'UM': ['OC', 'NA'], 'FM': ['OC'], 'MH': ['OC'], 'PW': ['OC'],
'PK': ['AS'], 'PA': ['NA'], 'PG': ['OC'], 'PY': ['SA'], 'PE': ['SA'], 'PH': ['AS'], 'PN': ['OC'], 'PL': ['EU'],
'PT': ['EU'], 'GW': ['AF'], 'TL': ['AS'], 'PR': ['NA'], 'QA': ['AS'], 'RE': ['AF'], 'RO': ['EU'], 'RW': ['AF'],
'RU': ['EU', 'AS'], 'BL': ['NA'], 'SH': ['AF'], 'KN': ['NA'], 'AI': ['NA'], 'LC': ['NA'], 'MF': ['NA'],
'PM': ['NA'], 'VC': ['NA'], 'SM': ['EU'], 'ST': ['AF'], 'SA': ['AS'], 'SN': ['AF'], 'RS': ['EU'], 'SC': ['AF'],
'SL': ['AF'], 'SG': ['AS'], 'SK': ['EU'], 'VN': ['AS'], 'SI': ['EU'], 'SO': ['AF'], 'ZA': ['AF'], 'ZW': ['AF'],
'ES': ['EU'], 'SS': ['AF'], 'EH': ['AF'], 'SD': ['AF'], 'SR': ['SA'], 'SJ': ['EU'], 'SZ': ['AF'], 'SE': ['EU'],
'CH': ['EU'], 'SY': ['AS'], 'TJ': ['AS'], 'TH': ['AS'], 'TG': ['AF'], 'TK': ['OC'], 'TO': ['OC'], 'TT': ['NA'],
'AE': ['AS'], 'TN': ['AF'], 'TR': ['EU', 'AS'], 'TM': ['AS'], 'TC': ['NA'], 'TV': ['OC'], 'UG': ['AF'],
'UA': ['EU'], 'MK': ['EU'], 'EG': ['AF'], 'GB': ['EU'], 'GG': ['EU'], 'JE': ['EU'], 'IM': ['EU'], 'TZ': ['AF'],
'US': ['NA'], 'VI': ['NA'], 'BF': ['AF'], 'UY': ['SA'], 'UZ': ['AS'], 'VE': ['SA'], 'WF': ['OC'], 'WS': ['OC'],
'YE': ['AS'], 'ZM': ['AF'], 'XX': ['OC'], 'XE': ['AS'], 'XD': ['AS'], 'XS': ['AS']
}
16 changes: 15 additions & 1 deletion src/django_forbid/skills/forbid_location.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import itertools

from django.conf import settings
from django.contrib.gis.geoip2 import GeoIP2
from django.http import HttpResponseForbidden
Expand All @@ -6,6 +8,7 @@

from . import Access
from . import Settings
from . import continents_codes


class ForbidLocationMiddleware:
Expand All @@ -30,8 +33,19 @@ def __call__(self, request):

countries = Settings.get("COUNTRIES", [])
territories = Settings.get("TERRITORIES", [])
country_state_code = city.get("region")
country_identifier = city.get("country_code")

if territories:
# Adds the continent codes of the countries that are forbidden partially.
territories = list({*territories, *itertools.chain.from_iterable(
map(continents_codes.__getitem__, filter(bool, map(Access.getattr, countries)))
)})

if country_state_code:
country_identifier += ":%s" % country_state_code
granted = all([
Access(countries).grants(city.get("country_code")),
Access(countries).grants(country_identifier),
Access(territories).grants(city.get("continent_code")),
])
except (AddressNotFoundError, Exception):
Expand Down
2 changes: 2 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class IP:
ip_local2 = "127.0.0.1"
ip_london = "212.102.63.59"
ip_zurich = "146.70.99.178"
ip_dallas = "198.96.95.234"
ip_cobain = "104.129.57.189"

locals = [
Expand All @@ -20,6 +21,7 @@ class IP:
ip_london,
ip_zurich,
ip_cobain,
ip_dallas,
]


Expand Down
30 changes: 30 additions & 0 deletions tests/test_location_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ def test_should_forbid_all_when_production_mode(get_response):
assert forbids(get_response, ip_address)


@override_settings(DJANGO_FORBID={"COUNTRIES": ["US:WA"]})
def test_should_allow_users_only_from_washington(get_response):
"""Access is granted from Washington."""
for ip_address in IP.all:
if ip_address != IP.ip_cobain:
assert forbids(get_response, ip_address)
continue
assert not forbids(get_response, ip_address)


@override_settings(DJANGO_FORBID={"COUNTRIES": ["!US:TX"]})
def test_should_forbid_users_only_from_texas(get_response):
"""Access is forbidden from Texas."""
for ip_address in IP.all:
if ip_address in [*IP.locals, IP.ip_dallas]:
assert forbids(get_response, ip_address)
continue
assert not forbids(get_response, ip_address)


@override_settings(DJANGO_FORBID={"COUNTRIES": ["!US:TX", "!US:IL", "GB"], "TERRITORIES": ["EU"]})
def test_should_forbid_users_only_from_texas_and_illinois(get_response):
"""Access is forbidden from Texas and Illinois."""
for ip_address in IP.all:
if ip_address in [*IP.locals, IP.ip_dallas]:
assert forbids(get_response, ip_address)
continue
assert not forbids(get_response, ip_address)


@override_settings(DJANGO_FORBID={"COUNTRIES": ["GB"]})
def test_should_allow_country_when_country_in_countries_whitelist_otherwise_forbid(get_response):
"""Access is granted from GB when GB is in the counties' whitelist."""
Expand Down
2 changes: 1 addition & 1 deletion tests/test_primary_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def test_should_forbid_users_when_country_in_territories_blacklist(get_response)
"""Should forbid access to users from territories in blacklist."""
for ip_address in IP.all:
request.META["HTTP_X_FORWARDED_FOR"] = ip_address
if ip_address in [*IP.locals, IP.ip_cobain]:
if ip_address in [*IP.locals, IP.ip_cobain, IP.ip_dallas]:
assert forbids(get_response, request)
continue
assert not forbids(get_response, request)
Expand Down

0 comments on commit 86439f5

Please sign in to comment.