Skip to content

Commit d2854e2

Browse files
pandafynemesifier
andauthored
[feature] Added support for simultaneous-use #615
Closes #615 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent 4d9d851 commit d2854e2

File tree

11 files changed

+310
-38
lines changed

11 files changed

+310
-38
lines changed
68.3 KB
Loading
68.2 KB
Loading

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ OpenWISP architecture.
3939
user/social_login.rst
4040
user/saml.rst
4141
user/enforcing_limits.rst
42+
user/simultaneous_use.rst
4243
user/change_of_authorization.rst
4344
user/radius_monitoring
4445
user/management_commands.rst

docs/user/settings.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,20 @@ authorize the request with other freeradius modules.
411411
Set this to ``True`` if you are performing authorization exclusively
412412
through the REST API.
413413

414+
.. _openwisp_radius_simultaneous_use_enabled:
415+
416+
``OPENWISP_RADIUS_SIMULTANEOUS_USE_ENABLED``
417+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
418+
419+
**Default**: ``True``
420+
421+
Allows disabling the :doc:`Simultaneous-Use <simultaneous_use>` feature,
422+
e.g.:
423+
424+
.. code-block:: python
425+
426+
OPENWISP_RADIUS_SIMULTANEOUS_USE_ENABLED = False
427+
414428
``OPENWISP_RADIUS_API_ACCOUNTING_AUTO_GROUP``
415429
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
416430

docs/user/simultaneous_use.rst

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
Limiting concurrent sessions (``Simultaneous-Use``)
2+
===================================================
3+
4+
``Simultaneous-Use`` is a FreeRADIUS feature that restricts how many
5+
sessions a user can keep active at the same time. When the maximum limit
6+
is reached and the user attempts to start another session from a different
7+
client device, the authorization is rejected with the following RADIUS
8+
reply message:
9+
10+
.. code-block:: text
11+
12+
You are already logged in - access denied
13+
14+
FreeRADIUS can enforce this check through its ``sql`` module, but that's
15+
not multi-tenant aware: this can cause issues when a user belongs to
16+
multiple organizations with different session limits, potentially
17+
resulting in wrong limits being applied.
18+
19+
To address this, OpenWISP RADIUS provides a multi-tenant aware
20+
``Simultaneous-Use`` check in its authorization REST API endpoint.
21+
22+
Configuring Simultaneous-Use Check
23+
----------------------------------
24+
25+
Add the ``Simultaneous-Use`` RADIUS check to the desired RADIUS group by
26+
following these steps:
27+
28+
1. In the admin interface, navigate to **RADIUS** in the left-hand menu.
29+
2. Go to **Groups**.
30+
3. Select the group you want to configure.
31+
4. In the **GROUP CHECKS** section, click on **Add another Group check**.
32+
5. Fill in the fields as follows:
33+
34+
- **Attribute**: ``Simultaneous-Use``
35+
- **Operator**: ``:=``
36+
- **Value**: ``1`` (or any number greater than 0; `1` limits users to
37+
one concurrent session)
38+
39+
.. image:: ../images/simultaneous-use-radius-check.png
40+
:alt: Example of setting Idle-Timeout to 240
41+
42+
.. important::
43+
44+
When using Simultaneous-Use, it is recommended to add an
45+
``Idle-Timeout`` RADIUS reply to the same RADIUS group, with a low
46+
value (below 300 seconds). This ensures inactive sessions are cleared
47+
quickly, preventing users from being blocked due to stale sessions.
48+
49+
6. For the same radius group, in the **GROUP REPLIES** section, click on
50+
**Add another Group reply**.
51+
7. Fill in the fields as follows:
52+
53+
- **Attribute**: ``Idle-Timeout``
54+
- **Operator**: ``=``
55+
- **Value**: ``240``
56+
57+
.. image:: ../images/idle-timeout-radius-reply.png
58+
:alt: Example of setting Idle-Timeout to 240
59+
60+
8. Click on **Save and continue editing** at the bottom of the page.
61+
62+
Disabling the ``Simultaneous-Use`` check
63+
----------------------------------------
64+
65+
The ``Simultaneous-Use`` feature is **enabled by default**.
66+
67+
It can be disabled with the :ref:`OPENWISP_RADIUS_SIMULTANEOUS_USE_ENABLED
68+
<openwisp_radius_simultaneous_use_enabled>` setting.
69+
70+
This is useful if you already rely on another FreeRADIUS module to enforce
71+
``Simultaneous-Use`` and do not need the OpenWISP implementation.

