Feat: SSO group sync (#7293)

* feat: Add settings for SSO group sync

* feat: Handle SSO group sync

* fix(SSO): Add default group only if it is the only one

When syncing SSO groups on first user creation,
the default group should not be added if there is
already another group synced by the IdP

* docs: Add SSO goup sync instructions

* fix: Run pre-commit hooks

* i18n(SSO): Wrap settings name and description

* docs(SSO): Fix links to allauth docs

* fix(frontend): Add SSO_GROUP_KEY option

* add unittests for SSO

* docs(SSO): Make hint for example comfiguration a tip

* docs(SSO): Describe relation between SSO sync and signup group

* fix(SSO): Avoid potential key error

* feat(SSO): Create mapped group if it does not exist

* docs(SSO): Describe how groups can be created during signup

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Philipp Fruck 2024-06-29 10:32:28 +00:00 committed by GitHub
parent b924530627
commit 60e22c50cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 273 additions and 11 deletions

View File

@ -66,7 +66,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
<li><a href="https://www.djangoproject.com/">Django</a></li> <li><a href="https://www.djangoproject.com/">Django</a></li>
<li><a href="https://www.django-rest-framework.org/">DRF</a></li> <li><a href="https://www.django-rest-framework.org/">DRF</a></li>
<li><a href="https://django-q.readthedocs.io/">Django Q</a></li> <li><a href="https://django-q.readthedocs.io/">Django Q</a></li>
<li><a href="https://django-allauth.readthedocs.io/">Django-Allauth</a></li> <li><a href="https://docs.allauth.org/">Django-Allauth</a></li>
</ul> </ul>
</details> </details>

View File

@ -4,13 +4,13 @@ title: InvenTree Single Sign On
## Single Sign On ## Single Sign On
InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://django-allauth.readthedocs.io/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html). InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://docs.allauth.org/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://docs.allauth.org/en/latest/socialaccount/providers/index.html).
!!! tip "Provider Documentation" !!! tip "Provider Documentation"
There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html)
!!! warning "Advanced Users" !!! warning "Advanced Users"
The SSO functionality provided by django-allauth is powerful, but can prove challenging to configure. Please ensure that you understand the implications of enabling SSO for your InvenTree instance. Specific technical details of each available SSO provider are beyond the scope of this documentation - please refer to the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) for more information. The SSO functionality provided by django-allauth is powerful, but can prove challenging to configure. Please ensure that you understand the implications of enabling SSO for your InvenTree instance. Specific technical details of each available SSO provider are beyond the scope of this documentation - please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
## SSO Configuration ## SSO Configuration
@ -31,8 +31,8 @@ There are two variables in the configuration file which define the operation of
| Environment Variable |Configuration File | Description | More Info | | Environment Variable |Configuration File | Description | More Info |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| INVENTREE_SOCIAL_BACKENDS | `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://django-allauth.readthedocs.io/en/latest/installation/quickstart.html) | | INVENTREE_SOCIAL_BACKENDS | `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://docs.allauth.org/en/latest/installation/quickstart.html) |
| INVENTREE_SOCIAL_PROVIDERS | `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) | | INVENTREE_SOCIAL_PROVIDERS | `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) |
In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module: In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module:
@ -44,7 +44,7 @@ In the example below, SSO provider modules are activated for *google*, *github*
Note that the provider modules specified in `social_backends` must be prefixed with `allauth.socialaccounts.providers` Note that the provider modules specified in `social_backends` must be prefixed with `allauth.socialaccounts.providers`
!!! warning "Provider Documentation" !!! warning "Provider Documentation"
We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) for more information. We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
!!! tip "Restart Server" !!! tip "Restart Server"
As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file. As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file.
@ -57,7 +57,7 @@ The next step is to create an external authentication app with your provider of
The provider application will be created as part of your SSO provider setup. This is *not* the same as the *SocialApp* entry in the InvenTree admin interface. The provider application will be created as part of your SSO provider setup. This is *not* the same as the *SocialApp* entry in the InvenTree admin interface.
!!! info "Read the Documentation" !!! info "Read the Documentation"
The [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers). The [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
In general, the external app will generate a *key* and *secret* pair - although different terminology may be used, depending on the provider. In general, the external app will generate a *key* and *secret* pair - although different terminology may be used, depending on the provider.
@ -132,6 +132,31 @@ In the [settings screen](./global.md), navigate to the *Login Settings* panel. H
Note that [email settings](./email.md) must be correctly configured before SSO will be activated. Ensure that your email setup is correctly configured and operational. Note that [email settings](./email.md) must be correctly configured before SSO will be activated. Ensure that your email setup is correctly configured and operational.
## SSO Group Sync Configuration
InvenTree has the ability to synchronize groups assigned to each user directly from the IdP. To enable this feature, navigate to the *Login Settings* panel in the [settings screen](./global.md) first. Here, the following options are available:
| Setting | Description |
| --- | --- |
| Enable SSO group sync | Enable synchronizing InvenTree groups with groups provided by the IdP |
| SSO group key | The name of the claim containing all groups, e.g. `groups` or `roles` |
| SSO group map | A mapping from SSO groups to InvenTree groups as JSON, e.g. `{"/inventree/admins": "admin"}`. If the mapped group does not exist once a user signs up, a new group without assigned permissions will be created. |
| Remove groups outside of SSO | Whether groups should be removed from the user if they are not present in the IdP data |
!!! warning "Remove groups outside of SSO"
Disabling this feature might cause security issues as groups that are removed in the IdP will stay assigned in InvenTree
### Keycloak OIDC example configuration
!!! tip "Configuration for different IdPs"
The main challenge in enabling the SSO group sync feature is for the SSO admin to configure the IdP such that the groups are correctly represented in in the Django allauth `extra_data` attribute. The SSO group sync feature has been developed and tested using integrated Keycloak users/groups and OIDC. If you are utilizing this feature using another IdP, kindly consider documenting your configuration steps as well.
Keycloak groups are not sent to the OIDC client by default. To enable such functionality, create a new client scope named `groups` in the Keycloak admin console. For this scope, add a new mapper ('By Configuration') and select 'Group Membership'. Give it a descriptive name and set the token claim name to `groups`.
For each OIDC client that relies on those group, explicitly add the `groups` scope to client scopes. The groups will now be sent to client upon request.
**Note:** A group named `foo` will be displayed as `/foo`. For this reason, the example above recommends using group names like `appname/rolename` which will be sent to the client as `/appname/rolename`.
## Security Considerations ## Security Considerations
You should use SSL for your website if you want to use this feature. Also set your callback-endpoints to `https://` addresses to reduce the risk of leaking user's tokens. You should use SSL for your website if you want to use this feature. Also set your callback-endpoints to `https://` addresses to reduce the risk of leaking user's tokens.

View File

@ -37,6 +37,10 @@ Change how logins, password-forgot, signups are handled.
| Enable registration | Boolean | Enable self-registration for users on the login-pages | False | | Enable registration | Boolean | Enable self-registration for users on the login-pages | False |
| Enable SSO | Boolean | Enable SSO on the login-pages | False | | Enable SSO | Boolean | Enable SSO on the login-pages | False |
| Enable SSO registration | Boolean | Enable self-registration for users via SSO on the login-pages | False | | Enable SSO registration | Boolean | Enable self-registration for users via SSO on the login-pages | False |
| Enable SSO group sync | Boolean | Enable synchronizing InvenTree groups directly from the IdP | False |
| SSO group key | String | The name of the groups claim attribute provided by the IdP | |
| SSO group map | String (JSON) | A mapping from SSO groups to local InvenTree groups | {} |
| Remove groups outside of SSO | Boolean | Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues | True |
| Enable password forgot | Boolean | Enable password forgot function on the login-pages.<br><br>This will let users reset their passwords on their own. For this feature to work you need to configure E-mail | True | | Enable password forgot | Boolean | Enable password forgot function on the login-pages.<br><br>This will let users reset their passwords on their own. For this feature to work you need to configure E-mail | True |
| E-Mail required | Boolean | Require user to supply e-mail on signup.<br><br>Without a way (e-mail) to contact the user notifications and security features might not work! | False | | E-Mail required | Boolean | Require user to supply e-mail on signup.<br><br>Without a way (e-mail) to contact the user notifications and security features might not work! | False |
| Enforce MFA | Boolean | Users must use multifactor security.<br><br>This forces each user to setup MFA and use it on each authentication | False | | Enforce MFA | Boolean | Users must use multifactor security.<br><br>This forces each user to setup MFA and use it on each authentication | False |

View File

@ -11,6 +11,8 @@ from django.core.exceptions import AppRegistryNotReady
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError, OperationalError from django.db.utils import IntegrityError, OperationalError
from allauth.socialaccount.signals import social_account_added, social_account_updated
import InvenTree.conversion import InvenTree.conversion
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
@ -70,6 +72,12 @@ class InvenTreeConfig(AppConfig):
self.add_user_on_startup() self.add_user_on_startup()
self.add_user_from_file() self.add_user_from_file()
# register event receiver and connect signal for SSO group sync. The connected signal is
# used for account updates whereas the receiver is used for the initial account creation.
from InvenTree import sso
social_account_updated.connect(sso.ensure_sso_groups)
def remove_obsolete_tasks(self): def remove_obsolete_tasks(self):
"""Delete any obsolete scheduled tasks in the database.""" """Delete any obsolete scheduled tasks in the database."""
obsolete = [ obsolete = [

View File

@ -269,7 +269,9 @@ class RegistratonMixin:
# Check if a default group is set in settings # Check if a default group is set in settings
start_group = get_global_setting('SIGNUP_GROUP') start_group = get_global_setting('SIGNUP_GROUP')
if start_group: if (
start_group and user.groups.count() == 0
): # check that no group has been added through SSO group sync
try: try:
group = Group.objects.get(id=start_group) group = Group.objects.get(id=start_group)
user.groups.add(group) user.groups.add(group)

View File

@ -1,7 +1,14 @@
"""Helper functions for Single Sign On functionality.""" """Helper functions for Single Sign On functionality."""
import json
import logging import logging
from django.contrib.auth.models import Group
from django.db.models.signals import post_save
from django.dispatch import receiver
from allauth.socialaccount.models import SocialAccount, SocialLogin
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
@ -75,3 +82,55 @@ def registration_enabled() -> bool:
def auto_registration_enabled() -> bool: def auto_registration_enabled() -> bool:
"""Return True if SSO auto-registration is enabled.""" """Return True if SSO auto-registration is enabled."""
return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO')) return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO'))
def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs):
"""Sync groups from IdP each time a SSO user logs on.
This event listener is registered in the apps ready method.
"""
if not get_global_setting('LOGIN_ENABLE_SSO_GROUP_SYNC'):
return
group_key = get_global_setting('SSO_GROUP_KEY')
group_map = json.loads(get_global_setting('SSO_GROUP_MAP'))
# map SSO groups to InvenTree groups
group_names = []
for sso_group in sociallogin.account.extra_data.get(group_key, []):
if mapped_name := group_map.get(sso_group):
group_names.append(mapped_name)
# ensure user has groups
user = sociallogin.account.user
for group_name in group_names:
try:
user.groups.get(name=group_name)
except Group.DoesNotExist:
# user not in group yet
try:
group = Group.objects.get(name=group_name)
except Group.DoesNotExist:
logger.info(f'Creating group {group_name} as it did not exist')
group = Group(name=group_name)
group.save()
logger.info(f'Adding group {group_name} to user {user}')
user.groups.add(group)
# remove groups not listed by SSO if not disabled
if get_global_setting('SSO_REMOVE_GROUPS'):
for group in user.groups.all():
if not group.name in group_names:
logger.info(f'Removing group {group.name} from {user}')
user.groups.remove(group)
@receiver(post_save, sender=SocialAccount)
def on_social_account_created(sender, instance: SocialAccount, created: bool, **kwargs):
"""Sync SSO groups when new SocialAccount is added.
Since the allauth `social_account_added` signal is not sent for some reason, this
signal is simulated using post_save signals. The issue has been reported as
https://github.com/pennersr/django-allauth/issues/3834
"""
if created:
ensure_sso_groups(None, SocialLogin(account=instance))

