Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
46f8a58
[feature] Made RegisteredUser model support multi-tenancy #692
pandafy Mar 30, 2026
7010cfc
[fix] Fixed migrations
pandafy Apr 13, 2026
d83228d
[qa] Fixed QA issues
pandafy Apr 13, 2026
c88a132
[ci] Upgraded openwisp-users
pandafy Apr 13, 2026
1dd7a39
[ci] Fixed failures
pandafy Apr 14, 2026
24d39f9
[fix] Fixed migrations for sample app
pandafy Apr 14, 2026
55580f9
[fix] Fixes by @coderabbitai
pandafy Apr 15, 2026
bbcdd72
[tests] Added tests
pandafy Apr 17, 2026
4cc1dc6
[feature] Added REST API endpoint for the user to update registration…
pandafy Apr 17, 2026
7e76f86
[fix] Fixes by @coderabbitai
pandafy Apr 20, 2026
cbef5fa
[fix] Fixed choices in UpgradeRegisteredUserSerializer
pandafy Apr 21, 2026
6e2fb88
[fix] Fixed tests
pandafy Apr 22, 2026
1dc6655
[tests] Fixed tests
pandafy Apr 22, 2026
e1ff16e
[fix] Fixed ValidatePhoneTokenView
pandafy Apr 23, 2026
a418450
[fix] Fixes by @coderabbitai
pandafy Apr 24, 2026
6fcec64
[fix] Fixes by @coderabbitai
pandafy Apr 24, 2026
7761257
[fix] Fixes by @coderabbitai
pandafy Apr 24, 2026
d52deff
[fix] Made requested changes
pandafy Apr 27, 2026
104a7cd
[tests] Improved tests for migration
pandafy Apr 29, 2026
31132cc
[fix] Made requested changes
pandafy May 4, 2026
b83ca76
[fix] Fixes QA issues
pandafy May 4, 2026
554eccf
[fix] Fixed test
pandafy May 4, 2026
b61ea07
[fix] Fixed tests
pandafy May 5, 2026
c1de2d7
[fix] Removed global RegisteredUser object
pandafy May 7, 2026
08a7bdf
[fix] Fixed tests
pandafy May 7, 2026
bfcf393
[fix] Fixed tests
pandafy May 7, 2026
f5c6d92
[fix] Fixed migrations
pandafy May 8, 2026
b3f99ba
[fix] Added autocompleted organization field in RegisteredUserinline
pandafy May 8, 2026
93d39da
[ci] Removed openwisp-users override
pandafy May 12, 2026
494c9fc
[fix] Fixes by @coderabbitai
pandafy May 12, 2026
1322a9f
[fix] Made requested changes
pandafy May 13, 2026
31a6740
[fix] Made PhoneToken multi-tenant
pandafy May 13, 2026
61e05ba
[fix] Fixed QA issues
pandafy May 13, 2026
2b741c4
[fix] Fixed migration tests
pandafy May 13, 2026
f63a522
[fix] Fixed security issues
pandafy May 14, 2026
8dc28c3
Merge branch 'master' into issues/692-different-identity-verification
pandafy May 14, 2026
870ad8f
[fix] Fixed bugs
pandafy May 14, 2026
aad1704
Merge branch 'master' into issues/692-different-identity-verification
nemesifier May 16, 2026
9d382b4
[chores] Made sure migration tests are extensible + minor improvements
nemesifier May 16, 2026
7d9601d
[fix] Admin filters, monitoring metrics and email address validation
pandafy May 18, 2026
46e5267
[feature] Add user-settable registration methods and validation
pandafy May 18, 2026
06687cf
[fix] Update registration method choices initialization in serializers
pandafy May 18, 2026
938b852
[fix] Fixed QA issues
pandafy May 18, 2026
fd60fba
[fix] Fixed user-settable registration methods validation and checks
pandafy May 19, 2026
cff9fa4
[fix] Fixed QA issues
pandafy May 19, 2026
fea912f
[fix] Add "bank_card" method to user settable methods
pandafy May 19, 2026
ec73d05
Merge remote-tracking branch 'origin/master' into issues/692-differen…
pandafy May 20, 2026
a031f38
[fix] Fixed default for USER_SETTABLE_REGISTRATION_METHODS
pandafy May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/user/management_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ Following is an example:

./manage.py delete_unverified_users --older-than-days 1 --exclude-methods mobile_phone,email

