Skip to content

Commit d10ec75

Browse files
Implement access control based on the user's device (GH-15)
2 parents 8ba44be + f5d9cfd commit d10ec75

File tree

6 files changed

+193
-13
lines changed

6 files changed

+193
-13
lines changed

README.md

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Django Forbid <img src="https://github.com/pysnippet.png" align="right" height="64" />
22

3-
Django app for forbidding access to some countries.
3+
Secure your Django app by controlling the access - grant or deny user access based on device and location, including VPN
4+
detection.
45

56
[![PyPI](https://img.shields.io/pypi/v/django-forbid.svg)](https://pypi.org/project/django-forbid/)
67
[![Python](https://img.shields.io/pypi/pyversions/django-forbid.svg?logoColor=white)](https://pypi.org/project/django-forbid/)
@@ -45,35 +46,57 @@ After connecting the Django Forbid to your project, you can define the set of de
4546
All you need is to set the `DJANGO_FORBID` variable in your project's settings. It should be a dictionary with the
4647
following keys:
4748

49+
- `DEVICES` - list of devices to permit or forbid access to
4850
- `COUNTRIES` - list of countries to permit or forbid access to
4951
- `TERRITORIES` - list of territories to permit or forbid access to
5052
- `OPTIONS` - a dictionary for additional settings
51-
- `ACTION` - whether to `PERMIT` or `FORBID` access to the listed zones (default is `FORBID`)
52-
- `PERIOD` - time in seconds to check for access again, 0 means on each request
53-
- `VPN` - use VPN detection and forbid access to VPN users
54-
- `URL` - set of URLs to redirect to when the user is located in a forbidden country or using a VPN
55-
- `FORBIDDEN_LOC` - the URL to redirect to when the user is located in a forbidden country
56-
- `FORBIDDEN_VPN` - the URL to redirect to when the user is using a VPN
53+
- `ACTION` - whether to `PERMIT` or `FORBID` access to the listed zones (default is `FORBID`)
54+
- `PERIOD` - time in seconds to check for access again, 0 means on each request
55+
- `VPN` - use VPN detection and forbid access to VPN users
56+
- `URL` - set of URLs to redirect to when the user is located in a forbidden country or using a VPN
57+
- `FORBIDDEN_LOC` - the URL to redirect to when the user is located in a forbidden country
58+
- `FORBIDDEN_VPN` - the URL to redirect to when the user is using a VPN
59+
- `FORBIDDEN_KIT` - the URL to redirect to when the user is using a forbidden device
60+
61+
Unlike the `COUNTRIES` and `TERRITORIES`, where the middleware decides whether to permit or forbid access based on the
62+
given `ACTION` value, the `DEVICES` list accepts device types where the names starting with `!` are forbidden. This is
63+
done to make it possible to make them all mix together.
64+
65+
```python
66+
# Forbid access to all devices that have a small screen.
67+
'DEVICES': ['!car', '!player', '!peripheral', '!camera']
68+
69+
# Allow access to all devices having regular or large screens.
70+
'DEVICES': ['desktop', 'smartphone', 'console', 'tablet', 'tv']
71+
```
72+
73+
The available device types are: `smartphone`, `peripheral` - refers to all hardware components that are attached to a
74+
computer, `wearable` - common types of wearable technology include smartwatches and smartglasses, `phablet` - a
75+
smartphone having a larger screen, `console` - PlayStation, Xbox, etc., `display`, `speaker` - Google Assistant, Siri,
76+
Alexa, etc., `desktop`, `tablet`, `camera`, `player` - iPod, Sony Walkman, Creative Zen, etc., `phone`, `car` - refers
77+
to a car browser and `tv` - refers to TVs having internet access.
5778

5879
```python
5980
DJANGO_FORBID = {
81+
'DEVICES': ['desktop', 'smartphone', 'console', 'tablet', 'tv'],
6082
'COUNTRIES': ['US', 'GB'],
6183
'TERRITORIES': ['EU'],
6284
'OPTIONS': {
6385
'ACTION': 'PERMIT',
6486
'PERIOD': 300,
6587
'VPN': True,
6688
'URL': {
67-
'FORBIDDEN_LOC': 'forbidden_country',
89+
'FORBIDDEN_LOC': 'forbidden_location',
6890
'FORBIDDEN_VPN': 'forbidden_network',
91+
'FORBIDDEN_KIT': 'forbidden_device',
6992
},
7093
},
7194
}
7295
```
7396

74-
The available ISO 3166 alpha-2 country codes are listed in [here](https://www.iban.com/country-codes). And the available
75-
ISO continent codes are: `AF` - Africa, `AN` - Antarctica, `AS` - Asia, `EU` - Europe, `NA` - North America, `OC` -
76-
Oceania and `SA` - South America.
97+
The available country codes in the required ISO 3166 alpha-2 format are
98+
listed [here](https://www.iban.com/country-codes). And the available continent codes (territories) are: `AF` -
99+
Africa, `AN` - Antarctica, `AS` - Asia, `EU` - Europe, `NA` - North America, `OC` - Oceania and `SA` - South America.
77100

78101
_None of the settings are required. If you don't specify any settings, the middleware will not do anything._
79102

setup.cfg

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,25 @@ name = django-forbid
33
version = attr: django_forbid.__version__
44
author = Artyom Vancyan
55
author_email = [email protected]
6-
description = Django app for forbidding access to some countries
6+
description = Secure your Django app by controlling the access - grant or deny user access based on device and location, including VPN detection.
77
long_description = file: README.md
88
long_description_content_type = text/markdown
99
url = https://github.com/pysnippet/django-forbid
1010
keywords =
1111
python
1212
django
13+
permit
1314
forbid
15+
access
16+
device
17+
secure
18+
country
19+
control
20+
security
21+
location
22+
territory
23+
vpn
24+
detection
1425
django-forbid
1526
license = MIT
1627
license_file = LICENSE
@@ -39,6 +50,7 @@ packages =
3950
install_requires =
4051
Django>=2.1
4152
geoip2
53+
device_detector
4254
include_package_data = yes
4355
python_requires = >=3.6
4456
package_dir =

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.5"
1+
__version__ = "0.0.6"

src/django_forbid/device.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import re
2+
3+
from device_detector import DeviceDetector
4+
5+
from .config import Settings
6+
7+
8+
def detect_device(http_ua):
9+
device_aliases = {
10+
"portable media player": "player",
11+
"smart display": "display",
12+
"smart speaker": "speaker",
13+
"feature phone": "phone",
14+
"car browser": "car",
15+
}
16+
17+
device_detector = DeviceDetector(http_ua)
18+
device_detector = device_detector.parse()
19+
device = device_detector.device_type()
20+
return device_aliases.get(device, device)
21+
22+
23+
def normalize(device_type):
24+
"""Removes the "!" prefix from the device type."""
25+
return device_type[1:]
26+
27+
28+
def forbidden(device_type):
29+
"""Checks if the device type is forbidden."""
30+
return device_type.startswith("!")
31+
32+
33+
def permitted(device_type):
34+
"""Checks if the device type is permitted."""
35+
return not forbidden(device_type)
36+
37+
38+
def device_forbidden(device_type):
39+
devices = Settings.get("DEVICES", [])
40+
41+
# Permit all devices if the
42+
# DEVICES setting is empty.
43+
if not devices:
44+
return False
45+
46+
# Creates a regular expression in the following form:
47+
# ^(?=PERMITTED_DEVICES)(?:(?!FORBIDDEN_DEVICES)\w)+$
48+
# where the list of forbidden and permitted devices are
49+
# filtered from the DEVICES setting by the "!" prefix.
50+
permit = r"|".join(filter(permitted, devices))
51+
forbid = r"|".join(map(normalize, filter(forbidden, devices)))
52+
forbid = r"(?!" + forbid + r")" if forbid else ""
53+
regexp = r"^(?=" + permit + r")(?:" + forbid + r"\w)+$"
54+
55+
# Regexp designed to match the permitted devices.
56+
# So, this checks if the device is not permitted.
57+
return not re.match(regexp, device_type)

src/django_forbid/middleware.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from .access import grants_access
88
from .config import Settings
99
from .detect import detect_vpn
10+
from .device import detect_device
11+
from .device import device_forbidden
1012

1113

1214
class ForbidMiddleware:
@@ -19,6 +21,16 @@ def __call__(self, request):
1921
address = request.META.get("REMOTE_ADDR")
2022
address = request.META.get("HTTP_X_FORWARDED_FOR", address)
2123

24+
# Detects the user's device and saves it in the session.
25+
if not request.session.get("DEVICE"):
26+
http_ua = request.META.get("HTTP_USER_AGENT")
27+
request.session["DEVICE"] = detect_device(http_ua)
28+
29+
if device_forbidden(request.session.get("DEVICE")):
30+
if Settings.has("OPTIONS.URL.FORBIDDEN_KIT"):
31+
return redirect(Settings.get("OPTIONS.URL.FORBIDDEN_KIT"))
32+
return HttpResponseForbidden()
33+
2234
# Checks if the PERIOD attr is set and the user has been granted access.
2335
if Settings.has("OPTIONS.PERIOD") and request.session.has_key("ACCESS"):
2436
acss = datetime.utcnow().replace(tzinfo=utc).timestamp()

tests/test_device_access.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from django.test import override_settings
2+
3+
from django_forbid.device import detect_device
4+
from django_forbid.device import device_forbidden
5+
6+
unknown_ua = "curl/7.47.0"
7+
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"
8+
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"
9+
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"
10+
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"
11+
desktop_ua = "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.28) Gecko/20130316 Songbird/1.12.1 (20140112193149)"
12+
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"
13+
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"
14+
speaker_ua = "AlexaMediaPlayer/2.0.201528.0 (Linux;Android 5.1.1) ExoPlayerLib/1.5.9"
15+
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"
16+
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)"
17+
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"
18+
phone_ua = "lephone K10/Dorado WAP-Browser/1.0.0"
19+
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"
20+
tv_ua = "Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.34 Safari/537.36 WebAppManager"
21+
22+
devices = (
23+
peripheral_ua, smartphone_ua, wearable_ua, phablet_ua, desktop_ua, console_ua,
24+
display_ua, speaker_ua, camera_ua, tablet_ua, player_ua, phone_ua, car_ua, tv_ua,
25+
)
26+
27+
28+
@override_settings(DJANGO_FORBID={"DEVICES": []})
29+
def test_access_with_empty_list_of_devices():
30+
"""Should allow access to all devices if the list is empty, even if the user agent is unknown."""
31+
for device_ua in devices + (unknown_ua,):
32+
device_type = detect_device(device_ua)
33+
assert not device_forbidden(device_type)
34+
35+
36+
@override_settings(DJANGO_FORBID={"DEVICES": ["desktop", "smartphone", "console", "tablet", "tv"]})
37+
def test_access_desktops_smartphones_consoles_tablets_and_tvs():
38+
"""Should allow access to desktops, smartphones, consoles, tablets and TVs."""
39+
for device_ua in devices + (unknown_ua,):
40+
device_type = detect_device(device_ua)
41+
if device_type not in ("desktop", "smartphone", "console", "tablet", "tv"):
42+
# Forbid access to all non-listed devices.
43+
assert device_forbidden(device_type)
44+
continue
45+
assert not device_forbidden(device_type)
46+
47+
48+
@override_settings(DJANGO_FORBID={"DEVICES": ["!car", "!speaker", "!wearable"]})
49+
def test_forbid_access_to_cars_speakers_and_wearables():
50+
"""Should forbid access to cars, speakers and wearables."""
51+
for device_ua in devices:
52+
device_type = detect_device(device_ua)
53+
if device_type in ("car", "speaker", "wearable"):
54+
# Forbid access to cars, speakers and wearables.
55+
assert device_forbidden(device_type)
56+
continue
57+
assert not device_forbidden(device_type)
58+
59+
60+
@override_settings(DJANGO_FORBID={"DEVICES": ["!phablet", "tablet", "phablet"]})
61+
def test_forbid_access_if_same_device_is_listed_as_permitted_and_forbidden():
62+
"""Should forbid access if the same device is listed as permitted and forbidden."""
63+
for device_ua in devices + (unknown_ua,):
64+
device_type = detect_device(device_ua)
65+
if device_type != "tablet":
66+
# Forbid all non-tablet devices.
67+
assert device_forbidden(device_type)
68+
continue
69+
assert not device_forbidden(device_type)
70+
71+
72+
@override_settings(DJANGO_FORBID={"DEVICES": ["smartphone", "phablet", "tablet"]})
73+
def test_forbid_access_unknown_devices_if_devices_are_set():
74+
"""Should forbid access to unknown devices if the list of devices is not empty."""
75+
device_type = detect_device(unknown_ua)
76+
assert device_forbidden(device_type)

0 commit comments

Comments
 (0)