Repository: inventree/InvenTree

Issue:
[FR] Enable restrictions on allowed domains for signup
### Please verify that this feature request has NOT been suggested before.

- [X] I checked and didn't find similar feature request

### Problem statement

As stated in this [discussion](https://github.com/inventree/InvenTree/discussions/2619) some users are asking for a way to easily scope signups to specific domains.

### Suggested solution

Add an optional setting with allowed email domains for singup.

### Describe alternatives you've considered

I still think that it is more secure to scope this on the auth provider site.

### Examples of other systems

getoutline, nextcloud, ...

### Do you want to develop this?

- [X] I want to develop this.


Discussion:

Resolving patch:
diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -215,9 +215,35 @@ def is_open_for_signup(self, request, *args, **kwargs):
             return super().is_open_for_signup(request, *args, **kwargs)
         return False
 
+    def clean_email(self, email):
+        """Check if the mail is valid to the pattern in LOGIN_SIGNUP_MAIL_RESTRICTION (if enabled in settings)."""
+        mail_restriction = InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', None)
+        if not mail_restriction:
+            return super().clean_email(email)
+
+        split_email = email.split('@')
+        if len(split_email) != 2:
+            logger.error(f'The user {email} has an invalid email address')
+            raise forms.ValidationError(_('The provided primary email address is not valid.'))
+
+        mailoptions = mail_restriction.split(',')
+        for option in mailoptions:
+            if not option.startswith('@'):
+                log_error('LOGIN_SIGNUP_MAIL_RESTRICTION is not configured correctly')
+                raise forms.ValidationError(_('The provided primary email address is not valid.'))
+            else:
+                if split_email[1] == option[1:]:
+                    return super().clean_email(email)
+
+        logger.info(f'The provided email domain for {email} is not approved')
+        raise forms.ValidationError(_('The provided email domain is not approved.'))
+
     def save_user(self, request, user, form, commit=True):
         """Check if a default group is set in settings."""
+        # Create the user
         user = super().save_user(request, user, form)
+
+        # Check if a default group is set in settings
         start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP')
         if start_group:
             try:
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -11,6 +11,7 @@
 import logging
 import math
 import os
+import re
 import uuid
 from datetime import datetime, timedelta
 from enum import Enum
@@ -81,19 +82,33 @@ def save(self, *args, **kwargs):
         self.clean(**kwargs)
         self.validate_unique(**kwargs)
 
+        # Execute before_save action
+        self._call_settings_function('before_save', args, kwargs)
+
         # Update this setting in the cache
         if do_cache:
             self.save_to_cache()
 
         super().save()
 
-        # Get after_save action
+        # Execute after_save action
+        self._call_settings_function('after_save', args, kwargs)
+
+    def _call_settings_function(self, reference: str, args, kwargs):
+        """Call a function associated with a particular setting.
+
+        Args:
+            reference (str): The name of the function to call
+            args: Positional arguments to pass to the function
+            kwargs: Keyword arguments to pass to the function
+        """
+        # Get action
         setting = self.get_setting_definition(self.key, *args, **kwargs)
-        after_save = setting.get('after_save', None)
+        settings_fnc = setting.get(reference, None)
 
         # Execute if callable
-        if callable(after_save):
-            after_save(self)
+        if callable(settings_fnc):
+            settings_fnc(self)
 
     @property
     def cache_key(self):
@@ -771,6 +786,19 @@ def update_instance_name(setting):
     site_obj.save()
 
 
+def validate_email_domains(setting):
+    """Validate the email domains setting."""
+    if not setting.value:
+        return
+
+    domains = setting.value.split(',')
+    for domain in domains:
+        if not domain:
+            raise ValidationError(_('An empty domain is not allowed.'))
+        if not re.match(r'^@[a-zA-Z0-9\.\-_]+$', domain):
+            raise ValidationError(_(f'Invalid domain name: {domain}'))
+
+
 class InvenTreeSetting(BaseInvenTreeSetting):
     """An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
 
@@ -1375,6 +1403,13 @@ def save(self, *args, **kwargs):
             'validator': bool,
         },
 
+        'LOGIN_SIGNUP_MAIL_RESTRICTION': {
+            'name': _('Allowed domains'),
+            'description': _('Restrict signup to certain domains (comma-separated, strarting with @)'),
+            'default': '',
+            'before_save': validate_email_domains,
+        },
+
         'SIGNUP_GROUP': {
             'name': _('Group on signup'),
             'description': _('Group to which new users are assigned on registration'),


Test patch:
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -134,6 +134,7 @@ def run_settings_check(self, key, setting):
             'units',
             'requires_restart',
             'after_save',
+            'before_save',
         ]
 
         for k in setting.keys():