Skip to content

Commit 82b99f8

Browse files
jevinjojonemesifierpandafy
authored
[feature] Added Called/Calling-Station-Id to authorize API to fix Simultaneous-Use #648
Closes #648 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io> Co-authored-by: Gagan Deep <pandafy.dev@gmail.com>
1 parent c87b435 commit 82b99f8

File tree

5 files changed

+104
-17
lines changed

5 files changed

+104
-17
lines changed

docs/deploy/freeradius.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ for the available configuration values.
140140
uri = "${..connect_uri}/api/v1/freeradius/authorize/"
141141
method = 'post'
142142
body = 'json'
143-
data = '{"username": "%{User-Name}", "password": "%{User-Password}"}'
143+
data = '{"username": "%{User-Name}", "password": "%{User-Password}", "called_station_id": "%{Called-Station-ID}", "calling_station_id": "%{Calling-Station-ID}"}'
144144
tls = ${..tls}
145145
}
146146

openwisp_radius/api/freeradius_views.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,16 @@ def post(self, request, *args, **kwargs):
268268
serializer.is_valid(raise_exception=True)
269269
username = serializer.validated_data.get("username")
270270
password = serializer.validated_data.get("password")
271+
called_station_id = serializer.validated_data.get("called_station_id")
272+
calling_station_id = serializer.validated_data.get("calling_station_id")
271273
user = self.get_user(request, username, password)
272274
if user and self.authenticate_user(request, user, password):
273-
data, status = self.get_replies(user, organization_id=request.auth)
275+
data, status = self.get_replies(
276+
user,
277+
organization_id=request.auth,
278+
called_station_id=called_station_id,
279+
calling_station_id=calling_station_id,
280+
)
274281
return Response(data, status=status)
275282
if app_settings.API_AUTHORIZE_REJECT:
276283
return Response(self.reject_attributes, status=self.reject_status)
@@ -296,7 +303,9 @@ def get_user(self, request, username, password):
296303
return user
297304
return None
298305

299-
def get_replies(self, user, organization_id):
306+
def get_replies(
307+
self, user, organization_id, called_station_id=None, calling_station_id=None
308+
):
300309
"""
301310
Returns user group replies and executes counter checks
302311
"""
@@ -312,7 +321,12 @@ def get_replies(self, user, organization_id):
312321

313322
# Validate simultaneous use
314323
simultaneous_use = self._check_simultaneous_use(
315-
data, user, group_checks, organization_id
324+
data,
325+
user,
326+
group_checks,
327+
organization_id,
328+
called_station_id=called_station_id,
329+
calling_station_id=calling_station_id,
316330
)
317331
if simultaneous_use is not None:
318332
return simultaneous_use
@@ -326,7 +340,15 @@ def get_replies(self, user, organization_id):
326340

327341
return data, self.accept_status
328342

