Skip to content

Commit a596fdb

Browse files
committed
[fix] Added setting to disable cross-organization login
1 parent 38ee442 commit a596fdb

9 files changed

Lines changed: 157 additions & 7 deletions

File tree

docs/user/rest-api.rst

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,13 @@ also get membership of the new organization only if the organization has
513513
:ref:`user registration enabled
514514
<openwisp_radius_registration_api_enabled>`.
515515

516+
.. note::
517+
518+
This behavior can be disabled globally by setting
519+
:ref:`OPENWISP_RADIUS_CROSS_ORGANIZATION_LOGIN_ENABLED <openwisp_radius_cross_organization_login_enabled>` to ``False``
520+
in your Django settings. When disabled, users cannot register to multiple
521+
organizations via the login endpoint.
522+
516523
.. _radius_reset_password:
517524

518525
Reset password
@@ -622,10 +629,10 @@ recognize these users and trigger the appropriate response needed (e.g.:
622629
reject them or initiate account verification).
623630

624631
If an existing user account tries to authenticate to an organization of
625-
which they're not member of, then they would be automatically added as
626-
members (if registration is enabled for that org). Please refer to
627-
:ref:`"Registering to Multiple Organizations"
628-
<radius_registering_to_multiple_organizations>`.
632+
which they're not a member, they will be automatically added as members
633+
only if the organization has both registration and cross organization login enabled.
634+
Please refer to :ref:`"Registering to Multiple Organizations"
635+
<radius_registering_to_multiple_organizations>` for more information.
629636

630637
This endpoint updates the user language preference field according to the
631638
``Accept-Language`` HTTP header.

docs/user/settings.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,37 @@ screenshot below.
628628
otherwise, if all the organization use the same configuration, we
629629
recommend changing the global setting.
630630

631+
.. _openwisp_radius_cross_organization_login_enabled:
632+
633+
``OPENWISP_RADIUS_CROSS_ORGANIZATION_LOGIN_ENABLED``
634+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
635+
636+
**Default**: ``True``
637+
638+
This setting controls whether a user who is registered in one organization
639+
can also access other organizations.
640+
641+
When enabled, a user who already has an account in one organization can log
642+
in to another organization using the
643+
:ref:`login endpoint <radius_login_obtain_user_auth_token>` without completing
644+
an additional registration. During the login process, the user is
645+
automatically added to the new organization.
646+
647+
When disabled (``False``), users can log in **only** to organizations they are
648+
already registered with. Logging in to a different organization is not
649+
allowed, even if that organization permits new user registrations.
650+
651+
**This setting can be overridden in individual organizations via the admin
652+
interface**, by going to *Organizations* then edit a specific organization
653+
and scroll down to *"Organization RADIUS settings"*, as shown in the
654+
screenshot below.
655+
656+
.. image:: ../images/organization_cross_registration.png
657+
:alt: Organization RADIUS settings
658+
659+
See :ref:`Registering to Multiple Organizations <radius_registering_to_multiple_organizations>`
660+
for more information.
661+
631662
.. _openwisp_radius_sms_verification_enabled:
632663

633664
``OPENWISP_RADIUS_SMS_VERIFICATION_ENABLED``

openwisp_radius/api/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,8 @@ def get_user(self, serializer, *args, **kwargs):
319319
def validate_membership(self, user):
320320
if not (user.is_superuser or user.is_member(self.organization)):
321321
if get_organization_radius_settings(
322+
self.organization, "cross_organization_login_enabled"
323+
) and get_organization_radius_settings(
322324
self.organization, "registration_enabled"
323325
):
324326
if self._needs_identity_verification(

openwisp_radius/base/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,15 @@ class AbstractOrganizationRadiusSettings(UUIDModel):
12911291
help_text=_REGISTRATION_ENABLED_HELP_TEXT,
12921292
fallback=app_settings.REGISTRATION_API_ENABLED,
12931293
)
1294+
cross_organization_login_enabled = FallbackBooleanChoiceField(
1295+
help_text=_(
1296+
"Allow users registered in a different organization to log in to"
1297+
" this organization without performing an additional registration."
1298+
),
1299+
verbose_name=_("Cross-organization registration enabled"),
1300+
fallback=app_settings.CROSS_ORGANIZATION_LOGIN_ENABLED,
1301+
)
1302+
12941303
saml_registration_enabled = FallbackBooleanChoiceField(
12951304
help_text=_SAML_REGISTRATION_ENABLED_HELP_TEXT,
12961305
verbose_name=_("SAML registration enabled"),

openwisp_radius/migrations/0038_clean_fallbackfields.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from openwisp_utils.fields import FallbackMixin
44

5-
from ..utils import load_model
6-
75

86
def clean_fallback_fields(apps, schema_editor):
97
"""
@@ -15,7 +13,9 @@ def clean_fallback_fields(apps, schema_editor):
1513
is the same as the fallback value, effectively removing the
1614
unnecessary data from the database.
1715
"""
18-
OrganizationRadiusSettings = load_model("OrganizationRadiusSettings")
16+
OrganizationRadiusSettings = apps.get_model(
17+
"openwisp_radius", "OrganizationRadiusSettings"
18+
)
1919
fallback_fields = []
2020
fallback_field_names = []
2121

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 5.2.9 on 2026-02-03 08:05
2+
3+
from django.db import migrations
4+
5+
import openwisp_utils.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("openwisp_radius", "0042_set_existing_batches_completed"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="organizationradiussettings",
17+
name="cross_organization_login_enabled",
18+
field=openwisp_utils.fields.FallbackBooleanChoiceField(
19+
blank=True,
20+
default=None,
21+
fallback=True,
22+
help_text="Allow users already registered in another organization to log in without registering with this organization.",
23+
null=True,
24+
verbose_name="Cross-organization registration enabled",
25+
),
26+
),
27+
]

