Skip to content

Commit

Permalink
[feature] Supported password expiration feature of openwisp-users #491
Browse files Browse the repository at this point in the history
Closes #491
  • Loading branch information
pandafy authored Nov 14, 2023
1 parent 9ac7164 commit 7cf4490
Show file tree
Hide file tree
Showing 14 changed files with 150 additions and 6 deletions.
23 changes: 23 additions & 0 deletions docs/source/user/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,29 @@ Specifies the datetime format of OpenVPN management status parser used by the
:ref:`convert_called_station_id <convert_called_station_id>`
command.

``OPENWISP_RADIUS_UNVERIFY_INACTIVE_USERS``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Default**: ``0`` (disabled)

Number of days from user's ``last_login`` after which the
user will be flagged as *unverified*.

When set to ``0``, the feature would be disabled and the user will
not be flagged as *unverified*.

``OPENWISP_RADIUS_DELETE_INACTIVE_USERS``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Default**: ``0`` (disabled)

Number of days from user's ``last_login`` after which the
user will be deleted.

When set to ``0``, the feature would be disabled and the user will
not be deleted.


API and user token related settings
===================================

Expand Down
9 changes: 7 additions & 2 deletions openwisp_radius/api/freeradius_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,13 @@ def authenticate_user(self, request, user, password):
"""
return bool(
getattr(request, '_mac_allowed', False)
or user.check_password(password)
or self.check_user_token(request, user, password)
or (
not user.has_password_expired()
and (
user.check_password(password)
or self.check_user_token(request, user, password)
)
)
)

def check_user_token(self, request, user, password):
Expand Down
2 changes: 2 additions & 0 deletions openwisp_radius/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ class RadiusUserSerializer(serializers.ModelSerializer):
source='registered_user.method',
allow_null=True,
)
password_expired = serializers.BooleanField(source='has_password_expired')
radius_user_token = serializers.CharField(source='radius_token.key', default=None)

class Meta:
Expand All @@ -636,5 +637,6 @@ class Meta:
'is_active',
'is_verified',
'method',
'password_expired',
'radius_user_token',
]
20 changes: 20 additions & 0 deletions openwisp_radius/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1560,3 +1560,23 @@ class Meta:
abstract = True
verbose_name = _('Registration Information')
verbose_name_plural = verbose_name

@classmethod
def unverify_inactive_users(cls):
if not app_settings.UNVERIFY_INACTIVE_USERS:
return
cls.objects.filter(
user__is_staff=False,
user__last_login__lt=timezone.now()
- timedelta(days=app_settings.UNVERIFY_INACTIVE_USERS),
).update(is_verified=False)

@classmethod
def delete_inactive_users(cls):
if not app_settings.DELETE_INACTIVE_USERS:
return
User.objects.filter(
is_staff=False,
last_login__lt=timezone.now()
- timedelta(days=app_settings.DELETE_INACTIVE_USERS),
).delete()
2 changes: 2 additions & 0 deletions openwisp_radius/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def get_default_password_reset_url(urls):
SAML_UPDATES_PRE_EXISTING_USERNAME = get_settings_value(
'SAML_UPDATES_PRE_EXISTING_USERNAME', False
)
UNVERIFY_INACTIVE_USERS = get_settings_value('UNVERIFY_INACTIVE_USERS', 0)
DELETE_INACTIVE_USERS = get_settings_value('DELETE_INACTIVE_USERS', 0)
DISPOSABLE_RADIUS_USER_TOKEN = get_settings_value('DISPOSABLE_RADIUS_USER_TOKEN', True)
API_ACCOUNTING_AUTO_GROUP = get_settings_value('API_ACCOUNTING_AUTO_GROUP', True)
FREERADIUS_ALLOWED_HOSTS = get_settings_value('FREERADIUS_ALLOWED_HOSTS', [])
Expand Down
12 changes: 12 additions & 0 deletions openwisp_radius/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ def delete_unverified_users(older_than_days=1, exclude_methods=''):
)


@shared_task
def unverify_inactive_users():
RegisteredUser = load_model('RegisteredUser')
RegisteredUser.unverify_inactive_users()


@shared_task
def delete_inactive_users():
RegisteredUser = load_model('RegisteredUser')
RegisteredUser.delete_inactive_users()


@shared_task
def convert_called_station_id(unique_id=None):
management.call_command('convert_called_station_id', unique_id=unique_id)
Expand Down
2 changes: 2 additions & 0 deletions openwisp_radius/tests/test_api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ def test_radius_user_serializer(self):
'location': user.location,
'is_active': user.is_active,
'is_verified': user.registered_user.is_verified,
'password_expired': user.has_password_expired(),
'method': user.registered_user.method,
'radius_user_token': user.radius_token.key,
},
Expand All @@ -348,6 +349,7 @@ def test_radius_user_serializer(self):
'is_active': admin.is_active,
'is_verified': None,
'method': None,
'password_expired': False,
'radius_user_token': None,
},
)
Expand Down
10 changes: 9 additions & 1 deletion openwisp_radius/tests/test_api/test_freeradius_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from django.db import IntegrityError
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.timezone import now, timedelta
from freezegun import freeze_time

from openwisp_utils.tests import capture_any_output, catch_signal
Expand Down Expand Up @@ -252,6 +252,14 @@ def test_authorize_200_querystring(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)

@mock.patch('openwisp_users.settings.USER_PASSWORD_EXPIRATION', 30)
def test_authorize_password_expired(self):
self._get_org_user()
User.objects.update(password_updated=now() - timedelta(days=60))
response = self._authorize_user(auth_header=self.auth_header)
self.assertNotEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
self.assertEqual(response.data, None)

def test_authorize_failed(self):
response = self._authorize_user(
username='baldo', password='ugo', auth_header=self.auth_header
Expand Down
18 changes: 17 additions & 1 deletion openwisp_radius/tests/test_api/test_rest_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import swapper
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.timezone import localtime
from django.utils.timezone import localtime, now, timedelta
from freezegun import freeze_time
from rest_framework.authtoken.models import Token

Expand Down Expand Up @@ -45,6 +45,7 @@ def _user_auth_token_helper(self, username):
self.assertIn('is_verified', response.data)
self.assertIn('method', response.data)
self.assertIn('radius_user_token', response.data)
return response

def test_user_auth_token_200(self):
org_user = self._get_org_user()
Expand Down Expand Up @@ -278,6 +279,13 @@ def test_user_auth_token_inactive_user(self):
self.assertEqual(response.status_code, 401)
self.assertFalse(response.data['is_active'])

@mock.patch('openwisp_users.settings.USER_PASSWORD_EXPIRATION', 30)
def test_user_auth_token_password_expired(self):
self._get_org_user()
User.objects.update(password_updated=now() - timedelta(days=60))
response = self._user_auth_token_helper('tester')
self.assertEqual(response.data['password_expired'], True)


class TestApiValidateToken(ApiTokenMixin, BaseTestCase):
def _get_url(self):
Expand Down Expand Up @@ -337,6 +345,7 @@ def _test_validate_auth_token_helper(self, user):
self.assertIn('is_verified', response.data)
self.assertIn('method', response.data)
self.assertIn('radius_user_token', response.data)
return response

def test_validate_auth_token_with_active_user(self):
user = self._get_user_with_org()
Expand Down Expand Up @@ -384,3 +393,10 @@ def test_user_auth_updates_last_login(self):
admin.refresh_from_db()
self.assertIsNotNone(admin.last_login)
self.assertEqual(localtime(admin.last_login).isoformat(), _TEST_DATE)

@mock.patch('openwisp_users.settings.USER_PASSWORD_EXPIRATION', 30)
def test_validate_auth_token_password_expired(self):
user = self._get_org_user().user
User.objects.update(password_updated=now() - timedelta(days=60))
response = self._test_validate_auth_token_helper(user)
self.assertEqual(response.data['password_expired'], True)
38 changes: 38 additions & 0 deletions openwisp_radius/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from openwisp_radius import tasks
from openwisp_utils.tests import capture_any_output, capture_stdout

from .. import settings as app_settings
from ..utils import load_model
from . import _RADACCT, FileMixin
from .mixins import BaseTestCase
Expand Down Expand Up @@ -242,3 +243,40 @@ def test_send_login_email(self, translation_activate, logger):
'have any OpenWISP RADIUS settings configured'
)
translation_activate.assert_not_called()

@mock.patch.object(app_settings, 'UNVERIFY_INACTIVE_USERS', 30)
def test_unverify_inactive_users(self, *args):
today = now()
admin = self._create_admin(last_login=today - timedelta(days=90))
user1 = self._create_org_user().user
user2 = self._create_org_user(
user=self._create_user(username='user2', email='user2@example.com')
).user
User.objects.filter(id=user1.id).update(last_login=today)
User.objects.filter(id=user2.id).update(last_login=today - timedelta(days=60))
RegisteredUser.objects.create(user=admin, is_verified=True)
RegisteredUser.objects.create(user=user1, is_verified=True)
RegisteredUser.objects.create(user=user2, is_verified=True)

tasks.unverify_inactive_users.delay()
admin.refresh_from_db()
user1.refresh_from_db()
user2.refresh_from_db()
self.assertEqual(admin.registered_user.is_verified, True)
self.assertEqual(user1.registered_user.is_verified, True)
self.assertEqual(user2.registered_user.is_verified, False)

@mock.patch.object(app_settings, 'DELETE_INACTIVE_USERS', 30)
def test_delete_inactive_users(self, *args):
today = now()
admin = self._create_admin(last_login=today - timedelta(days=90))
user1 = self._create_org_user().user
user2 = self._create_org_user(
user=self._create_user(username='user2', email='user2@example.com')
).user
User.objects.filter(id=user1.id).update(last_login=today)
User.objects.filter(id=user2.id).update(last_login=today - timedelta(days=60))

tasks.delete_inactive_users.delay()
self.assertEqual(User.objects.filter(id__in=[admin.id, user1.id]).count(), 2)
self.assertEqual(User.objects.filter(id__in=[user2.id]).count(), 0)
1 change: 1 addition & 0 deletions openwisp_radius/tests/test_users_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def test_radiustoken_inline(self):
params.pop('_password', None)
params.pop('bio', None)
params.pop('last_login', None)
params.pop('password_updated', None)
params.pop('birth_date', None)
params = self._additional_params_pop(params)
params.update(self._get_user_edit_form_inline_params(user, org))
Expand Down
3 changes: 1 addition & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@
install_requires=[
(
'openwisp-users '
'@ https://github.com/openwisp/openwisp-users/tarball/'
'master'
'@ https://github.com/openwisp/openwisp-users/tarball/master'
),
(
'openwisp-utils[rest,celery] @ '
Expand Down
6 changes: 6 additions & 0 deletions tests/openwisp2/sample_users/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ class Migration(migrations.Migration):
max_length=8,
),
),
(
'password_updated',
models.DateField(
blank=True, null=True, verbose_name="password updated"
),
),
(
'notes',
models.TextField(
Expand Down
10 changes: 10 additions & 0 deletions tests/openwisp2/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@
'args': [365],
'relative': True,
},
'unverify_inactive_users': {
'task': 'openwisp_radius.tasks.unverify_inactive_users',
'schedule': crontab(hour=1, minute=30),
'relative': True,
},
'delete_inactive_users': {
'task': 'openwisp_radius.tasks.delete_inactive_users',
'schedule': crontab(hour=1, minute=50),
'relative': True,
},
}

SENDSMS_BACKEND = 'sendsms.backends.console.SmsBackend'
Expand Down

0 comments on commit 7cf4490

Please sign in to comment.