Skip to content

Commit

Permalink
Implement access control based on the user's device (GH-15)
Browse files Browse the repository at this point in the history
  • Loading branch information
ArtyomVancyan authored Apr 17, 2023
2 parents 8ba44be + f5d9cfd commit d10ec75
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 13 deletions.
45 changes: 34 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Django Forbid <img src="https://github.com/pysnippet.png" align="right" height="64" />

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/)
Expand Down Expand Up @@ -45,35 +46,57 @@ 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': {
'ACTION': 'PERMIT',
'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._

Expand Down
14 changes: 13 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ name = django-forbid
version = attr: django_forbid.__version__
author = Artyom Vancyan
author_email = [email protected]
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
Expand Down Expand Up @@ -39,6 +50,7 @@ packages =
install_requires =
Django>=2.1
geoip2
device_detector
include_package_data = yes
python_requires = >=3.6
package_dir =
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.0.5"
__version__ = "0.0.6"
57 changes: 57 additions & 0 deletions src/django_forbid/device.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions src/django_forbid/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down
76 changes: 76 additions & 0 deletions tests/test_device_access.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit d10ec75

Please sign in to comment.