diff --git a/README.md b/README.md index cf304f8..dbaf6de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Django Forbid -Django app for forbidding access to some countries. +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/) @@ -45,18 +46,39 @@ After connecting the Django Forbid to your project, you can define the set of de All you need is to set the `DJANGO_FORBID` variable in your project's settings. It should be a dictionary with the following keys: +- `DEVICES` - list of devices to permit or forbid access to - `COUNTRIES` - list of countries to permit or forbid access to - `TERRITORIES` - list of territories to permit or forbid access to - `OPTIONS` - a dictionary for additional settings - - `ACTION` - whether to `PERMIT` or `FORBID` access to the listed zones (default is `FORBID`) - - `PERIOD` - time in seconds to check for access again, 0 means on each request - - `VPN` - use VPN detection and forbid access to VPN users - - `URL` - set of URLs to redirect to when the user is located in a forbidden country or using a VPN - - `FORBIDDEN_LOC` - the URL to redirect to when the user is located in a forbidden country - - `FORBIDDEN_VPN` - the URL to redirect to when the user is using a VPN + - `ACTION` - whether to `PERMIT` or `FORBID` access to the listed zones (default is `FORBID`) + - `PERIOD` - time in seconds to check for access again, 0 means on each request + - `VPN` - use VPN detection and forbid access to VPN users + - `URL` - set of URLs to redirect to when the user is located in a forbidden country or using a VPN + - `FORBIDDEN_LOC` - the URL to redirect to when the user is located in a forbidden country + - `FORBIDDEN_VPN` - the URL to redirect to when the user is using a VPN + - `FORBIDDEN_KIT` - the URL to redirect to when the user is using a forbidden device + +Unlike the `COUNTRIES` and `TERRITORIES`, where the middleware decides whether to permit or forbid access based on the +given `ACTION` value, the `DEVICES` list accepts device types where the names starting with `!` are forbidden. This is +done to make it possible to make them all mix together. + +```python +# Forbid access to all devices that have a small screen. +'DEVICES': ['!car', '!player', '!peripheral', '!camera'] + +# Allow access to all devices having regular or large screens. +'DEVICES': ['desktop', 'smartphone', 'console', 'tablet', 'tv'] +``` + +The available device types are: `smartphone`, `peripheral` - refers to all hardware components that are attached to a +computer, `wearable` - common types of wearable technology include smartwatches and smartglasses, `phablet` - a +smartphone having a larger screen, `console` - PlayStation, Xbox, etc., `display`, `speaker` - Google Assistant, Siri, +Alexa, etc., `desktop`, `tablet`, `camera`, `player` - iPod, Sony Walkman, Creative Zen, etc., `phone`, `car` - refers +to a car browser and `tv` - refers to TVs having internet access. ```python DJANGO_FORBID = { + 'DEVICES': ['desktop', 'smartphone', 'console', 'tablet', 'tv'], 'COUNTRIES': ['US', 'GB'], 'TERRITORIES': ['EU'], 'OPTIONS': { @@ -64,16 +86,17 @@ DJANGO_FORBID = { 'PERIOD': 300, 'VPN': True, 'URL': { - 'FORBIDDEN_LOC': 'forbidden_country', + 'FORBIDDEN_LOC': 'forbidden_location', 'FORBIDDEN_VPN': 'forbidden_network', + 'FORBIDDEN_KIT': 'forbidden_device', }, }, } ``` -The available ISO 3166 alpha-2 country codes are listed in [here](https://www.iban.com/country-codes). And the available -ISO continent codes are: `AF` - Africa, `AN` - Antarctica, `AS` - Asia, `EU` - Europe, `NA` - North America, `OC` - -Oceania and `SA` - South America. +The available country codes in the required ISO 3166 alpha-2 format are +listed [here](https://www.iban.com/country-codes). And the available continent codes (territories) are: `AF` - +Africa, `AN` - Antarctica, `AS` - Asia, `EU` - Europe, `NA` - North America, `OC` - Oceania and `SA` - South America. _None of the settings are required. If you don't specify any settings, the middleware will not do anything._ diff --git a/setup.cfg b/setup.cfg index 20d04b1..865416e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,14 +3,25 @@ name = django-forbid version = attr: django_forbid.__version__ author = Artyom Vancyan author_email = artyom@pysnippet.org -description = Django app for forbidding access to some countries +description = Secure your Django app by controlling the access - grant or deny user access based on device and location, including VPN detection. long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/pysnippet/django-forbid keywords = python django + permit forbid + access + device + secure + country + control + security + location + territory + vpn + detection django-forbid license = MIT license_file = LICENSE @@ -39,6 +50,7 @@ packages = install_requires = Django>=2.1 geoip2 + device_detector include_package_data = yes python_requires = >=3.6 package_dir = diff --git a/src/django_forbid/__init__.py b/src/django_forbid/__init__.py index b1a19e3..034f46c 100644 --- a/src/django_forbid/__init__.py +++ b/src/django_forbid/__init__.py @@ -1 +1 @@ -__version__ = "0.0.5" +__version__ = "0.0.6" diff --git a/src/django_forbid/device.py b/src/django_forbid/device.py new file mode 100644 index 0000000..2ddeb88 --- /dev/null +++ b/src/django_forbid/device.py @@ -0,0 +1,57 @@ +import re + +from device_detector import DeviceDetector + +from .config import Settings + + +def detect_device(http_ua): + device_aliases = { + "portable media player": "player", + "smart display": "display", + "smart speaker": "speaker", + "feature phone": "phone", + "car browser": "car", + } + + device_detector = DeviceDetector(http_ua) + device_detector = device_detector.parse() + device = device_detector.device_type() + return device_aliases.get(device, device) + + +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) + + +def device_forbidden(device_type): + devices = Settings.get("DEVICES", []) + + # Permit all devices if the + # DEVICES setting is empty. + if not devices: + return False + + # 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. + # So, this checks if the device is not permitted. + return not re.match(regexp, device_type) diff --git a/src/django_forbid/middleware.py b/src/django_forbid/middleware.py index f6a211b..244b216 100644 --- a/src/django_forbid/middleware.py +++ b/src/django_forbid/middleware.py @@ -7,6 +7,8 @@ from .access import grants_access from .config import Settings from .detect import detect_vpn +from .device import detect_device +from .device import device_forbidden class ForbidMiddleware: @@ -19,6 +21,16 @@ 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() diff --git a/tests/test_device_access.py b/tests/test_device_access.py new file mode 100644 index 0000000..87af843 --- /dev/null +++ b/tests/test_device_access.py @@ -0,0 +1,76 @@ +from django.test import override_settings + +from django_forbid.device import detect_device +from django_forbid.device import device_forbidden + +unknown_ua = "curl/7.47.0" +peripheral_ua = "Mozilla/5.0 (Linux; Android 7.0; SHTRIH-SMARTPOS-F2 Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/51.0.2704.91 Mobile Safari/537.36" +smartphone_ua = "SAMSUNG-GT-S3850/S3850CXKD1 SHP/VPP/R5 Dolfin/2.0 NexPlayer/3.0 SMM-MMS/1.2.0 profile/MIDP-2.1 configuration/CLDC-1.1 OPN-B" +wearable_ua = "Mozilla/5.0 (Linux; Android 8.1.0; KidPhone4G Build/O11019; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36" +phablet_ua = "Mozilla/5.0 (Linux; Android 6.0; GI-626 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.124 Mobile Safari/537.36" +desktop_ua = "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.28) Gecko/20130316 Songbird/1.12.1 (20140112193149)" +console_ua = "Mozilla/5.0 (Linux; Android 4.1.1; ARCHOS GAMEPAD Build/JRO03H) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Safari/535.19" +display_ua = "Mozilla/5.0 (Linux; U; Android 4.0.4; fr-be; DA220HQL Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30" +speaker_ua = "AlexaMediaPlayer/2.0.201528.0 (Linux;Android 5.1.1) ExoPlayerLib/1.5.9" +camera_ua = "Mozilla/5.0 (Linux; U; Android 2.3.3; ja-jp; COOLPIX S800c Build/CP01_WW) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1" +tablet_ua = "Mozilla/5.0 (iPad3,6; iPad; U; CPU OS 7_1 like Mac OS X; en_US) com.google.GooglePlus/33839 (KHTML, like Gecko) Mobile/P103AP (gzip)" +player_ua = "Mozilla/5.0 (iPod; U; CPU iPhone OS 4_2_1 like Mac OS X; ja-jp) AppleWebKit/533.17.9 (KHTML, like Gecko) Mobile/8C148" +phone_ua = "lephone K10/Dorado WAP-Browser/1.0.0" +car_ua = "Mozilla/5.0 (Linux; Android 4.4.2; CarPad-II-P Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Safari/537.36" +tv_ua = "Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.34 Safari/537.36 WebAppManager" + +devices = ( + peripheral_ua, smartphone_ua, wearable_ua, phablet_ua, desktop_ua, console_ua, + display_ua, speaker_ua, camera_ua, tablet_ua, player_ua, phone_ua, car_ua, tv_ua, +) + + +@override_settings(DJANGO_FORBID={"DEVICES": []}) +def test_access_with_empty_list_of_devices(): + """Should allow access to all devices if the list is empty, even if the user agent is unknown.""" + for device_ua in devices + (unknown_ua,): + device_type = detect_device(device_ua) + assert not device_forbidden(device_type) + + +@override_settings(DJANGO_FORBID={"DEVICES": ["desktop", "smartphone", "console", "tablet", "tv"]}) +def test_access_desktops_smartphones_consoles_tablets_and_tvs(): + """Should allow access to desktops, smartphones, consoles, tablets and TVs.""" + for device_ua in devices + (unknown_ua,): + device_type = detect_device(device_ua) + if device_type not in ("desktop", "smartphone", "console", "tablet", "tv"): + # Forbid access to all non-listed devices. + assert device_forbidden(device_type) + continue + assert not device_forbidden(device_type) + + +@override_settings(DJANGO_FORBID={"DEVICES": ["!car", "!speaker", "!wearable"]}) +def test_forbid_access_to_cars_speakers_and_wearables(): + """Should forbid access to cars, speakers and wearables.""" + for device_ua in devices: + device_type = detect_device(device_ua) + if device_type in ("car", "speaker", "wearable"): + # Forbid access to cars, speakers and wearables. + assert device_forbidden(device_type) + continue + assert not device_forbidden(device_type) + + +@override_settings(DJANGO_FORBID={"DEVICES": ["!phablet", "tablet", "phablet"]}) +def test_forbid_access_if_same_device_is_listed_as_permitted_and_forbidden(): + """Should forbid access if the same device is listed as permitted and forbidden.""" + for device_ua in devices + (unknown_ua,): + device_type = detect_device(device_ua) + if device_type != "tablet": + # Forbid all non-tablet devices. + assert device_forbidden(device_type) + continue + assert not device_forbidden(device_type) + + +@override_settings(DJANGO_FORBID={"DEVICES": ["smartphone", "phablet", "tablet"]}) +def test_forbid_access_unknown_devices_if_devices_are_set(): + """Should forbid access to unknown devices if the list of devices is not empty.""" + device_type = detect_device(unknown_ua) + assert device_forbidden(device_type)