Skip to content

Commit 340a64e

Browse files
authored
[change] Change of Authorization: added replies, handled MaxQuotaReached, refactored #643
- When the RADIUS group of a user is changed, the previous implementation did not include the RADIUS replies of the new group in the CoA packet, which could result in incomplete or incorrect authorization updates. This has been fixed by including the RADIUS replies of the new group. - Added handling of ``MaxQuotaReached`` exceptions: when the RADIUS group of a user is changed but their limits are already exceeded, instead of sending a CoA Request (to modify a session), we now send a Disconnect Message to log out the user. - Refactored code to reuse counter and attribute retrieval from ``AuthorizeView._check_counters``, reducing duplication and improving maintainability. Closes #643
1 parent f5da602 commit 340a64e

File tree

8 files changed

+555
-211
lines changed

8 files changed

+555
-211
lines changed

openwisp_radius/api/freeradius_views.py

Lines changed: 22 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import ipaddress
22
import logging
3-
import math
43
import re
54

65
import drf_link_header_pagination
@@ -30,9 +29,15 @@
3029
from .. import registration
3130
from .. import settings as app_settings
3231
from ..counters.base import BaseCounter
33-
from ..counters.exceptions import MaxQuotaReached, SkipCheck
32+
from ..counters.exceptions import MaxQuotaReached
3433
from ..signals import radius_accounting_success
35-
from ..utils import get_group_checks, get_user_group, load_model
34+
from ..utils import (
35+
execute_counter_checks,
36+
get_group_checks,
37+
get_group_replies,
38+
get_user_group,
39+
load_model,
40+
)
3641
from .serializers import (
3742
AuthorizeSerializer,
3843
RadiusAccountingSerializer,
@@ -299,8 +304,9 @@ def get_replies(self, user, organization_id):
299304
user_group = get_user_group(user, organization_id)
300305

301306
if user_group:
302-
for reply in self.get_group_replies(user_group.group):
303-
data.update({reply.attribute: {"op": reply.op, "value": reply.value}})
307+
# Use utility function to get group replies
308+
group_replies = get_group_replies(user_group.group)
309+
data.update(group_replies)
304310

305311
group_checks = get_group_checks(user_group.group)
306312

@@ -348,53 +354,20 @@ def _check_counters(self, data, user, group, group_checks):
348354
Execute counter checks and return rejection response if any quota is exceeded
349355
Returns None if all checks pass
350356
"""
351-
for Counter in app_settings.COUNTERS:
352-
group_check = group_checks.get(Counter.check_name)
353-
if not group_check:
354-
continue
355-
try:
356-
counter = Counter(user=user, group=group, group_check=group_check)
357-
remaining = counter.check()
358-
except SkipCheck:
359-
continue
360-
# if max is reached send access rejected + reply message
361-
except MaxQuotaReached as max_quota:
362-
data.update(self.reject_attributes.copy())
363-
if "Reply-Message" not in data:
364-
data["Reply-Message"] = max_quota.reply_message
365-
return data, self.max_quota_status
366-
# avoid crashing on unexpected runtime errors
367-
except Exception as e:
368-
logger.exception(f'Got exception "{e}" while executing {counter}')
369-
continue
370-
if remaining is None:
371-
continue
372-
reply_name = counter.reply_name
373-
# send remaining value in RADIUS reply, if needed.
374-
# This emulates the implementation of sqlcounter in freeradius
375-
# which sends the reply message only if the value is smaller
376-
# than what was defined to a previous reply message
377-
if reply_name not in data or remaining < self._get_reply_value(
378-
data, counter
379-
):
380-
data[reply_name] = remaining
381-
382-
return None
383-
384-
@staticmethod
385-
def _get_reply_value(data, counter):
386-
value = data[counter.reply_name]["value"]
387357
try:
388-
return int(value)
389-
except ValueError:
390-
logger.warning(
391-
f'{counter.reply_name} value ("{value}") '
392-
"cannot be converted to integer."
358+
counter_replies = execute_counter_checks(
359+
user, group, group_checks, existing_replies=data
393360
)
394-
return math.inf
361+
# Merge counter replies into data
362+
data.update(counter_replies)
363+
except MaxQuotaReached as max_quota:
364+
# if max is reached send access rejected + reply message
365+
data.update(self.reject_attributes.copy())
366+
if "Reply-Message" not in data:
367+
data["Reply-Message"] = max_quota.reply_message
368+
return data, self.max_quota_status
395369

396-
def get_group_replies(self, group):
397-
return group.radiusgroupreply_set.all()
370+
return None
398371

399372
def _get_user_query_conditions(self, request):
400373
is_active = Q(is_active=True)

openwisp_radius/coa.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import ipaddress
2+
import logging
3+
4+
from django.contrib.auth import get_user_model
5+
6+
from .counters.exceptions import MaxQuotaReached
7+
from .radclient.client import RadClient
8+
from .utils import (
9+
execute_counter_checks,
10+
get_group_checks,
11+
get_group_replies,
12+
load_model,
13+
)
14+
15+
logger = logging.getLogger(__name__)
16+
17+
RadiusAccounting = load_model("RadiusAccounting")
18+
RadiusGroupCheck = load_model("RadiusGroupCheck")
19+
RadiusGroupReply = load_model("RadiusGroupReply")
20+
RadiusGroup = load_model("RadiusGroup")
21+
Nas = load_model("Nas")
22+
User = get_user_model()
23+
24+
25+
class ChangeOfAuthorizationManager:
26+
"""
27+
Manages Change of Authorization (CoA) operations for RADIUS users.
28+
Handles counter checks, attribute retrieval, and communication with NAS.
29+
"""
30+
31+
def get_radsecret_from_radacct(self, rad_acct):
32+
"""
33+
Get RADIUS secret for a given RadiusAccounting session.
34+
"""
35+
qs = Nas.objects.filter(organization_id=rad_acct.organization_id).only(
36+
"name", "secret"
37+
)
38+
nas_ip_address = ipaddress.ip_address(rad_acct.nas_ip_address)
39+
for nas in qs.iterator():
40+
try:
41+
if nas_ip_address in ipaddress.ip_network(nas.name):
42+
return nas.secret
43+
except ValueError:
44+
logger.warning(
45+
f'Failed to parse NAS IP network for "{nas.id}" object. Skipping!'
46+
)
47+
48+
def get_radius_attributes(self, user, old_group_id, new_group):
49+
"""
50+
Get RADIUS attributes for CoA operation including both checks and replies.
51+
Returns dict of attributes.
52+
"""
53+
attributes = {}
54+
old_group = (
55+
RadiusGroup.objects.prefetch_related(
56+
"radiusgroupcheck_set", "radiusgroupreply_set"
57+
)
58+
.filter(id=old_group_id)
59+
.first()
60+
)
61+
62+
# Include all RadiusGroupReplies for the new group
63+
group_replies = get_group_replies(new_group)
64+
if group_replies:
65+
for key, value in group_replies.items():
66+
attributes[key] = value["value"]
67+
elif old_group:
68+
# We need to unset attributes set by the previous group
69+
old_group_replies = get_group_replies(old_group)
70+
for reply in old_group_replies:
71+
attributes[reply] = ""
72+
73+
# Include replies from the RadiusGroupChecks for the new group
74+
group_checks = get_group_checks(new_group)
75+
if group_checks:
76+
check_results = execute_counter_checks(user, new_group, group_checks)
77+
for reply, value in check_results.items():
78+
attributes[reply] = str(value)
79+
elif old_group:
80+
# We need to unset attributes set by the previous group
81+
old_group_checks = get_group_checks(old_group, counters_only=True)
82+
check_results = execute_counter_checks(
83+
user, old_group, old_group_checks, raise_quota_exceeded=False
84+
)
85+
for reply_name in check_results.keys():
86+
attributes[reply_name] = ""
87+
88+
return attributes
89+
90+
def perform_change_of_authorization(self, user_id, old_group_id, new_group_id):
91+
"""
92+
Perform Change of Authorization for a user's group change.
93+
"""
94+
try:
95+
user = User.objects.get(pk=user_id)
96+
except User.DoesNotExist:
97+
logger.warning(
98+
f'Failed to find user with "{user_id}" ID. Skipping CoA operation.'
99+
)
100+
return
101+
try:
102+
new_rad_group = (
103+
RadiusGroup.objects.prefetch_related(
104+
"radiusgroupcheck_set", "radiusgroupreply_set"
105+
)
106+
.select_related("organization", "organization__radius_settings")
107+
.get(id=new_group_id)
108+
)
109+
except RadiusGroup.DoesNotExist:
110+
logger.warning(
111+
f'Failed to find RadiusGroup with "{new_group_id}" ID.'
112+
" Skipping CoA operation."
113+
)
114+
return
115+
org_radius_settings = new_rad_group.organization.radius_settings
116+
# The coa_enabled value is provided by a FallbackBooleanChoiceField on the
117+
# model instance and cannot be reliably evaluated inside queryset filters.
118+
# Evaluate it here on the resolved model instance instead of trying to
119+
# filter at the database level.
120+
if not org_radius_settings.coa_enabled:
121+
logger.info(
122+
f'CoA is disabled for "{new_rad_group.organization}" organization.'
123+
" Skipping CoA operation."
124+
)
125+
return
126+
# Check if user has open RadiusAccounting sessions
127+
open_sessions = RadiusAccounting.objects.filter(
128+
username=user.username,
129+
organization_id=new_rad_group.organization_id,
130+
stop_time__isnull=True,
131+
)
132+
if not open_sessions:
133+
logger.warning(
134+
f'The user "{user.username} <{user.email}>" does not have any open'
135+
" RadiusAccounting sessions. Skipping CoA operation."
136+
)
137+
return
138+
attributes = {}
139+
func = "perform_change_of_authorization"
140+
operation = "CoA"
141+
try:
142+
new_group_attributes = self.get_radius_attributes(
143+
user, old_group_id, new_rad_group
144+
)
145+
if not new_group_attributes:
146+
# No attributes to send, skip CoA operation
147+
logger.warning(
148+
f'No RADIUS attributes found for "{new_group_id}" RadiusGroup.'
149+
" Skipping CoA operation."
150+
)
151+
return
152+
attributes.update(new_group_attributes)
153+
except MaxQuotaReached:
154+
func = "perform_disconnect"
155+
operation = "Disconnect"
156+
updated_sessions = []
157+
for session in open_sessions:
158+
radsecret = self.get_radsecret_from_radacct(session)
159+
if not radsecret:
160+
logger.warning(
161+
f'Failed to find RADIUS secret for "{session.unique_id}"'
162+
" RadiusAccounting object. Skipping CoA operation"
163+
" for this session."
164+
)
165+
continue
166+
attributes["User-Name"] = session.username
167+
client = RadClient(
168+
host=session.nas_ip_address,
169+
radsecret=radsecret,
170+
)
171+
result = getattr(client, func)(attributes)
172+
if result is True:
173+
session.groupname = new_rad_group.name
174+
updated_sessions.append(session)
175+
else:
176+
logger.warning(
177+
f'Failed to perform {operation} for "{session.unique_id}"'
178+
f' RadiusAccounting object of "{user}" user'
179+
)
180+
RadiusAccounting.objects.bulk_update(updated_sessions, fields=["groupname"])
181+
182+
183+
coa_manager = ChangeOfAuthorizationManager()

0 commit comments

Comments
 (0)