From a567c534ed5e42ddf02ab87a550a8df9c0f0be80 Mon Sep 17 00:00:00 2001 From: sankalp Date: Fri, 19 Nov 2021 18:27:20 +0530 Subject: [PATCH] [feature] Send email when radius accounting session starts #343 Closes #343 --- openwisp_radius/admin.py | 2 + openwisp_radius/api/views.py | 16 ++++--- openwisp_radius/apps.py | 9 ++++ openwisp_radius/base/models.py | 4 ++ .../0025_login_status_url_org_settings.py | 31 +++++++++++++ openwisp_radius/receivers.py | 20 +++++++++ openwisp_radius/tasks.py | 45 +++++++++++++++++++ .../templates/radius_accounting_start.html | 9 ++++ setup.py | 4 +- .../0025_login_status_url_org_settings.py | 31 +++++++++++++ tests/openwisp2/settings.py | 2 + 11 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 openwisp_radius/migrations/0025_login_status_url_org_settings.py create mode 100644 openwisp_radius/templates/radius_accounting_start.html create mode 100644 tests/openwisp2/sample_radius/migrations/0025_login_status_url_org_settings.py diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 1b673b69..529e1fdf 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -666,6 +666,8 @@ class OrganizationRadiusSettingsInline(admin.StackedInline): 'sms_verification', 'sms_sender', 'allowed_mobile_prefixes', + 'login_url', + 'status_url', ) }, ), diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 96de3f73..f15d5fcf 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -41,7 +41,7 @@ from rest_framework.throttling import BaseThrottle # get_ident method from openwisp_radius.api.serializers import RadiusUserSerializer -from openwisp_users.api.authentication import BearerAuthentication +from openwisp_users.api.authentication import BearerAuthentication, SesameAuthentication from openwisp_users.api.permissions import IsOrganizationManager from openwisp_users.api.views import ChangePasswordView as BasePasswordChangeView @@ -268,7 +268,7 @@ class ObtainAuthTokenView( throttle_scope = 'obtain_auth_token' serializer_class = rest_auth_settings.TokenSerializer auth_serializer_class = AuthTokenSerializer - authentication_classes = [] + authentication_classes = [SesameAuthentication] @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): @@ -281,11 +281,13 @@ def post(self, request, *args, **kwargs): """ Obtain the user radius token required for authentication in APIs. """ - serializer = self.auth_serializer_class( - data=request.data, context={'request': request} - ) - serializer.is_valid(raise_exception=True) - user = self.get_user(serializer, *args, **kwargs) + user = request.user + if user.is_anonymous: + serializer = self.auth_serializer_class( + data=request.data, context={'request': request} + ) + serializer.is_valid(raise_exception=True) + user = self.get_user(serializer, *args, **kwargs) token, _ = UserToken.objects.get_or_create(user=user) self.get_or_create_radius_token(user, self.organization, renew=renew_required) self.update_user_details(user) diff --git a/openwisp_radius/apps.py b/openwisp_radius/apps.py index 9353154e..e4a826c6 100644 --- a/openwisp_radius/apps.py +++ b/openwisp_radius/apps.py @@ -14,9 +14,11 @@ create_default_groups_handler, organization_post_save, organization_pre_save, + radius_accounting_success_handler, set_default_group_handler, ) from .registration import register_registration_method +from .signals import radius_accounting_success from .utils import load_model, update_user_related_records @@ -62,6 +64,13 @@ def connect_signals(self): RadiusToken = load_model('RadiusToken') RadiusAccounting = load_model('RadiusAccounting') User = get_user_model() + from openwisp_radius.api.freeradius_views import AccountingView + + radius_accounting_success.connect( + radius_accounting_success_handler, + sender=AccountingView, + dispatch_uid='radius_accounting_success', + ) post_save.connect( create_default_groups_handler, diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 36e8ee90..ce8f5844 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -162,6 +162,8 @@ _IDENTITY_VERIFICATION_ENABLED_HELP_TEXT = _( 'Whether identity verification is required at the time of user registration' ) +_LOGIN_URL_HELP_TEXT = _('Enter the url where users can log in to the wifi service') +_STATUS_URL_HELP_TEXT = _('Enter the url where users can log out from the wifi service') class AutoUsernameMixin(object): @@ -1128,6 +1130,8 @@ class AbstractOrganizationRadiusSettings(UUIDModel): default=True, help_text=_REGISTRATION_ENABLED_HELP_TEXT, ) + login_url = models.URLField(null=True, blank=True, help_text=_LOGIN_URL_HELP_TEXT) + status_url = models.URLField(null=True, blank=True, help_text=_STATUS_URL_HELP_TEXT) class Meta: verbose_name = _('Organization radius settings') diff --git a/openwisp_radius/migrations/0025_login_status_url_org_settings.py b/openwisp_radius/migrations/0025_login_status_url_org_settings.py new file mode 100644 index 00000000..c0acbbfe --- /dev/null +++ b/openwisp_radius/migrations/0025_login_status_url_org_settings.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.8 on 2021-11-19 07:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('openwisp_radius', '0024_registereduser_modified'), + ] + + operations = [ + migrations.AddField( + model_name='organizationradiussettings', + name='login_url', + field=models.URLField( + blank=True, + help_text='Enter the url where users can log in to the wifi service', + null=True, + ), + ), + migrations.AddField( + model_name='organizationradiussettings', + name='status_url', + field=models.URLField( + blank=True, + help_text='Enter the url where users can log out from the wifi service', + null=True, + ), + ), + ] diff --git a/openwisp_radius/receivers.py b/openwisp_radius/receivers.py index b4ce47dd..17c8bd67 100644 --- a/openwisp_radius/receivers.py +++ b/openwisp_radius/receivers.py @@ -1,10 +1,30 @@ """ Receiver functions for django signals (eg: post_save) """ +import logging + +from celery.exceptions import OperationalError + +from openwisp_radius.tasks import send_login_email + from . import settings as app_settings from . import tasks from .utils import create_default_groups, load_model +logger = logging.getLogger(__name__) + + +def radius_accounting_success_handler(sender, accounting_data, **kwargs): + unique_id = accounting_data.get('unique_id', None) + if unique_id: + RadiusAccounting = load_model('RadiusAccounting') + ra = RadiusAccounting.objects.filter(unique_id=unique_id).first() + if ra and ra.stop_time is None: + try: + send_login_email.delay(accounting_data) + except OperationalError: + logger.warn('Celery broker is unreachable') + def set_default_group_handler(sender, instance, created, **kwargs): if created: diff --git a/openwisp_radius/tasks.py b/openwisp_radius/tasks.py index a2bc3832..90afe753 100644 --- a/openwisp_radius/tasks.py +++ b/openwisp_radius/tasks.py @@ -1,5 +1,15 @@ +import logging + from celery import shared_task +from django.contrib.auth import get_user_model from django.core import management +from django.template import loader +from django.utils.translation import activate +from django.utils.translation import gettext_lazy as _ + +from openwisp_utils.admin_theme.email import send_email + +logger = logging.getLogger(__name__) @shared_task @@ -39,3 +49,38 @@ def delete_unverified_users(older_than_days=1, exclude_methods=''): @shared_task def convert_called_station_id(unique_id=None): management.call_command('convert_called_station_id', unique_id=unique_id) + + +@shared_task +def send_login_email(accounting_data): + User = get_user_model() + username = accounting_data.get('username', None) + org_uuid = accounting_data.get('organization') + user = User.objects.filter(username=username).first() + if user: + organization = ( + user.openwisp_users_organization.all().filter(id=org_uuid).first() + ) + if organization: + from sesame.utils import get_query_string + + org_radius_settings = organization.radius_settings + login_url = org_radius_settings.login_url + if login_url: + activate(user.language) + one_time_login_url = login_url + get_query_string(user) + subject = _('New radius accounting session started') + context = { + 'user': user, + 'subject': subject, + 'call_to_action_url': one_time_login_url, + 'call_to_action_text': _('Manage Session'), + } + body_html = loader.render_to_string( + 'radius_accounting_start.html', context + ) + send_email(subject, body_html, body_html, [user.email], context) + else: + logger.error( + f'login_url is not defined for {organization.name} organization' + ) diff --git a/openwisp_radius/templates/radius_accounting_start.html b/openwisp_radius/templates/radius_accounting_start.html new file mode 100644 index 00000000..8bdfd026 --- /dev/null +++ b/openwisp_radius/templates/radius_accounting_start.html @@ -0,0 +1,9 @@ +{% load i18n %}{% load l10n %} +
+