openwisp_radius/api/freeradius_views.py

Lines changed: 74 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -302,41 +302,83 @@ def get_replies(self, user, organization_id):
302302

303303
group_checks = get_group_checks(user_group.group)
304304

305-
for Counter in app_settings.COUNTERS:
306-
group_check = group_checks.get(Counter.check_name)
307-
if not group_check:
308-
continue
309-
try:
310-
counter = Counter(
311-
user=user, group=user_group.group, group_check=group_check
312-
)
313-
remaining = counter.check()
314-
except SkipCheck:
315-
continue
316-
# if max is reached send access rejected + reply message
317-
except MaxQuotaReached as max_quota:
318-
data.update(self.reject_attributes.copy())
319-
if "Reply-Message" not in data:
320-
data["Reply-Message"] = max_quota.reply_message
321-
return data, self.max_quota_status
322-
# avoid crashing on unexpected runtime errors
323-
except Exception as e:
324-
logger.exception(f'Got exception "{e}" while executing {counter}')
325-
continue
326-
if remaining is None:
327-
continue
328-
reply_name = counter.reply_name
329-
# send remaining value in RADIUS reply, if needed.
330-
# This emulates the implementation of sqlcounter in freeradius
331-
# which sends the reply message only if the value is smaller
332-
# than what was defined to a previous reply message
333-
if reply_name not in data or remaining < self._get_reply_value(
334-
data, counter
335-
):
336-
data[reply_name] = remaining
305+
# Validate simultaneous use
306+
simultaneous_use = self._check_simultaneous_use(
307+
data, user, group_checks, organization_id
308+
)
309+
if simultaneous_use is not None:
310+
return simultaneous_use
311+
312+
# Execute counter checks
313+
counter_result = self._check_counters(
314+
data, user, user_group.group, group_checks
315+
)
316+
if counter_result is not None:
317+
return counter_result
337318

338319
return data, self.accept_status
339320

321+
def _check_simultaneous_use(self, data, user, group_checks, organization_id):
322+
"""
323+
Check if user has exceeded simultaneous use limit
324+
325+
Returns rejection response if limit is exceeded
326+
"""
327+
if (check := group_checks.get("Simultaneous-Use")) is None or (
328+
max_simultaneous := int(check.value)
329+
) <= 0:
330+
# Exit early if the `Simultaneous-Use` check is not defined
331+
# in the RadiusGroup or if it permits unlimited concurrent sessions.
332+
return None
333+
open_sessions = RadiusAccounting.objects.filter(
334+
username=user.username,
335+
organization_id=organization_id,
336+
stop_time__isnull=True,
337+
).count()
338+
if open_sessions >= max_simultaneous:
339+
data.update(self.reject_attributes.copy())
340+
if "Reply-Message" not in data:
341+
data["Reply-Message"] = "You are already logged in - access denied"
342+
return data, self.reject_status
343+
344+
def _check_counters(self, data, user, group, group_checks):
345+
"""
346+
Execute counter checks and return rejection response if any quota is exceeded
347+
Returns None if all checks pass
348+
"""
349+
for Counter in app_settings.COUNTERS:
350+
group_check = group_checks.get(Counter.check_name)
351+
if not group_check:
352+
continue
353+
try:
354+
counter = Counter(user=user, group=group, group_check=group_check)
355+
remaining = counter.check()
356+
except SkipCheck:
357+
continue
358+
# if max is reached send access rejected + reply message
359+
except MaxQuotaReached as max_quota:
360+
data.update(self.reject_attributes.copy())
361+
if "Reply-Message" not in data:
362+
data["Reply-Message"] = max_quota.reply_message
363+
return data, self.max_quota_status
364+
# avoid crashing on unexpected runtime errors
365+
except Exception as e:
366+
logger.exception(f'Got exception "{e}" while executing {counter}')
367+
continue
368+
if remaining is None:
369+
continue
370+
reply_name = counter.reply_name
371+
# send remaining value in RADIUS reply, if needed.
372+
# This emulates the implementation of sqlcounter in freeradius
373+
# which sends the reply message only if the value is smaller
374+
# than what was defined to a previous reply message
375+
if reply_name not in data or remaining < self._get_reply_value(
376+
data, counter
377+
):
378+
data[reply_name] = remaining
379+
380+
return None
381+
340382
@staticmethod
341383
def _get_reply_value(data, counter):
342384
value = data[counter.reply_name]["value"]