openwisp_radius/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ def get_default_password_reset_url(urls):
9494
ALLOWED_MOBILE_PREFIXES = get_settings_value("ALLOWED_MOBILE_PREFIXES", [])
9595
ALLOW_FIXED_LINE_OR_MOBILE = get_settings_value("ALLOW_FIXED_LINE_OR_MOBILE", False)
9696
REGISTRATION_API_ENABLED = get_settings_value("REGISTRATION_API_ENABLED", True)
97+
CROSS_ORGANIZATION_LOGIN_ENABLED = get_settings_value(
98+
"CROSS_ORGANIZATION_LOGIN_ENABLED", True
99+
)
97100
NEEDS_IDENTITY_VERIFICATION = get_settings_value("NEEDS_IDENTITY_VERIFICATION", False)
98101
SMS_MESSAGE_TEMPLATE = get_settings_value(
99102
"SMS_MESSAGE_TEMPLATE", _("{organization} verification code: {code}")

openwisp_radius/tests/test_api/test_rest_token.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,28 @@ def test_unverified_registered_user_different_organization(self):
251251
self.assertEqual(response.status_code, 200)
252252
self.assertIn("key", response.data)
253253

254+
@capture_any_output()
255+
def test_user_auth_token_cross_organization_login_disabled(self):
256+
self._get_org_user()
257+
org2 = self._create_org(name="org2")
258+
OrganizationRadiusSettings.objects.create(
259+
organization=org2, cross_organization_login_enabled=False
260+
)
261+
url = reverse("radius:user_auth_token", args=[org2.slug])
262+
response = self.client.post(url, {"username": "tester", "password": "tester"})
263+
self.assertEqual(response.status_code, 403)
264+
self.assertEqual(
265+
response.data["detail"],
266+
f"{org2} does not allow self registration of new accounts.",
267+
)
268+
# Ensure no OrganizationUser was created
269+
self.assertEqual(
270+
OrganizationUser.objects.filter(
271+
organization=org2, user__username="tester"
272+
).count(),
273+
0,
274+
)
275+
254276
def test_user_auth_token_404(self):
255277
url = reverse(
256278
"radius:user_auth_token", args=["00000000-0000-0000-0000-000000000000"]

openwisp_radius/tests/test_api/test_utils.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,52 @@ def test_is_sms_verification_enabled(self):
7979
str(context_manager.exception),
8080
"Could not complete operation because of an internal misconfiguration",
8181
)
82+
83+
@capture_any_output()
84+
def test_cross_organization_login_enabled(self):
85+
org = self._create_org()
86+
OrganizationRadiusSettings.objects.create(organization=org)
87+
88+
with self.subTest("Test cross organization registration enabled set to True"):
89+
org.radius_settings.cross_organization_login_enabled = True
90+
self.assertEqual(
91+
get_organization_radius_settings(
92+
org, "cross_organization_login_enabled"
93+
),
94+
True,
95+
)
96+
97+
with self.subTest("Test cross organization registration enabled set to False"):
98+
org.radius_settings.cross_organization_login_enabled = False
99+
self.assertEqual(
100+
get_organization_radius_settings(
101+
org, "cross_organization_login_enabled"
102+
),
103+
False,
104+
)
105+
106+
with self.subTest("Test cross organization registration enabled set to None"):
107+
org.radius_settings.cross_organization_login_enabled = None
108+
org.radius_settings.save(
109+
update_fields=["cross_organization_login_enabled"]
110+
)
111+
org.radius_settings.refresh_from_db(
112+
fields=["cross_organization_login_enabled"]
113+
)
114+
self.assertEqual(
115+
get_organization_radius_settings(
116+
org, "cross_organization_login_enabled"
117+
),
118+
app_settings.CROSS_ORGANIZATION_LOGIN_ENABLED,
119+
)
120+
121+
with self.subTest("Test related radius setting does not exist"):
122+
org.radius_settings = None
123+
with self.assertRaises(APIException) as context_manager:
124+
get_organization_radius_settings(
125+
org, "cross_organization_login_enabled"
126+
)
127+
self.assertEqual(
128+
str(context_manager.exception),
129+
"Could not complete operation because of an internal misconfiguration",
130+
)

0 commit comments

Comments
 (0)