329-
def _check_simultaneous_use(self, data, user, group_checks, organization_id):
343+
def _check_simultaneous_use(
344+
self,
345+
data,
346+
user,
347+
group_checks,
348+
organization_id,
349+
called_station_id=None,
350+
calling_station_id=None,
351+
):
330352
"""
331353
Check if user has exceeded simultaneous use limit
332354
@@ -338,11 +360,22 @@ def _check_simultaneous_use(self, data, user, group_checks, organization_id):
338360
# Exit early if the `Simultaneous-Use` check is not defined
339361
# in the RadiusGroup or if it permits unlimited concurrent sessions.
340362
return None
363+
341364
open_sessions = RadiusAccounting.objects.filter(
342365
username=user.username,
343366
organization_id=organization_id,
344367
stop_time__isnull=True,
345-
).count()
368+
)
369+
# In some corner cases, RADIUS accounting sessions
370+
# can remain open even though the user is not authenticated
371+
# on the NAS anymore, for this reason, we shall allow re-authentication
372+
# of the same client on the same NAS.
373+
if called_station_id and calling_station_id:
374+
open_sessions = open_sessions.exclude(
375+
called_station_id=called_station_id,
376+
calling_station_id=calling_station_id,
377+
)
378+
open_sessions = open_sessions.count()
346379
if open_sessions >= max_simultaneous:
347380
data.update(self.reject_attributes.copy())
348381
if "Reply-Message" not in data:

openwisp_radius/api/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ class AuthorizeSerializer(serializers.Serializer):
111111
max_length=User._meta.get_field("username").max_length, write_only=True
112112
)
113113
password = serializers.CharField(style={"input_type": "password"}, write_only=True)
114+
called_station_id = serializers.CharField(
115+
max_length=253, required=False, allow_blank=False, write_only=True
116+
)
117+
calling_station_id = serializers.CharField(
118+
max_length=253, required=False, allow_blank=False, write_only=True
119+
)
114120

115121

116122
class RadiusPostAuthSerializer(serializers.ModelSerializer):

openwisp_radius/tests/mixins.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,17 @@ def _radius_batch_prefix_data(self, **kwargs):
187187
options.update(**kwargs)
188188
return options
189189

190-
def _authorize_user(self, username="tester", password="tester", auth_header=None):
190+
def _authorize_user(
191+
self, username="tester", password="tester", extra_payload=None, auth_header=None
192+
):
193+
extra_payload = extra_payload or {}
194+
opts = {
195+
"path": reverse("radius:authorize"),
196+
"data": {"username": username, "password": password, **extra_payload},
197+
}
191198
if auth_header:
192-
return self.client.post(
193-
reverse("radius:authorize"),
194-
{"username": username, "password": password},
195-
HTTP_AUTHORIZATION=auth_header,
196-
)
197-
return self.client.post(
198-
reverse("radius:authorize"),
199-
{"username": username, "password": password},
200-
)
199+
opts["HTTP_AUTHORIZATION"] = auth_header
200+
return self.client.post(**opts)
201201

202202
def _login_and_obtain_auth_token(
203203
self, username="tester", password="tester", organization=None

openwisp_radius/tests/test_api/test_freeradius_api.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1561,6 +1561,52 @@ def test_authorize_simultaneous_use(self):
15611561
{"op": "=", "value": "240"},
15621562
)
15631563

1564+
simultaneous_use_check.refresh_from_db(fields=["value"])
1565+
with self.subTest("Same device reauth on same NAS is allowed"):
1566+
# User already has one open session (at the limit of 1)
1567+
self.assertEqual(simultaneous_use_check.value, "1")
1568+
self.assertEqual(
1569+
RadiusAccounting.objects.filter(
1570+
username=user.username, stop_time=None
1571+
).count(),
1572+
1,
1573+
)
1574+
1575+
# Re-authentication from the same device and NAS should be allowed
1576+
response = self._authorize_user(
1577+
auth_header=self.auth_header,
1578+
extra_payload={
1579+
"called_station_id": self.acct_post_data["called_station_id"],
1580+
"calling_station_id": self.acct_post_data["calling_station_id"],
1581+
},
1582+
)
1583+
self.assertEqual(response.status_code, 200)
1584+
self.assertEqual(response.data["control:Auth-Type"], "Accept")
1585+
1586+
simultaneous_use_check.refresh_from_db(fields=["value"])
1587+
with self.subTest("Authorization from different device on same NAS is denied"):
1588+
# User already has one open session (at the limit of 1)
1589+
self.assertEqual(simultaneous_use_check.value, "1")
1590+
self.assertEqual(
1591+
RadiusAccounting.objects.filter(
1592+
username=user.username, stop_time=None
1593+
).count(),
1594+
1,
1595+
)
1596+
1597+
response = self._authorize_user(
1598+
auth_header=self.auth_header,
1599+
extra_payload={
1600+
"calling_station_id": "11:22:33:44:55:66",
1601+
"called_station_id": self.acct_post_data["called_station_id"],
1602+
},
1603+
)
1604+
self.assertEqual(response.status_code, 401)
1605+
self.assertEqual(
1606+
response.data["control:Auth-Type"],
1607+
"Reject",
1608+
)
1609+
15641610
with self.subTest("Closed sessions are ignored"):
15651611
# Keep limit at 1 and Close all previously open sessions
15661612
RadiusAccounting.objects.filter(stop_time=None).update(
@@ -1733,7 +1779,9 @@ def test_authorize_with_user_auth(self):
17331779
self._test_authorize_with_user_auth_helper(user.phone_number, "tester")
17341780

17351781
with self.subTest("Test authorization failure"):
1736-
r = self._authorize_user("thisuserdoesnotexist", "tester", self.auth_header)
1782+
r = self._authorize_user(
1783+
"thisuserdoesnotexist", "tester", auth_header=self.auth_header
1784+
)
17371785
self.assertEqual(r.status_code, 200)
17381786
self.assertEqual(r.data, None)
17391787

0 commit comments

Comments
 (0)