From 270f6c6cbaa0cf98c3f21e0ffa03238b202e0917 Mon Sep 17 00:00:00 2001 From: Harsh Markam Date: Tue, 25 Aug 2026 18:22:25 +0530 Subject: [PATCH 1/4] fix: handle username collision during registration --- openwisp_radius/api/serializers.py | 14 +++++++ openwisp_radius/tests/test_api/test_api.py | 48 ++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 38b9808d..d040d21e 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -37,6 +37,7 @@ from .. import settings as app_settings from ..counters.exceptions import SkipCheck from ..utils import ( + find_available_username, get_group_checks, get_organization_radius_settings, get_user_group, @@ -593,6 +594,19 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS + def validate_username(self, username): + email = self.initial_data.get("email", "") + local_part = email.rsplit("@", 1)[0] if "@" in email else "" + + if ( + username == local_part + and User.objects.filter(username=username).exists() + and not User.objects.filter(username=username, email__iexact=email).exists() + ): + username = find_available_username(username, []) + + return super().validate_username(username) + def validate_phone_number(self, phone_number): org = self.context["view"].organization if get_organization_radius_settings(org, "sms_verification"): diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 05084276..098a8286 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -207,6 +207,54 @@ def test_register_201(self): False, ) + + def test_register_same_username_different_email(self): + self._register_user( + extra_params={ + "username": "johndae", + "email": "johndae@gmail.com", + } + ) + + self._register_user( + extra_params={ + "username": "johndae", + "email": "johndae@yahoo.com", + }, + expect_users=2, + ) + + self._register_user( + extra_params={ + "username": "johndae", + "email": "johndae@outlook.com", + }, + expect_users=3, + ) + + users = User.objects.filter( + email__in=[ + "johndae@gmail.com", + "johndae@yahoo.com", + "johndae@outlook.com", + ] + ) + + self.assertEqual(users.count(), 3) + + self.assertEqual( + users.get(email="johndae@gmail.com").username, + "johndae", + ) + self.assertEqual( + users.get(email="johndae@yahoo.com").username, + "johndae1", + ) + self.assertEqual( + users.get(email="johndae@outlook.com").username, + "johndae2", + ) + def test_register_400_password(self): response = self._register_user( extra_params={"password1": "password1", "password2": "password2"}, From c0529b540bdca7332ec07b2f1d7de285f0af4fe2 Mon Sep 17 00:00:00 2001 From: Harsh Markam Date: Wed, 26 Aug 2026 15:29:45 +0530 Subject: [PATCH 2/4] fix: address registration collision review feedback --- docs/user/rest-api.rst | 18 ++++ openwisp_radius/api/serializers.py | 21 +++-- openwisp_radius/tests/test_api/test_api.py | 97 ++++++++++++++++++++-- 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 30c6b677..a3834ee2 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -487,6 +487,24 @@ simple, but can be :ref:`enabled through configuration `; if identity verification is disabled for a particular org, an empty string will be acceptable. +.. note:: + + When the submitted ``username`` matches the local-part of the submitted + ``email`` address and that username is already used by another email + address in the same organization, the registration API automatically + selects the next available username by appending a numeric suffix. + + For example, registrations using the same email local-part can result in: + + * ``johndae@gmail.com`` → ``johndae`` + * ``johndae@yahoo.com`` → ``johndae1`` + * ``johndae@outlook.com`` → ``johndae2`` + + This does not change the behavior of cross-organization registration, + where an existing account is handled as described in + :ref:`Registering to Multiple Organizations + `. + .. _radius_registering_to_multiple_organizations: Registering to Multiple Organizations diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index d040d21e..4b414fbb 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -595,13 +595,24 @@ def __init__(self, *args, **kwargs): self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS def validate_username(self, username): - email = self.initial_data.get("email", "") - local_part = email.rsplit("@", 1)[0] if "@" in email else "" + email = self.initial_data.get("email") + local_part = ( + email.rsplit("@", 1)[0] + if isinstance(email, str) and "@" in email + else "" + ) + + organization = self.context["view"].organization + existing_username = User.objects.filter(username=username) if ( - username == local_part - and User.objects.filter(username=username).exists() - and not User.objects.filter(username=username, email__iexact=email).exists() + username.casefold() == local_part.casefold() + and existing_username.exists() + and not existing_username.filter(email__iexact=email).exists() + and OrganizationUser.objects.filter( + organization=organization, + user__username=username, + ).exists() ): username = find_available_username(username, []) diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 098a8286..87a60b03 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -207,13 +207,17 @@ def test_register_201(self): False, ) - def test_register_same_username_different_email(self): + User.objects.create( + username="johndae1", + email="occupied@example.com", + ) self._register_user( extra_params={ "username": "johndae", "email": "johndae@gmail.com", - } + }, + expect_users=2, ) self._register_user( @@ -221,7 +225,7 @@ def test_register_same_username_different_email(self): "username": "johndae", "email": "johndae@yahoo.com", }, - expect_users=2, + expect_users=3, ) self._register_user( @@ -229,7 +233,7 @@ def test_register_same_username_different_email(self): "username": "johndae", "email": "johndae@outlook.com", }, - expect_users=3, + expect_users=4, ) users = User.objects.filter( @@ -248,11 +252,11 @@ def test_register_same_username_different_email(self): ) self.assertEqual( users.get(email="johndae@yahoo.com").username, - "johndae1", + "johndae2", ) self.assertEqual( users.get(email="johndae@outlook.com").username, - "johndae2", + "johndae3", ) def test_register_400_password(self): @@ -413,6 +417,87 @@ def test_register_duplicate_different_org(self): self.default_org.radius_settings.sms_verification = False self.default_org.radius_settings.save() + def test_register_same_username_different_email_different_org(self): + org1 = self.default_org + org2 = self._get_org(org_name="org2") + + existing_user = self._create_user( + username="johndae", + email="johndae@gmail.com", + ) + OrganizationUser.objects.create( + user=existing_user, + organization=org1, + ) + + url = reverse("radius:rest_register", args=[org2.slug]) + response = self.client.post( + url, + data={ + "username": "johndae", + "email": "johndae@yahoo.com", + "password1": "password", + "password2": "password", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.data, + { + "details": "A user like the one being registered already exists.", + "organizations": [ + {"slug": org1.slug, "name": org1.name}, + ], + }, + ) + self.assertEqual( + User.objects.filter(username="johndae").count(), + 1, + ) + + def test_register_same_username_different_email_case_insensitive(self): + self._register_user( + extra_params={ + "username": "johndae", + "email": "johndae@gmail.com", + } + ) + + response = self._register_user( + extra_params={ + "username": "johndae", + "email": "JohnDae@yahoo.com", + }, + expect_users=2, + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + User.objects.get(email__iexact="JohnDae@yahoo.com").username, + "johndae1", + ) + + def test_register_invalid_email_type(self): + self._superuser_login() + + url = reverse("radius:rest_register", args=[self.default_org.slug]) + response = self.client.post( + url, + data=json.dumps( + { + "username": "invalid-email-user", + "email": None, + "password1": "password", + "password2": "password", + } + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("email", response.data) + def test_radius_user_serializer(self): self._register_user() try: From 810bd87608da7138c6e0a1da23d4f6cb5559771c Mon Sep 17 00:00:00 2001 From: Harsh Markam Date: Wed, 26 Aug 2026 16:11:33 +0530 Subject: [PATCH 3/4] style: fix black formatting --- openwisp_radius/api/serializers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 4b414fbb..de9400a0 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -597,9 +597,7 @@ def __init__(self, *args, **kwargs): def validate_username(self, username): email = self.initial_data.get("email") local_part = ( - email.rsplit("@", 1)[0] - if isinstance(email, str) and "@" in email - else "" + email.rsplit("@", 1)[0] if isinstance(email, str) and "@" in email else "" ) organization = self.context["view"].organization From cf16191b78ff22176e2bc4adb53e5f2de4f5fc4e Mon Sep 17 00:00:00 2001 From: Harsh Markam Date: Wed, 26 Aug 2026 17:15:58 +0530 Subject: [PATCH 4/4] [docs] Fix REST API formatting --- docs/user/rest-api.rst | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index a3834ee2..311d04c6 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -489,20 +489,22 @@ is disabled for a particular org, an empty string will be acceptable. .. note:: - When the submitted ``username`` matches the local-part of the submitted - ``email`` address and that username is already used by another email - address in the same organization, the registration API automatically - selects the next available username by appending a numeric suffix. + When the submitted ``username`` matches the local-part of the + submitted ``email`` address and that username is already used by + another email address in the same organization, the registration API + automatically selects the next available username by appending a + numeric suffix. - For example, registrations using the same email local-part can result in: + For example, registrations using the same email local-part can result + in: - * ``johndae@gmail.com`` → ``johndae`` - * ``johndae@yahoo.com`` → ``johndae1`` - * ``johndae@outlook.com`` → ``johndae2`` + - ``johndae@gmail.com`` → ``johndae`` + - ``johndae@yahoo.com`` → ``johndae1`` + - ``johndae@outlook.com`` → ``johndae2`` This does not change the behavior of cross-organization registration, - where an existing account is handled as described in - :ref:`Registering to Multiple Organizations + where an existing account is handled as described in :ref:`Registering + to Multiple Organizations `. .. _radius_registering_to_multiple_organizations: