Skip to content

Commit

Permalink
[feature] Added API endpoint to return user's RADIUS usage #499
Browse files Browse the repository at this point in the history
Closes #499
  • Loading branch information
pandafy committed Jan 3, 2024
1 parent 90786b1 commit cca5c19
Show file tree
Hide file tree
Showing 12 changed files with 352 additions and 32 deletions.
19 changes: 19 additions & 0 deletions docs/source/user/api.rst
Expand Up @@ -656,6 +656,25 @@ in the URL.
Responds only to **GET**.

.. _radius_usage_api_view:

User Radius Usage
-----------------

**Requires the user auth token (Bearer Token)**.

Returns the radius usage of the logged-in user and the organization specified
in the URL.

It executes the relevant RADIUS counters and returns information that
shows how much time and/or traffic the user has consumed.

.. code-block:: text
/api/v1/radius/organization/<organization-slug>/account/usage/
Responds only to **GET**.

Create SMS token
----------------

Expand Down
19 changes: 19 additions & 0 deletions docs/source/user/settings.rst
Expand Up @@ -825,6 +825,25 @@ can consume.
It should be changed according to the NAS software in use, for example,
if using PfSense, this setting should be set to ``pfSense-Max-Total-Octets``.

``OPENWISP_RADIUS_RADIUS_ATTRIBUTES_TYPE_MAP``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Default**: ``{}``

Used by :ref:`User Radius Usage API <radius_usage_api_view>`,
it stores mapping of RADIUS attributes to the unit of value
enforced by the attribute, e.g. ``bytes`` for traffic counters and
``seconds`` for session time counters.

In the following example, the setting is configured to return ``bytes``
type in the API response for ``ChilliSpot-Max-Input-Octets`` attribute:

.. code-block:: python
OPENWISP_RADIUS_RADIUS_ATTRIBUTES_TYPE_MAP = {
'ChilliSpot-Max-Input-Octets': 'bytes'
}
.. _social_login_settings:

Social Login related settings
Expand Down
32 changes: 3 additions & 29 deletions openwisp_radius/api/freeradius_views.py
Expand Up @@ -32,7 +32,7 @@
from ..counters.base import BaseCounter
from ..counters.exceptions import MaxQuotaReached, SkipCheck
from ..signals import radius_accounting_success
from ..utils import load_model
from ..utils import get_group_checks, get_user_group, load_model
from .serializers import (
AuthorizeSerializer,
RadiusAccountingSerializer,
Expand Down Expand Up @@ -296,13 +296,13 @@ def get_replies(self, user, organization_id):
Returns user group replies and executes counter checks
"""
data = self.accept_attributes.copy()
user_group = self.get_user_group(user, organization_id)
user_group = get_user_group(user, organization_id)

if user_group:
for reply in self.get_group_replies(user_group.group):
data.update({reply.attribute: {'op': reply.op, 'value': reply.value}})

group_checks = self.get_group_checks(user_group.group)
group_checks = get_group_checks(user_group.group)

for counter in app_settings.COUNTERS:
group_check = group_checks.get(counter.check_name)
Expand Down Expand Up @@ -346,35 +346,9 @@ def _get_reply_value(data, counter):
)
return math.inf

def get_user_group(self, user, organization_id):
return (
user.radiususergroup_set.filter(group__organization_id=organization_id)
.select_related('group')
.order_by('priority')
.first()
)

def get_group_replies(self, group):
return group.radiusgroupreply_set.all()

def get_group_checks(self, group):
"""
Used to query the DB for group checks only once
instead of once per each counter in use.
"""
if not app_settings.COUNTERS:
return

check_attributes = []
for counter in app_settings.COUNTERS:
check_attributes.append(counter.check_name)

group_checks = group.radiusgroupcheck_set.filter(attribute__in=check_attributes)
result = {}
for group_check in group_checks:
result[group_check.attribute] = group_check
return result

def _get_user_query_conditions(self, request):
is_active = Q(is_active=True)
needs_verification = self._needs_identity_verification({'pk': request._auth})
Expand Down
55 changes: 54 additions & 1 deletion openwisp_radius/api/serializers.py
Expand Up @@ -16,6 +16,7 @@
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import Http404
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
Expand All @@ -32,8 +33,14 @@

from .. import settings as app_settings
from ..base.forms import PasswordResetForm
from ..counters.exceptions import MaxQuotaReached, SkipCheck
from ..registration import REGISTRATION_METHOD_CHOICES
from ..utils import get_organization_radius_settings, load_model
from ..utils import (
get_group_checks,
get_organization_radius_settings,
get_user_group,
load_model,
)
from .utils import ErrorDictMixin, IDVerificationHelper

logger = logging.getLogger(__name__)
Expand All @@ -42,6 +49,8 @@
RadiusAccounting = load_model('RadiusAccounting')
RadiusBatch = load_model('RadiusBatch')
RadiusToken = load_model('RadiusToken')
RadiusGroupCheck = load_model('RadiusGroupCheck')
RadiusUserGroup = load_model('RadiusUserGroup')
RegisteredUser = load_model('RegisteredUser')
OrganizationUser = swapper.load_model('openwisp_users', 'OrganizationUser')
Organization = swapper.load_model('openwisp_users', 'Organization')
Expand Down Expand Up @@ -266,6 +275,50 @@ class Meta:
read_only_fields = ('organization',)


class UserGroupCheckSerializer(serializers.ModelSerializer):
result = serializers.SerializerMethodField()
type = serializers.SerializerMethodField()

class Meta:
model = RadiusGroupCheck
fields = ('attribute', 'op', 'value', 'result', 'type')

def get_result(self, obj):
try:
counter = app_settings.CHECK_ATTRIBUTE_COUNTERS_MAP[obj.attribute]
remaining = counter(
user=self.context['user'],
group=self.context['group'],
group_check=obj,
).check()
return int(obj.value) - remaining
except MaxQuotaReached:
return int(obj.value)
except (SkipCheck, ValueError, KeyError):
return None

def get_type(self, obj):
try:
counter = app_settings.CHECK_ATTRIBUTE_COUNTERS_MAP[obj.attribute]
except KeyError:
return None
else:
return counter.get_attribute_type()


class UserRadiusUsageSerializer(serializers.Serializer):
def to_representation(self, obj):
organization = self.context['view'].organization
user_group = get_user_group(obj, organization.pk)
if not user_group:
raise Http404
group_checks = get_group_checks(user_group.group).values()
checks_data = UserGroupCheckSerializer(
group_checks, many=True, context={'user': obj, 'group': user_group.group}
).data
return {'checks': checks_data}


class GroupSerializer(serializers.ModelSerializer):
class Meta:
model = Group
Expand Down
5 changes: 5 additions & 0 deletions openwisp_radius/api/urls.py
Expand Up @@ -50,6 +50,11 @@ def get_api_urls(api_views=None):
api_views.user_accounting,
name='user_accounting',
),
path(
'radius/organization/<slug:slug>/account/usage/',
api_views.user_radius_usage,
name='user_radius_usage',
),
# generate new sms phone token
path(
'radius/organization/<slug:slug>/account/phone/token/',
Expand Down
27 changes: 27 additions & 0 deletions openwisp_radius/api/views.py
Expand Up @@ -60,6 +60,7 @@
ChangePhoneNumberSerializer,
RadiusAccountingSerializer,
RadiusBatchSerializer,
UserRadiusUsageSerializer,
ValidatePhoneTokenSerializer,
)
from .swagger import ObtainTokenRequest, ObtainTokenResponse, RegisterResponse
Expand All @@ -80,6 +81,8 @@
RadiusAccounting = load_model('RadiusAccounting')
RadiusToken = load_model('RadiusToken')
RadiusBatch = load_model('RadiusBatch')
RadiusUserGroup = load_model('RadiusUserGroup')
RadiusGroupCheck = load_model('RadiusGroupCheck')
auth_backend = UsersAuthenticationBackend()


Expand Down Expand Up @@ -444,6 +447,30 @@ def get_queryset(self):
user_accounting = UserAccountingView.as_view()


@method_decorator(
name='get',
decorator=swagger_auto_schema(
operation_description="""
**Requires the user auth token (Bearer Token).**
Returns the user's RADIUS usage and limit for the organization.
It executes the relevant counters and returns returns information that
shows how much time and/or traffic the user has consumed.
""",
),
)
class UserRadiusUsageView(ThrottledAPIMixin, DispatchOrgMixin, RetrieveAPIView):
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated,)
queryset = User.objects.none()
serializer_class = UserRadiusUsageSerializer

def get_object(self):
return self.request.user


user_radius_usage = UserRadiusUsageView.as_view()


class PasswordChangeView(ThrottledAPIMixin, DispatchOrgMixin, BasePasswordChangeView):
authentication_classes = (BearerAuthentication,)

Expand Down
9 changes: 9 additions & 0 deletions openwisp_radius/counters/base.py
Expand Up @@ -60,6 +60,15 @@ def __repr__(self):
f'organization_id={self.organization_id})'
)

@classmethod
def get_attribute_type(self):
check_name = self.check_name.lower()
if 'traffic' in check_name:
return 'bytes'
elif 'session' in check_name:
return 'seconds'
return app_settings.RADIUS_ATTRIBUTES_TYPE_MAP.get(self.check_name, None)

def get_reset_timestamps(self):
try:
return resets[self.reset](self.user)
Expand Down
4 changes: 4 additions & 0 deletions openwisp_radius/settings.py
Expand Up @@ -220,13 +220,17 @@ def get_default_password_reset_url(urls):
except KeyError as e: # pragma: no cover
raise ImproperlyConfigured(str(e))

RADIUS_ATTRIBUTES_TYPE_MAP = get_settings_value('RADIUS_ATTRIBUTES_TYPE_MAP', {})

COUNTERS = []
CHECK_ATTRIBUTE_COUNTERS_MAP = {}
for counter_path in _counters:
try:
counter_class = import_string(counter_path)
except ImportError as e: # pragma: no cover
raise ImproperlyConfigured(str(e))
COUNTERS.append(counter_class)
CHECK_ATTRIBUTE_COUNTERS_MAP[counter_class.check_name] = counter_class


# Extend the EXPORT_USERS_COMMAND_CONFIG[fields]
Expand Down

0 comments on commit cca5c19

Please sign in to comment.