If a user has multiple ``RegisteredUser`` rows across organizations, the
command keeps that user when **any** related row uses one of the excluded
methods.

``upgrade_from_django_freeradius``
----------------------------------

Expand Down
42 changes: 42 additions & 0 deletions docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,48 @@ Param Description
phone_number string
============ ===========

Update user registration method
+++++++++++++++++++++++++++++++

**Requires the user auth token (Bearer Token)**.

Allows users to update their registered user method for an organization.
The method can only be updated when it is currently set to
``pending_verification``. Once updated, it cannot be changed again via
this endpoint.

This endpoint is used during cross-organization login when a user
authenticates to a new organization. The user must complete verification
for that organization before they can create account with the new
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This describes the flow inaccurately. The implementation already creates the OrganizationUser and a pending RegisteredUser row during cross organization login when registration is enabled; access remains denied until org specific verification is completed. Please reword this to avoid saying the account is created only after verification.

organization.

.. code-block:: text

/api/v1/radius/organization/<organization-slug>/account/registration-method/

Responds only to **POST**.

Parameters:

====== ===========
Param Description
====== ===========
method string (\*)
====== ===========

(\*) ``method`` must be one of the available
:ref:`registration/verification methods
<openwisp_radius_needs_identity_verification>`, excluding
``pending_verification``.

**Success Response (200 OK)**:

.. code-block:: json

{
"method": "mobile_phone"
}

.. _radius_batch_user_creation:

Batch user creation
Expand Down
30 changes: 30 additions & 0 deletions docs/user/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,9 @@ verification method. The following choices are available by default:
- ``mobile_phone``: Mobile phone number :ref:`verification via SMS
<openwisp_radius_sms_verification_enabled>`
- ``social_login``: :doc:`social login feature <social_login>`
- ``pending_verification``: Transitional state used when a user
authenticates to a new organization but has not yet completed
verification for that organization.

.. note::

Expand All @@ -714,6 +717,33 @@ verification method. The following choices are available by default:
**Disclaimer:** these are just suggestions on possible configurations
of OpenWISP RADIUS and must not be considered as legal advice.

``OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Default**: ``["", "email", "mobile_phone"]``

Defines which ``RegisteredUser.method`` values can be written by users
through the public registration APIs.

Methods not included in this setting cannot be selected by users through
those APIs, even if they are present in the full list returned by
``get_registration_choices()``.

This is especially useful to keep server-assigned provenance methods such
as ``saml``, ``social_login`` or ``manual`` out of user-controlled API
input. These methods may still be assigned internally by server-side
authentication or integration flows when appropriate.

Example:

.. code-block:: python

OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS = [
"",
"email",
"mobile_phone",
]

.. _openwisp_radius_register_registration_method:

Adding support for more registration/verification methods
Expand Down
60 changes: 55 additions & 5 deletions openwisp_radius/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from django.contrib.admin.utils import model_ngettext
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.db.models import Prefetch
from django.forms.models import BaseInlineFormSet
from django.http import HttpResponseRedirect
from django.templatetags.static import static
from django.urls import reverse
Expand Down Expand Up @@ -534,11 +536,31 @@ def has_change_permission(self, request, obj=None):
return False


class RegisteredUserFormset(BaseInlineFormSet):
def get_unique_error_message(self, unique_check):
# Django inline formsets perform their own uniqueness validation
# (BaseModelFormSet.validate_unique) *before* model-level validation runs.
# Because of this, the custom `violation_error_message` defined on
# `UniqueConstraint` is never surfaced in the admin UI.
#
# Overriding this method allows us to replace Django’s generic
# "Please correct the duplicate data for <field>." message with a
# domain-specific, user-friendly error that matches our constraint.
if unique_check == ("user", "organization"):
return _(
"A user cannot have more than one registration record in the"
" same organization."
)


class RegisteredUserInline(StackedInline):
model = RegisteredUser
form = AlwaysHasChangedForm
formset = RegisteredUserFormset
extra = 0
readonly_fields = ("modified",)
fields = ("organization", "method", "is_verified", "modified")
autocomplete_fields = ("organization",)

def has_delete_permission(self, request, obj=None):
return False
Expand All @@ -549,22 +571,50 @@ def has_delete_permission(self, request, obj=None):
RadiusUserGroupInline,
PhoneTokenInline,
]
UserAdmin.list_filter += (RegisteredUserFilter, "registered_user__method")
UserAdmin.list_filter += (RegisteredUserFilter, "registered_users__method")
user_admin_get_queryset = UserAdmin.get_queryset