View File

@ -0,0 +1,122 @@
"""Test the sso module functionality."""
from django.contrib.auth.models import Group, User
from django.test import override_settings
from django.test.testcases import TransactionTestCase
from allauth.socialaccount.models import SocialAccount, SocialLogin
from common.models import InvenTreeSetting
from InvenTree import sso
from InvenTree.forms import RegistratonMixin
from InvenTree.unit_test import InvenTreeTestCase
class Dummy:
"""Simulate super class of RegistratonMixin."""
def save_user(self, _request, user: User, *args) -> User:
"""This method is only used that the super() call of RegistrationMixin does not fail."""
return user
class MockRegistrationMixin(RegistratonMixin, Dummy):
"""Mocked implementation of the RegistrationMixin."""
class TestSsoGroupSync(TransactionTestCase):
"""Tests for the SSO group sync feature."""
def setUp(self):
"""Construct sociallogin object for test cases."""
# configure SSO
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_GROUP_SYNC', True)
InvenTreeSetting.set_setting('SSO_GROUP_KEY', 'groups')
InvenTreeSetting.set_setting(
'SSO_GROUP_MAP', '{"idp_group": "inventree_group"}'
)
# configure sociallogin
extra_data = {'groups': ['idp_group']}
self.group = Group(name='inventree_group')
self.group.save()
# ensure default group exists
user = User(username='testuser', first_name='Test', last_name='User')
user.save()
account = SocialAccount(user=user, extra_data=extra_data)
self.sociallogin = SocialLogin(account=account)
def test_group_added_to_user(self):
"""Check that a new SSO group is added to the user."""
user: User = self.sociallogin.account.user
self.assertEqual(user.groups.count(), 0)
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
def test_group_already_exists(self):
"""Check that existing SSO group is not modified."""
user: User = self.sociallogin.account.user
user.groups.add(self.group)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
@override_settings(SSO_REMOVE_GROUPS=True)
def test_remove_non_sso_group(self):
"""Check that any group not provided by IDP is removed."""
user: User = self.sociallogin.account.user
# group must be saved to database first
group = Group(name='local_group')
group.save()
user.groups.add(group)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'local_group')
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
def test_override_default_group_with_sso_group(self):
"""The default group should be overridden if SSO groups are available."""
user: User = self.sociallogin.account.user
self.assertEqual(user.groups.count(), 0)
Group(id=42, name='default_group').save()
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
sso.ensure_sso_groups(None, self.sociallogin)
MockRegistrationMixin().save_user(None, user, None)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'inventree_group')
def test_default_group_without_sso_group(self):
"""If no SSO group is specified, the default group should be applied."""
self.sociallogin.account.extra_data = {}
user: User = self.sociallogin.account.user
self.assertEqual(user.groups.count(), 0)
Group(id=42, name='default_group').save()
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
sso.ensure_sso_groups(None, self.sociallogin)
MockRegistrationMixin().save_user(None, user, None)
self.assertEqual(user.groups.count(), 1)
self.assertEqual(user.groups.first().name, 'default_group')
@override_settings(SSO_REMOVE_GROUPS=True)
def test_remove_groups_overrides_default_group(self):
"""If no SSO group is specified, the default group should not be added if SSO_REMOVE_GROUPS=True."""
user: User = self.sociallogin.account.user
self.sociallogin.account.extra_data = {}
self.assertEqual(user.groups.count(), 0)
Group(id=42, name='default_group').save()
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
sso.ensure_sso_groups(None, self.sociallogin)
MockRegistrationMixin().save_user(None, user, None)
# second ensure_sso_groups will be called by signal if social account changes
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(user.groups.count(), 0)
def test_sso_group_created_if_not_exists(self):
"""If the mapped group does not exist, a new group with the same name should be created."""
self.group.delete()
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 0)
sso.ensure_sso_groups(None, self.sociallogin)
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)