openwisp_radius/api/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ def to_representation(self, obj):
328328
user_group = get_user_group(obj, organization.pk)
329329
if not user_group:
330330
raise Http404
331-
group_checks = get_group_checks(user_group.group).values()
331+
group_checks = get_group_checks(user_group.group, counters_only=True).values()
332332
checks_data = UserGroupCheckSerializer(
333333
group_checks, many=True, context={"user": obj, "group": user_group.group}
334334
).data

openwisp_radius/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def get_default_password_reset_url(urls):
113113
"location": "disabled",
114114
},
115115
)
116+
SIMULTANEOUS_USE_ENABLED = get_settings_value("SIMULTANEOUS_USE_ENABLED", True)
116117

117118
try: # pragma: no cover
118119
assert PASSWORD_RESET_URLS

openwisp_radius/tests/test_api/test_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,7 @@ def test_user_radius_usage_view(self):
10691069
self.assertEqual(response.status_code, 200)
10701070
self.assertIn("checks", response.data)
10711071
checks = response.data["checks"]
1072+
self.assertEqual(len(checks), 2)
10721073
self.assertDictEqual(
10731074
dict(checks[0]),
10741075
{

openwisp_radius/tests/test_api/test_freeradius_api.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
RadiusAccounting = load_model("RadiusAccounting")
3131
RadiusPostAuth = load_model("RadiusPostAuth")
3232
RadiusGroupReply = load_model("RadiusGroupReply")
33+
RadiusGroupCheck = load_model("RadiusGroupCheck")
34+
RadiusGroup = load_model("RadiusGroup")
3335
RegisteredUser = load_model("RegisteredUser")
3436
OrganizationRadiusSettings = load_model("OrganizationRadiusSettings")
3537
Organization = swapper.load_model("openwisp_users", "Organization")
@@ -1344,7 +1346,7 @@ def test_authorize_counters_reply_interaction(self):
13441346

13451347
with self.subTest("Counters disabled"):
13461348
with mock.patch.object(app_settings, "COUNTERS", []):
1347-
with self.assertNumQueries(6):
1349+
with self.assertNumQueries(7):
13481350
response = self._authorize_user(auth_header=self.auth_header)
13491351
self.assertEqual(response.status_code, 200)
13501352
expected = {
@@ -1437,6 +1439,139 @@ def test_authorize_counters_exception_handling(self):
14371439
self.assertEqual(response.status_code, 200)
14381440
self.assertEqual(response.data, truncated_accept_response)
14391441

1442+
@mock.patch.object(app_settings, "SIMULTANEOUS_USE_ENABLED", True)
1443+
def test_authorize_simultaneous_use(self):
1444+
user = self._get_org_user().user
1445+
group = user.radiususergroup_set.first().group
1446+
self._create_radius_groupreply(
1447+
group=group,
1448+
groupname=group.name,
1449+
attribute="Idle-Timeout",
1450+
op="=",
1451+
value="240",
1452+
)
1453+
org2 = self._create_org(name="org2")
1454+
self._create_org_user(organization=org2, user=user)
1455+
1456+
with self.subTest("Authorization within simultaneous use limit"):
1457+
simultaneous_use_check = self._create_radius_groupcheck(
1458+
group=group,
1459+
groupname=group.name,
1460+
attribute="Simultaneous-Use",
1461+
op=":=",
1462+
value="2",
1463+
)
1464+
1465+
# Create one open session (within limit of 2)
1466+
acct_data = self.acct_post_data
1467+
acct_data.update(
1468+
{
1469+
"username": user.username,
1470+
"unique_id": "test_session_1",
1471+
"stop_time": None,
1472+
}
1473+
)
1474+
self._create_radius_accounting(**acct_data)
1475+
1476+
# Authorization should succeed
1477+
response = self._authorize_user(auth_header=self.auth_header)
1478+
self.assertEqual(response.status_code, 200)
1479+
self.assertEqual(response.data["control:Auth-Type"], "Accept")
1480+
1481+
with self.subTest("Authorization exceeding simultaneous use limit"):
1482+
RadiusGroupCheck.objects.filter(id=simultaneous_use_check.id).update(
1483+
value="1"
1484+
)
1485+
1486+
# Already have one open session (at the limit of 1)
1487+
response = self._authorize_user(auth_header=self.auth_header)
1488+
self.assertEqual(response.status_code, 401)
1489+
self.assertEqual(response.data["control:Auth-Type"], "Reject")
1490+
self.assertEqual(
1491+
response.data["Reply-Message"],
1492+
"You are already logged in - access denied",
1493+
)
1494+
self.assertEqual(
1495+
response.data["Idle-Timeout"],
1496+
{"op": "=", "value": "240"},
1497+
)
1498+
1499+
with self.subTest("Closed sessions are ignored"):
1500+
# Keep limit at 1 and Close all previously open sessions
1501+
RadiusAccounting.objects.filter(stop_time=None).update(
1502+
stop_time="2025-08-12T23:00:24.020460+01:00"
1503+
)
1504+
1505+
# Authorization should succeed (closed sessions don't count)
1506+
response = self._authorize_user(auth_header=self.auth_header)
1507+
self.assertEqual(response.status_code, 200)
1508+
self.assertEqual(response.data["control:Auth-Type"], "Accept")
1509+
1510+
with self.subTest("Open session in different org is ignored"):
1511+
# Keep limit at 1 and create an open session for the user in org2
1512+
self._create_radius_accounting(
1513+
**{
1514+
**self.acct_post_data,
1515+
"username": user.username,
1516+
"unique_id": "test_session_org2",
1517+
"stop_time": None,
1518+
"organization": org2,
1519+
}
1520+
)
1521+
1522+
# Authorization should succeed (session in different org doesn't count)
1523+
response = self._authorize_user(auth_header=self.auth_header)
1524+
self.assertEqual(response.status_code, 200)
1525+
self.assertEqual(response.data["control:Auth-Type"], "Accept")
1526+
1527+
with self.subTest("Zero limit allows unlimited simultaneous sessions"):
1528+
RadiusGroupCheck.objects.filter(id=simultaneous_use_check.id).update(
1529+
value="0"
1530+
)
1531+
1532+
# Create multiple open sessions
1533+
acct_data.update(
1534+
{
1535+
"username": user.username,
1536+
"stop_time": None,
1537+
"session_time": 1,
1538+
"input_octets": 0,
1539+
"output_octets": 0,
1540+
}
1541+
)
1542+
for i in range(2, 4): # Create 2 more sessions (total 3)
1543+
acct_data["unique_id"] = f"test_session_{i}"
1544+
self._create_radius_accounting(**acct_data)
1545+
1546+
# Authorization should still succeed with unlimited sessions
1547+
response = self._authorize_user(auth_header=self.auth_header)
1548+
self.assertEqual(response.status_code, 200)
1549+
self.assertEqual(response.data["control:Auth-Type"], "Accept")
1550+
1551+
@mock.patch.object(app_settings, "SIMULTANEOUS_USE_ENABLED", False)
1552+
@mock.patch("openwisp_radius.api.freeradius_views.RadiusAccounting.objects.count")
1553+
def test_authorize_simultaneous_use_disabled_in_settings(self, mocked_count):
1554+
user = self._get_org_user().user
1555+
group = user.radiususergroup_set.first().group
1556+
self._create_radius_groupcheck(
1557+
group=group,
1558+
groupname=group.name,
1559+
attribute="Simultaneous-Use",
1560+
op=":=",
1561+
value="1",
1562+
)
1563+
1564+
# Create an open session that would normally exceed limit
1565+
acct_data = self.acct_post_data
1566+
acct_data.update({"username": user.username, "stop_time": None}) # Open session
1567+
self._create_radius_accounting(**acct_data)
1568+
1569+
# Authorization should succeed (check is disabled)
1570+
response = self._authorize_user(auth_header=self.auth_header)
1571+
self.assertEqual(response.status_code, 200)
1572+
self.assertEqual(response.data["control:Auth-Type"], "Accept")
1573+
mocked_count.assert_not_called()
1574+
14401575
def test_authorize_radius_token_200(self):
14411576
self._get_org_user()
14421577
rad_token = self._login_and_obtain_auth_token()

0 commit comments

Comments
 (0)