def get_queryset(self, request):
queryset = user_admin_get_queryset(self, request)
registered_users = RegisteredUser.objects.only(
"user_id", "organization_id", "is_verified"
)
if not request.user.is_superuser:
registered_users = registered_users.filter(
organization__in=request.user.organizations_managed
)
return queryset.prefetch_related(
Prefetch(
"registered_users",
queryset=registered_users,
to_attr="prefetched_registered_users",
)
)


def get_is_verified(self, obj):
try:
value = "yes" if obj.registered_user.is_verified else "no"
except Exception:
prefetched_registered_users = getattr(obj, "prefetched_registered_users", None)
if prefetched_registered_users is not None:
is_verifieds = [
reg_user.is_verified for reg_user in prefetched_registered_users
]
else:
is_verifieds = []
if not is_verifieds:
value = "unknown"
elif any(is_verifieds):
value = "yes"
else:
value = "no"
icon_url = static(f"admin/img/icon-{value}.svg")
return mark_safe(f'<img src="{icon_url}" alt="{value}">')


UserAdmin.get_queryset = get_queryset
UserAdmin.get_is_verified = get_is_verified
UserAdmin.get_is_verified.short_description = _("Verified")
UserAdmin.list_display.insert(3, "get_is_verified")
UserAdmin.list_select_related = ("registered_user",)


class OrganizationRadiusSettingsInline(admin.StackedInline):
Expand Down
19 changes: 11 additions & 8 deletions openwisp_radius/api/freeradius_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@