View File

@ -1909,6 +1909,38 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'LOGIN_ENABLE_SSO_GROUP_SYNC': {
'name': _('Enable SSO group sync'),
'description': _(
'Enable synchronizing InvenTree groups with groups provided by the IdP'
),
'default': False,
'validator': bool,
},
'SSO_GROUP_KEY': {
'name': _('SSO group key'),
'description': _(
'The name of the groups claim attribute provided by the IdP'
),
'default': 'groups',
'validator': str,
},
'SSO_GROUP_MAP': {
'name': _('SSO group map'),
'description': _(
'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.'
),
'validator': json.loads,
'default': '{}',
},
'SSO_REMOVE_GROUPS': {
'name': _('Remove groups outside of SSO'),
'description': _(
'Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues'
),
'default': True,
'validator': bool,
},
'LOGIN_MAIL_REQUIRED': { 'LOGIN_MAIL_REQUIRED': {
'name': _('Email required'), 'name': _('Email required'),
'description': _('Require user to supply mail on signup'), 'description': _('Require user to supply mail on signup'),
@ -1945,7 +1977,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'SIGNUP_GROUP': { 'SIGNUP_GROUP': {
'name': _('Group on signup'), 'name': _('Group on signup'),
'description': _('Group to which new users are assigned on registration'), 'description': _(
'Group to which new users are assigned on registration. If SSO group sync is enabled, this group is only set if no group can be assigned from the IdP.'
),
'default': '', 'default': '',
'choices': settings_group_options, 'choices': settings_group_options,
}, },

View File

@ -39,6 +39,10 @@
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO_REG" icon="fa-user-plus" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO_REG" icon="fa-user-plus" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO_GROUP_SYNC" icon="fa-users" %}
{% include "InvenTree/settings/setting.html" with key="SSO_GROUP_KEY" icon="fa-key" %}
{% include "InvenTree/settings/setting.html" with key="SSO_GROUP_MAP" icon="fa-book" %}
{% include "InvenTree/settings/setting.html" with key="SSO_REMOVE_GROUPS" icon="fa-user-minus" %}
</tbody> </tbody>
</table> </table>

View File

@ -77,7 +77,11 @@ export default function SystemSettings() {
'LOGIN_SIGNUP_MAIL_RESTRICTION', 'LOGIN_SIGNUP_MAIL_RESTRICTION',
'LOGIN_ENABLE_SSO', 'LOGIN_ENABLE_SSO',
'LOGIN_ENABLE_SSO_REG', 'LOGIN_ENABLE_SSO_REG',
'LOGIN_SIGNUP_SSO_AUTO' 'LOGIN_SIGNUP_SSO_AUTO',
'LOGIN_ENABLE_SSO_GROUP_SYNC',
'SSO_GROUP_MAP',
'SSO_GROUP_KEY',
'SSO_REMOVE_GROUPS'
]} ]}
/> />
) )