From 301bd76a358cdc5c2e594e3d845048e2805e089a Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Mon, 21 Sep 2026 15:14:02 -0300 Subject: [PATCH 1/4] [fix] Corrected password expiration dates and duplicate notifications --- openwisp_users/base/models.py | 4 +- openwisp_users/tasks.py | 7 ++- openwisp_users/tests/test_models.py | 91 ++++++++++++++++++++++++++--- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index ef5a0dec..3f5c9662 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -129,7 +129,7 @@ def _get_pk(obj): return str(pk) def set_password(self, *args, **kwargs): - self.password_updated = timezone.now().date() + self.password_updated = localdate() return super().set_password(*args, **kwargs) def has_password_expired(self): @@ -145,7 +145,7 @@ def has_password_expired(self): ) else: return False - return expiry_date < timezone.now().date() + return expiry_date < localdate() def is_member(self, organization): return self._get_pk(organization) in self.organizations_dict diff --git a/openwisp_users/tasks.py b/openwisp_users/tasks.py index a29d0645..02e9a7c9 100644 --- a/openwisp_users/tasks.py +++ b/openwisp_users/tasks.py @@ -6,7 +6,7 @@ from django.template.loader import render_to_string from django.urls import reverse from django.utils import translation -from django.utils.timezone import now, timedelta +from django.utils.timezone import localdate, timedelta from django.utils.translation import gettext_lazy as _ from swapper import load_model @@ -30,7 +30,7 @@ def password_expiration_email(): ): # The password expiration feature is not enabled return - expiry_date = now().date() + timedelta(days=7) + expiry_date = localdate() + timedelta(days=7) query = Q() if app_settings.USER_PASSWORD_EXPIRATION: query |= Q( @@ -54,6 +54,9 @@ def password_expiration_email(): emailaddress__verified=True, ) .filter(query) + # a user can own more than one verified email address: + # without distinct() the join would send one email per address + .distinct() ) email_count = 0 for user in qs.iterator(): diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 5c02b3c6..1dfe5bff 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -416,7 +416,7 @@ def test_has_password_expired(self): # User.objects.create_user does not call User.set_password. # Therefore, we set the password_updated field manually. - User.objects.update(password_updated=now().date()) + User.objects.update(password_updated=localdate()) staff_user.refresh_from_db() end_user.refresh_from_db() @@ -437,7 +437,7 @@ def test_has_password_expired(self): self.assertEqual(end_user.has_password_expired(), False) with self.subTest("Test password is expired"): - User.objects.update(password_updated=now().date() - timedelta(days=180)) + User.objects.update(password_updated=localdate() - timedelta(days=180)) staff_user.refresh_from_db() end_user.refresh_from_db() with ( @@ -459,10 +459,10 @@ def test_has_password_expired(self): @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) @patch.object(app_settings, "STAFF_USER_PASSWORD_EXPIRATION", 90) def test_password_expiration_mail(self): - user_expiry_date = now().date() - timedelta( + user_expiry_date = localdate() - timedelta( days=(app_settings.USER_PASSWORD_EXPIRATION - 7) ) - staff_user_expiry_date = now().date() - timedelta( + staff_user_expiry_date = localdate() - timedelta( days=(app_settings.STAFF_USER_PASSWORD_EXPIRATION - 7) ) staff_user = self._create_operator() @@ -498,7 +498,7 @@ def test_password_expiration_mail(self): password_updated=user_expiry_date ) password_expiration_email.delay() - expected_date = localize((now() + timedelta(days=7)).date()) + expected_date = localize(localdate() + timedelta(days=7)) self.assertEqual(len(mail.outbox), 1) email = mail.outbox.pop() self.assertEqual(email.to, [verified_email_user.email]) @@ -525,7 +525,7 @@ def test_password_expiration_mail(self): @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) @patch("openwisp_users.utils.sleep") def test_password_expiration_mail_sleep(self, mocked_sleep): - user_expiry_date = now().date() - timedelta( + user_expiry_date = localdate() - timedelta( days=(app_settings.USER_PASSWORD_EXPIRATION - 7) ) for i in range(10): @@ -547,7 +547,7 @@ def test_password_expiration_mail_settings_disabled(self): self.assertEqual(app_settings.USER_PASSWORD_EXPIRATION, 0) self.assertEqual(app_settings.STAFF_USER_PASSWORD_EXPIRATION, 0) self._create_user() - User.objects.update(password_updated=now().date() - timedelta(days=180)) + User.objects.update(password_updated=localdate() - timedelta(days=180)) with patch("openwisp_users.tasks.send_email") as mocked_send_email: password_expiration_email.delay() mocked_send_email.assert_not_called() @@ -1195,3 +1195,80 @@ def test_expiration_reminder_email_recipient_selection(self): expiration_reminder_email, None, ) + + @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) + def test_password_expiration_mail_multiple_verified_emails(self): + user = self._create_user() + for index in range(2): + EmailAddress.objects.create( + user=user, + email=f"alias{index}@tester.com", + verified=True, + ) + User.objects.update( + password_updated=localdate() + - timedelta(days=app_settings.USER_PASSWORD_EXPIRATION - 7) + ) + password_expiration_email.delay() + self.assertEqual( + len(mail.outbox), + 1, + msg=( + f"Expected 1 password expiry notice, got {len(mail.outbox)}:" + " the notice is duplicated once per verified email address" + " of the same user" + ), + ) + self.assertEqual(mail.outbox[0].to, [user.email]) + + @freeze_time("2026-03-29 23:30:00") + @override_settings(TIME_ZONE="Europe/Rome") + @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) + def test_password_expiration_local_date(self): + # 23:30 UTC is already the next day in Europe/Rome (UTC+2), + # the password expiration dates must follow the local calendar day + user = self._create_user() + + with self.subTest("password_updated is stamped with the local date"): + user.set_password("tester") + self.assertEqual( + user.password_updated, + localdate(), + msg=( + f"Expected password_updated {localdate()} (local date)," + f" got {user.password_updated} (UTC date)" + ), + ) + + with self.subTest("expiration is evaluated against the local date"): + expiration = app_settings.USER_PASSWORD_EXPIRATION + user.password_updated = localdate() - timedelta(days=expiration + 1) + self.assertTrue( + user.has_password_expired(), + msg=( + "Expected the password to be expired: it expired on" + f" {user.password_updated + timedelta(days=expiration)}," + f" while the local date is {localdate()}" + ), + ) + + @freeze_time("2026-03-29 23:30:00") + @override_settings(TIME_ZONE="Europe/Rome") + @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) + def test_password_expiration_mail_local_date(self): + self._create_user() + User.objects.update( + password_updated=localdate() + - timedelta(days=app_settings.USER_PASSWORD_EXPIRATION - 7) + ) + password_expiration_email.delay() + self.assertEqual( + len(mail.outbox), + 1, + msg=( + f"Expected 1 password expiry notice, got {len(mail.outbox)}:" + " the task looks for passwords expiring 7 days after the UTC" + f" date ({now().date()}) instead of the local date" + f" ({localdate()})" + ), + ) From 62c067f64a9ccba57a9d33e97f5b6561584eb98a Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Mon, 21 Sep 2026 15:38:43 -0300 Subject: [PATCH 2/4] [chores] Addressed review feedback --- openwisp_users/tests/test_models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 1dfe5bff..b8300861 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -1240,8 +1240,16 @@ def test_password_expiration_local_date(self): ), ) - with self.subTest("expiration is evaluated against the local date"): - expiration = app_settings.USER_PASSWORD_EXPIRATION + expiration = app_settings.USER_PASSWORD_EXPIRATION + + with self.subTest("password is valid on the expiration date"): + user.password_updated = localdate() - timedelta(days=expiration) + self.assertFalse( + user.has_password_expired(), + msg="Expected password to remain valid on its expiration date", + ) + + with self.subTest("password is expired after the expiration date"): user.password_updated = localdate() - timedelta(days=expiration + 1) self.assertTrue( user.has_password_expired(), From 03e8f6c6bb36dcc17889380d86cc35acca7e1253 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Mon, 21 Sep 2026 15:45:17 -0300 Subject: [PATCH 3/4] [chores] Addressed review feedback --- docs/user/account-and-password-expiration.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/user/account-and-password-expiration.rst b/docs/user/account-and-password-expiration.rst index 27c81785..970d273e 100644 --- a/docs/user/account-and-password-expiration.rst +++ b/docs/user/account-and-password-expiration.rst @@ -61,6 +61,12 @@ How password expiration works When password expiration is enabled, OpenWISP Users checks the age of each user's password and notifies users before their password expires. +Password updates, expiration checks, and expiration notices use the active +local calendar date. A password remains valid on its expiration date and +expires the following day. Each eligible user receives at most one +expiration notice per task run, even if the user has multiple verified +email addresses. + Password expiration can be configured separately for regular users and staff users: From a2a966d7b50b0003e7a66785b56e9c68d28a7f57 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Mon, 21 Sep 2026 17:04:35 -0300 Subject: [PATCH 4/4] [chores] Addressed review feedback --- docs/user/account-and-password-expiration.rst | 6 ------ openwisp_users/tests/test_models.py | 6 ++++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/user/account-and-password-expiration.rst b/docs/user/account-and-password-expiration.rst index 970d273e..27c81785 100644 --- a/docs/user/account-and-password-expiration.rst +++ b/docs/user/account-and-password-expiration.rst @@ -61,12 +61,6 @@ How password expiration works When password expiration is enabled, OpenWISP Users checks the age of each user's password and notifies users before their password expires. -Password updates, expiration checks, and expiration notices use the active -local calendar date. A password remains valid on its expiration date and -expires the following day. Each eligible user receives at most one -expiration notice per task run, even if the user has multiple verified -email addresses. - Password expiration can be configured separately for regular users and staff users: diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index b8300861..628e19ed 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -1225,8 +1225,10 @@ def test_password_expiration_mail_multiple_verified_emails(self): @override_settings(TIME_ZONE="Europe/Rome") @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) def test_password_expiration_local_date(self): - # 23:30 UTC is already the next day in Europe/Rome (UTC+2), - # the password expiration dates must follow the local calendar day + """ + 23:30 UTC is already the next day in Europe/Rome (UTC+2), so password + expiration dates must follow the local calendar day. + """ user = self._create_user() with self.subTest("password_updated is stamped with the local date"):