+{% trans "New session has been started for your account with username:" %} {{ user.get_username }} +

+

+{% trans "Please click on the button below to manage your session:" %} +

+
diff --git a/setup.py b/setup.py index 49e74f16..151ffd6d 100644 --- a/setup.py +++ b/setup.py @@ -39,8 +39,8 @@ # Needed for the new authentication backend in openwisp-users # TODO: remove when the new version of openwisp-users is released ( - 'openwisp-users @ https://github.com/openwisp/openwisp-users/' - 'tarball/issue/261-user-preferred-lang' + 'openwisp-users @ https://github.com/codesankalp/openwisp-users/' + 'tarball/dev' ), # TODO: change this when next point version of openwisp-utils is released ( diff --git a/tests/openwisp2/sample_radius/migrations/0025_login_status_url_org_settings.py b/tests/openwisp2/sample_radius/migrations/0025_login_status_url_org_settings.py new file mode 100644 index 00000000..f069dfd6 --- /dev/null +++ b/tests/openwisp2/sample_radius/migrations/0025_login_status_url_org_settings.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.8 on 2021-11-19 12:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sample_radius', '0023_registereduser_modified'), + ] + + operations = [ + migrations.AddField( + model_name='organizationradiussettings', + name='login_url', + field=models.URLField( + blank=True, + help_text='Enter the url where users can log in to the wifi service', + null=True, + ), + ), + migrations.AddField( + model_name='organizationradiussettings', + name='status_url', + field=models.URLField( + blank=True, + help_text='Enter the url where users can log out from the wifi service', + null=True, + ), + ), + ] diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 4fc67abd..289d6e6b 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -56,6 +56,7 @@ AUTHENTICATION_BACKENDS = ( 'openwisp_users.backends.UsersAuthenticationBackend', 'djangosaml2.backends.Saml2Backend', + 'sesame.backends.ModelBackend', ) AUTH_USER_MODEL = 'openwisp_users.User' @@ -73,6 +74,7 @@ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'sesame.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'djangosaml2.middleware.SamlSessionMiddleware',