RadiusToken = load_model("RadiusToken")
RadiusAccounting = load_model("RadiusAccounting")
RegisteredUser = load_model("RegisteredUser")
OrganizationRadiusSettings = load_model("OrganizationRadiusSettings")
OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser")
Organization = swapper.load_model("openwisp_users", "Organization")
Expand Down Expand Up @@ -290,7 +291,7 @@ def get_user(self, request, username, password):
"""
conditions = self._get_user_query_conditions(request)
try:
user = auth_backend.get_users(username).filter(conditions)[0]
user = auth_backend.get_users(username).filter(conditions).distinct()[0]
except IndexError:
return None
# ensure user is member of the authenticated org
Expand Down Expand Up @@ -409,19 +410,21 @@ def _get_user_query_conditions(self, request):
# just ensure user is active
if not needs_verification:
return is_active
# if identity verification is enabled
is_verified = Q(registered_user__is_verified=True)
organization_id = request._auth
registered_user = Q(registered_users__organization_id=organization_id)
is_verified = Q(registered_users__is_verified=True)
AUTHORIZE_UNVERIFIED = registration.AUTHORIZE_UNVERIFIED
# and no method should authorize unverified users
# ensure user is active AND verified
if not AUTHORIZE_UNVERIFIED:
return is_active & is_verified
return is_active & registered_user & is_verified
# in case some methods are allowed to authorize unverified users
# ensure user is active AND
# (user is verified OR user uses one of these methods)
else:
authorize_unverified = Q(registered_user__method__in=AUTHORIZE_UNVERIFIED)
return is_active & (is_verified | authorize_unverified)
return (
is_active
& registered_user
& (is_verified | Q(registered_users__method__in=AUTHORIZE_UNVERIFIED))
)

def authenticate_user(self, request, user, password):
"""
Expand Down
98 changes: 87 additions & 11 deletions openwisp_radius/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
from .. import settings as app_settings
from ..base.forms import PasswordResetForm
from ..counters.exceptions import SkipCheck
from ..registration import REGISTRATION_METHOD_CHOICES
from ..utils import (
get_group_checks,
get_organization_radius_settings,
Expand Down Expand Up @@ -571,9 +570,13 @@ class RegisterSerializer(
'verification in its "Organization RADIUS Settings."'
),
default="",
choices=REGISTRATION_METHOD_CHOICES,
choices=(),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS

def validate_phone_number(self, phone_number):
org = self.context["view"].organization
if get_organization_radius_settings(org, "sms_verification"):
Expand Down Expand Up @@ -688,9 +691,11 @@ def save(self, request):
# the custom_signup method contains the openwisp specific logic
self.custom_signup(request, user)
# create a RegisteredUser object for every user that registers through API
RegisteredUser.objects.create(
org = self.context["view"].organization
RegisteredUser.get_or_create_for_user_and_org(
user=user,
method=self.validated_data["method"],
organization=org,
defaults={"method": self.validated_data["method"]},
)
setup_user_email(request, user, [])
return user
Expand Down Expand Up @@ -753,20 +758,64 @@ def save(self):
# yet, tha will be done by the phone token validation view
# once the phone number has been validated
# at this point we flag the user as unverified again
self.user.registered_user.is_verified = False
self.user.registered_user.save()
org = self.context["view"].organization
reg_user, _ = RegisteredUser.get_or_create_for_user_and_org(
user=self.user,
organization=org,
defaults={"is_verified": False, "method": ""},
)
reg_user.is_verified = False
reg_user.save()


class UpdateRegisteredUserMethodSerializer(ValidatedModelSerializer):
method = serializers.ChoiceField(
choices=app_settings.USER_SETTABLE_REGISTRATION_METHODS,
help_text=_(
"The registration method to set for the user. "
"Cannot be 'pending_verification'."
),
)

class Meta:
model = RegisteredUser
fields = ["method"]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS

def validate_method(self, value):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think we can blindly trust all registered method values here. Some values are not normal user choices: they represent server side flows which should set the method only after that flow actually happened. Practical examples are saml and social_login.

Right now, could a user submit {"method": "saml"} or {"method": "social_login"} here? If yes, the DB would say the user registered with SAML or social login even though neither flow happened. Maybe this does not lead to an immediate bypass today, but it is still unsafe to let users write values which other parts of the system may treat as trusted registration state.

Please let me know what you think. If you think it cannot be abused, explain why. Otherwise let's discuss the right way to restrict this endpoint before changing it, because a quick allowlist/denylist could also be wrong if we do not define the intended flow clearly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nemesifier the RegisterSerializer already accepts a method field

method = serializers.ChoiceField(
help_text=_(
"Required only when the organization has mandatory identity "
'verification in its "Organization RADIUS Settings."'
),
default="",
choices=REGISTRATION_METHOD_CHOICES,
)

So the tightening of the allowed registration choices should also apply there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. We can add another setting which specifies with registration methods are self initiated.

if value == "pending_verification":
raise serializers.ValidationError(
_("'pending_verification' cannot be set as a registration method.")
)
return value

def validate(self, attrs):
if self.instance.method != "pending_verification":
raise serializers.ValidationError(
{
"method": _(
"Method can only be updated from pending verification state."
)
}
)
return attrs

def update(self, instance, validated_data):
instance.method = validated_data["method"]
instance.save()
return instance


class RadiusUserSerializer(serializers.ModelSerializer):
"""
Used to return information about the logged in user
"""

is_verified = serializers.BooleanField(source="registered_user.is_verified")
method = serializers.CharField(
source="registered_user.method",
allow_null=True,
)
is_verified = serializers.SerializerMethodField()
method = serializers.SerializerMethodField()
password_expired = serializers.BooleanField(source="has_password_expired")
radius_user_token = serializers.CharField(source="radius_token.key", default=None)

Expand All @@ -786,3 +835,30 @@ class Meta:
"password_expired",
"radius_user_token",
]

def _get_registered_user(self, obj):
if not hasattr(self, "_registered_user_cache"):
self._registered_user_cache = {}
if obj.pk not in self._registered_user_cache:
view = self.context.get("view")
organization = getattr(view, "organization", None)
reg_user = None
# We iterate over .all() instead of using .filter() because callers
# of this serializer (e.g. validate_auth_token) prefetch
# "registered_users" via prefetch_related. Using .all() hits the
# in-memory prefetch cache (0 DB queries), whereas .filter() would
# bypass the cache and issue a new query every time.
for ru in obj.registered_users.all():
if organization and ru.organization_id == organization.pk:
reg_user = ru
break
self._registered_user_cache[obj.pk] = reg_user
return self._registered_user_cache[obj.pk]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def get_is_verified(self, obj):
reg_user = self._get_registered_user(obj)
return reg_user.is_verified if reg_user else None

def get_method(self, obj):
reg_user = self._get_registered_user(obj)
return reg_user.method if reg_user else None
5 changes: 5 additions & 0 deletions openwisp_radius/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ def get_api_urls(api_views=None):
api_views.change_phone_number,
name="phone_number_change",
),
path(
"radius/organization/<slug:slug>/account/registration-method/",
api_views.update_registered_user_registration_method,
name="update_registered_user_registration_method",
),
path("radius/batch/", api_views.batch, name="batch"),
path(
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",
Expand Down
Loading
Loading