diff --git a/docs/images/organization_cross_login.png b/docs/images/organization_cross_login.png new file mode 100644 index 00000000..88ee4070 Binary files /dev/null and b/docs/images/organization_cross_login.png differ diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 79528090..369fdd5a 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -513,6 +513,14 @@ also get membership of the new organization only if the organization has :ref:`user registration enabled `. +.. note:: + + This behavior can be disabled globally by setting + :ref:`OPENWISP_RADIUS_CROSS_ORGANIZATION_LOGIN_ENABLED + ` to ``False`` in + your Django settings. When disabled, users cannot register to multiple + organizations via the login endpoint. + .. _radius_reset_password: Reset password @@ -622,10 +630,11 @@ recognize these users and trigger the appropriate response needed (e.g.: reject them or initiate account verification). If an existing user account tries to authenticate to an organization of -which they're not member of, then they would be automatically added as -members (if registration is enabled for that org). Please refer to -:ref:`"Registering to Multiple Organizations" -`. +which they're not a member, they will be automatically added as members +only if the organization has both registration and cross-organization +login enabled. Please refer to :ref:`"Registering to Multiple +Organizations" ` for more +information. This endpoint updates the user language preference field according to the ``Accept-Language`` HTTP header. diff --git a/docs/user/settings.rst b/docs/user/settings.rst index 4df3d1c2..d76f7b04 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -628,6 +628,37 @@ screenshot below. otherwise, if all the organization use the same configuration, we recommend changing the global setting. +.. _openwisp_radius_cross_organization_login_enabled: + +``OPENWISP_RADIUS_CROSS_ORGANIZATION_LOGIN_ENABLED`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Default**: ``True`` + +This setting controls whether a user who is registered in one organization +can also access other organizations. + +When enabled, a user who already has an account in one organization can +log in to another organization using the :ref:`login endpoint +` without completing an additional +registration. During the login process, the user is automatically added to +the new organization **only if registration is enabled** for that organization. + +When disabled (``False``), users can log in **only** to organizations they +are already registered with. Logging in to a different organization is not +allowed, even if that organization permits new user registrations. + +**This setting can be overridden in individual organizations via the admin +interface**, by going to *Organizations* then edit a specific organization +and scroll down to *"Organization RADIUS settings"*, as shown in the +screenshot below. + +.. image:: ../images/organization_cross_login.png + :alt: cross-organization login setting + +See :ref:`Registering to Multiple Organizations +` for more information. + .. _openwisp_radius_sms_verification_enabled: ``OPENWISP_RADIUS_SMS_VERIFICATION_ENABLED`` diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index ebd816de..2d2b9023 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -579,6 +579,7 @@ class OrganizationRadiusSettingsInline(admin.StackedInline): "freeradius_allowed_hosts", "coa_enabled", "registration_enabled", + "cross_organization_login_enabled", "saml_registration_enabled", "social_registration_enabled", "mac_addr_roaming_enabled", diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 5ce5fcc1..c66fc65c 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -328,6 +328,8 @@ def get_user(self, serializer, *args, **kwargs): def validate_membership(self, user): if not (user.is_superuser or user.is_member(self.organization)): if get_organization_radius_settings( + self.organization, "cross_organization_login_enabled" + ) and get_organization_radius_settings( self.organization, "registration_enabled" ): if self._needs_identity_verification( diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index ee43be11..2364aa49 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1293,6 +1293,15 @@ class AbstractOrganizationRadiusSettings(UUIDModel): help_text=_REGISTRATION_ENABLED_HELP_TEXT, fallback=app_settings.REGISTRATION_API_ENABLED, ) + cross_organization_login_enabled = FallbackBooleanChoiceField( + help_text=_( + "Allow users registered in a different organization to log in to" + " this organization without performing an additional registration." + ), + verbose_name=_("Cross-organization login enabled"), + fallback=app_settings.CROSS_ORGANIZATION_LOGIN_ENABLED, + ) + saml_registration_enabled = FallbackBooleanChoiceField( help_text=_SAML_REGISTRATION_ENABLED_HELP_TEXT, verbose_name=_("SAML registration enabled"), diff --git a/openwisp_radius/migrations/0038_clean_fallbackfields.py b/openwisp_radius/migrations/0038_clean_fallbackfields.py index bd86d17c..569c3312 100644 --- a/openwisp_radius/migrations/0038_clean_fallbackfields.py +++ b/openwisp_radius/migrations/0038_clean_fallbackfields.py @@ -2,8 +2,6 @@ from openwisp_utils.fields import FallbackMixin -from ..utils import load_model - def clean_fallback_fields(apps, schema_editor): """ @@ -15,7 +13,9 @@ def clean_fallback_fields(apps, schema_editor): is the same as the fallback value, effectively removing the unnecessary data from the database. """ - OrganizationRadiusSettings = load_model("OrganizationRadiusSettings") + OrganizationRadiusSettings = apps.get_model( + "openwisp_radius", "OrganizationRadiusSettings" + ) fallback_fields = [] fallback_field_names = [] diff --git a/openwisp_radius/migrations/0043_organizationradiussettings_cross_organization_registration_enabled.py b/openwisp_radius/migrations/0043_organizationradiussettings_cross_organization_registration_enabled.py new file mode 100644 index 00000000..300d91cc --- /dev/null +++ b/openwisp_radius/migrations/0043_organizationradiussettings_cross_organization_registration_enabled.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2026-02-03 08:05 + +from django.db import migrations + +import openwisp_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("openwisp_radius", "0042_set_existing_batches_completed"), + ] + + operations = [ + migrations.AddField( + model_name="organizationradiussettings", + name="cross_organization_login_enabled", + field=openwisp_utils.fields.FallbackBooleanChoiceField( + blank=True, + default=None, + fallback=True, + help_text="Allow users already registered in another organization to log in without registering with this organization.", + null=True, + verbose_name="Cross-organization login enabled", + ), + ), + ] diff --git a/openwisp_radius/settings.py b/openwisp_radius/settings.py index e7f908dd..1f49fb48 100644 --- a/openwisp_radius/settings.py +++ b/openwisp_radius/settings.py @@ -94,6 +94,9 @@ def get_default_password_reset_url(urls): ALLOWED_MOBILE_PREFIXES = get_settings_value("ALLOWED_MOBILE_PREFIXES", []) ALLOW_FIXED_LINE_OR_MOBILE = get_settings_value("ALLOW_FIXED_LINE_OR_MOBILE", False) REGISTRATION_API_ENABLED = get_settings_value("REGISTRATION_API_ENABLED", True) +CROSS_ORGANIZATION_LOGIN_ENABLED = get_settings_value( + "CROSS_ORGANIZATION_LOGIN_ENABLED", True +) NEEDS_IDENTITY_VERIFICATION = get_settings_value("NEEDS_IDENTITY_VERIFICATION", False) SMS_MESSAGE_TEMPLATE = get_settings_value( "SMS_MESSAGE_TEMPLATE", _("{organization} verification code: {code}") diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index 911d532f..013ceadf 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -251,6 +251,28 @@ def test_unverified_registered_user_different_organization(self): self.assertEqual(response.status_code, 200) self.assertIn("key", response.data) + @capture_any_output() + def test_user_auth_token_cross_organization_login_disabled(self): + self._get_org_user() + org2 = self._create_org(name="org2") + OrganizationRadiusSettings.objects.create( + organization=org2, cross_organization_login_enabled=False + ) + url = reverse("radius:user_auth_token", args=[org2.slug]) + response = self.client.post(url, {"username": "tester", "password": "tester"}) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.data["detail"], + f"{org2} does not allow self registration of new accounts.", + ) + # Ensure no OrganizationUser was created + self.assertEqual( + OrganizationUser.objects.filter( + organization=org2, user__username="tester" + ).count(), + 0, + ) + def test_user_auth_token_404(self): url = reverse( "radius:user_auth_token", args=["00000000-0000-0000-0000-000000000000"] diff --git a/openwisp_radius/tests/test_api/test_utils.py b/openwisp_radius/tests/test_api/test_utils.py index 4dff58f5..df424c2b 100644 --- a/openwisp_radius/tests/test_api/test_utils.py +++ b/openwisp_radius/tests/test_api/test_utils.py @@ -79,3 +79,50 @@ def test_is_sms_verification_enabled(self): str(context_manager.exception), "Could not complete operation because of an internal misconfiguration", ) + + @capture_any_output() + def test_cross_organization_login_enabled(self): + org = self._create_org() + OrganizationRadiusSettings.objects.create(organization=org) + + with self.subTest("Test cross-organization registration enabled set to True"): + org.radius_settings.cross_organization_login_enabled = True + self.assertEqual( + get_organization_radius_settings( + org, "cross_organization_login_enabled" + ), + True, + ) + + with self.subTest("Test cross-organization registration enabled set to False"): + org.radius_settings.cross_organization_login_enabled = False + self.assertEqual( + get_organization_radius_settings( + org, "cross_organization_login_enabled" + ), + False, + ) + + with self.subTest("Test cross-organization registration enabled set to None"): + org.radius_settings.cross_organization_login_enabled = None + org.radius_settings.save(update_fields=["cross_organization_login_enabled"]) + org.radius_settings.refresh_from_db( + fields=["cross_organization_login_enabled"] + ) + self.assertEqual( + get_organization_radius_settings( + org, "cross_organization_login_enabled" + ), + app_settings.CROSS_ORGANIZATION_LOGIN_ENABLED, + ) + + with self.subTest("Test related radius setting does not exist"): + org.radius_settings = None + with self.assertRaises(APIException) as context_manager: + get_organization_radius_settings( + org, "cross_organization_login_enabled" + ) + self.assertEqual( + str(context_manager.exception), + "Could not complete operation because of an internal misconfiguration", + )