Skip to content

Commit

Permalink
[feature] Send email when radius accounting session starts #343
Browse files Browse the repository at this point in the history
Closes #343
  • Loading branch information
codesankalp committed Nov 29, 2021
1 parent 8124ff4 commit 5f34960
Show file tree
Hide file tree
Showing 22 changed files with 401 additions and 35 deletions.
13 changes: 6 additions & 7 deletions docs/source/developer/captive_portal_mock.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ Captive Portal Login Mock View

This view looks for ``auth_pass`` or ``password`` in the POST request data,
and if it finds anything will try to look for any ``RadiusToken`` instance
having its key equal to this value, and if it does find one, it will create a
radius session (an entry in the ``radacct`` table) related to the user
to which the radius token belongs, provided there's no other open session
for the same user.
having its key equal to this value, and if it does find one, it makes a
``POST`` request to accouting view to create the radius session related to
the user to which the radius token belongs, provided there's no other open
session for the same user.

Captive Portal Logout Mock View
-------------------------------
Expand All @@ -32,6 +32,5 @@ Captive Portal Logout Mock View

This view looks for an entry in the ``radacct`` table with ``session_id``
equals to what is passed in the ``logout_id`` POST field and if it finds
one, it flags the session as terminated with ``User-Request``
termination cause and populates the ``stop_time`` column with the current
UTC time.
one, it makes a ``POST`` request to accounting view to flags the session
as terminated by passing ``User-Request`` as termination cause.
2 changes: 2 additions & 0 deletions openwisp_radius/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,8 @@ class OrganizationRadiusSettingsInline(admin.StackedInline):
'sms_verification',
'sms_sender',
'allowed_mobile_prefixes',
'login_url',
'status_url',
)
},
),
Expand Down
16 changes: 9 additions & 7 deletions openwisp_radius/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions openwisp_radius/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
create_default_groups_handler,
organization_post_save,
organization_pre_save,
send_email_on_new_accounting_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


Expand Down Expand Up @@ -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(
send_email_on_new_accounting_handler,
sender=AccountingView,
dispatch_uid='send_email_on_new_accounting',
)

post_save.connect(
create_default_groups_handler,
Expand Down
4 changes: 4 additions & 0 deletions openwisp_radius/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand Down
12 changes: 12 additions & 0 deletions openwisp_radius/locale/de/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,15 @@ msgstr "Passwort zurücksetzen"
#, python-format
msgid "Password reset on %s"
msgstr "Passwort zurückgesetzt auf %s"

#: openwisp_radius/templates/radius_accounting_start.html:3
msgid "New session has been started for your account with username:"
msgstr "Eine neue Sitzung wurde für Ihr Konto mit dem Benutzernamen gestartet:"

#: openwisp_radius/templates/radius_accounting_start.html:4
msgid "You can manage your account or terminate this session any time by clicking on the button below."
msgstr "Sie können Ihr Konto verwalten oder diese Sitzung jederzeit beenden, indem Sie auf die Schaltfläche unten klicken."

#: openwisp_radius/tasks.py:75
msgid "Manage Session"
msgstr "Sitzung verwalten"
12 changes: 12 additions & 0 deletions openwisp_radius/locale/fur/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,15 @@ msgstr "Resetta la password"
#, python-format
msgid "Password reset on %s"
msgstr "Reimposta password su %s"

#: openwisp_radius/templates/radius_accounting_start.html:3
msgid "New session has been started for your account with username:"
msgstr "È stata avviata una nuova sessione per il tuo account con nome utente:"

#: openwisp_radius/templates/radius_accounting_start.html:4
msgid "You can manage your account or terminate this session any time by clicking on the button below."
msgstr "Puoi gestire il tuo account o terminare questa sessione in qualsiasi momento facendo clic sul pulsante in basso."

#: openwisp_radius/tasks.py:75
msgid "Manage Session"
msgstr "Gestisci sessione"
12 changes: 12 additions & 0 deletions openwisp_radius/locale/it/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,15 @@ msgstr "Resetta la password"
#, python-format
msgid "Password reset on %s"
msgstr "Reimposta password su %s"

#: openwisp_radius/templates/radius_accounting_start.html:3
msgid "New session has been started for your account with username:"
msgstr "È stata avviata una nuova sessione per il tuo account con nome utente:"

#: openwisp_radius/templates/radius_accounting_start.html:4
msgid "You can manage your account or terminate this session any time by clicking on the button below."
msgstr "Puoi gestire il tuo account o terminare questa sessione in qualsiasi momento facendo clic sul pulsante in basso."

#: openwisp_radius/tasks.py:75
msgid "Manage Session"
msgstr "Gestisci sessione"
12 changes: 12 additions & 0 deletions openwisp_radius/locale/ru/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,15 @@ msgstr "Сброс пароля"
#, python-format
msgid "Password reset on %s"
msgstr "Сброс пароля %s"

#: openwisp_radius/templates/radius_accounting_start.html:3
msgid "New session has been started for your account with username:"
msgstr "Новый сеанс запущен для вашей учетной записи с именем пользователя:"

#: openwisp_radius/templates/radius_accounting_start.html:4
msgid "Please click on the button below to manage your session:"
msgstr "Вы можете управлять своей учетной записью или прекратить этот сеанс в любое время, нажав кнопку ниже."

#: openwisp_radius/tasks.py:75
msgid "Manage Session"
msgstr "Управление сеансом"
12 changes: 12 additions & 0 deletions openwisp_radius/locale/sl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,15 @@ msgstr "Ponastavitev gesla"
#, python-format
msgid "Password reset on %s"
msgstr "Ponastavitev gesla na %s"

#: openwisp_radius/templates/radius_accounting_start.html:3
msgid "New session has been started for your account with username:"
msgstr "Za vaš račun z uporabniškim imenom se je začela nova seja:"

#: openwisp_radius/templates/radius_accounting_start.html:4
msgid "You can manage your account or terminate this session any time by clicking on the button below."
msgstr "Svoj račun lahko upravljate ali kadar koli prekinete to sejo s klikom na spodnji gumb."

#: openwisp_radius/tasks.py:75
msgid "Manage Session"
msgstr "Upravljanje seje"
31 changes: 31 additions & 0 deletions openwisp_radius/migrations/0025_login_status_url_org_settings.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
18 changes: 18 additions & 0 deletions openwisp_radius/receivers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
"""
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 send_email_on_new_accounting_handler(sender, accounting_data, **kwargs):
stop_time = accounting_data.get('stop_time', None)
update_time = accounting_data.get('update_time', None)
if stop_time is None and update_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:
Expand Down
50 changes: 50 additions & 0 deletions openwisp_radius/tasks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import logging

import swapper
from celery import shared_task
from django.contrib.auth import get_user_model
from django.core import management
from django.core.exceptions import ObjectDoesNotExist
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
Expand Down Expand Up @@ -39,3 +51,41 @@ 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()
Organization = swapper.load_model('openwisp_users', 'Organization')
username = accounting_data.get('username', None)
org_uuid = accounting_data.get('organization')
try:
user = User.objects.get(username=username)
organization = Organization.objects.get(id=org_uuid)
if organization.is_member(user):
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'
)
else:
logger.warn(f'{username} is not the member of {organization.name}')
except ObjectDoesNotExist:
logger.warn(f'user with {username} does not exists')
5 changes: 5 additions & 0 deletions openwisp_radius/templates/radius_accounting_start.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% load i18n %}{% load l10n %}
<div class="msg">
<p>{% trans "New session has been started for your account with username:" %} {{ user.get_username }}</p>
<p>{% trans "You can manage your account or terminate this session any time by clicking on the button below." %}</p>
</div>
18 changes: 16 additions & 2 deletions openwisp_radius/tests/test_api/test_freeradius_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest import mock

import swapper
from celery.exceptions import OperationalError
from dateutil import parser
from django.contrib.auth import get_user_model
from django.core.cache import cache
Expand All @@ -13,6 +14,7 @@
from django.utils.timezone import now
from freezegun import freeze_time

from openwisp_radius.api.freeradius_views import AccountingView
from openwisp_utils.tests import capture_any_output, catch_signal

from ... import registration
Expand Down Expand Up @@ -678,7 +680,8 @@ def test_accounting_no_token_403(self):
)

@freeze_time(START_DATE)
def test_accounting_start_200(self):
@mock.patch('openwisp_radius.receivers.send_login_email.delay')
def test_accounting_start_200(self, send_login_email):
self.assertEqual(RadiusAccounting.objects.count(), 0)
ra = self._create_radius_accounting(**self._acct_initial_data)
data = self._prep_start_acct_data()
Expand All @@ -693,7 +696,17 @@ def test_accounting_start_200(self):
ra.refresh_from_db()
self.assertAcctData(ra, data)

def test_accounting_start_radius_token_201(self):
@mock.patch(
'openwisp_radius.receivers.send_login_email.delay', side_effect=OperationalError
)
@mock.patch('openwisp_radius.receivers.logger')
def test_celery_broker_unreachable(self, logger, *args):
data = self._prep_start_acct_data()
radius_accounting_success.send(sender=AccountingView, accounting_data=data)
logger.warn.assert_called_with('Celery broker is unreachable')

@mock.patch('openwisp_radius.receivers.send_login_email.delay')
def test_accounting_start_radius_token_201(self, send_login_email):
self._get_org_user()
self._login_and_obtain_auth_token()
data = self._prep_start_acct_data()
Expand All @@ -706,6 +719,7 @@ def test_accounting_start_radius_token_201(self):
content_type='application/json',
)
handler.assert_called_once()
send_login_email.assert_called_once()
self.assertEqual(response.status_code, 201)
self.assertIsNone(response.data)
self.assertEqual(RadiusAccounting.objects.count(), 1)
Expand Down
Loading

0 comments on commit 5f34960

Please sign in to comment.