Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting.
+### Authentication & SSO {#authentication}
+
+#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
+
+: Allow users to signup for a new Paperless-ngx account.
+
+ Defaults to False
+
+#### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS}
+
+: A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist.
+
+ Defaults to None
+
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
Defaults to True
-#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
+#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS}
-: Allow users to signup for a new Paperless-ngx account.
+: Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
+
+: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
+
+ ```json
+ {"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
+ ```
Defaults to False
+#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
+
+: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
+If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups.
+
+ Defaults to None
+
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
+import logging
from urllib.parse import quote
from allauth.account.adapter import DefaultAccountAdapter
from allauth.core import context
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.forms import ValidationError
from django.urls import reverse
+logger = logging.getLogger("paperless.auth")
+
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
path = path.replace("UID-KEY", quote(key))
return settings.PAPERLESS_URL + path
+ def save_user(self, request, user, form, commit=True): # noqa: FBT002
+ """
+ Save the user instance. Default groups are assigned to the user, if
+ specified in the settings.
+ """
+ user: User = super().save_user(request, user, form, commit)
+ group_names: list[str] = settings.ACCOUNT_DEFAULT_GROUPS
+ if len(group_names) > 0:
+ groups = Group.objects.filter(name__in=group_names)
+ logger.debug(f"Adding default groups to user `{user}`: {group_names}")
+ user.groups.add(*groups)
+ user.save()
+ return user
+
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
url = reverse("base")
return url
- def populate_user(self, request, sociallogin, data):
+ def save_user(self, request, sociallogin, form=None):
"""
- Populate the user with data from the social account. Stub is kept in case
- global default permissions are implemented in the future.
+ Save the user instance. Default groups are assigned to the user, if
+ specified in the settings.
"""
- # TODO: If default global permissions are implemented, should also be here
- return super().populate_user(request, sociallogin, data) # pragma: no cover
+ # save_user also calls account_adapter save_user which would set ACCOUNT_DEFAULT_GROUPS
+ user: User = super().save_user(request, sociallogin, form)
+ group_names: list[str] = settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS
+ if len(group_names) > 0:
+ groups = Group.objects.filter(name__in=group_names)
+ logger.debug(
+ f"Adding default social groups to user `{user}`: {group_names}",
+ )
+ user.groups.add(*groups)
+ user.save()
+ return user
from django.utils.translation import gettext_lazy as _
from paperless.signals import handle_failed_login
+from paperless.signals import handle_social_account_updated
class PaperlessConfig(AppConfig):
from django.contrib.auth.signals import user_login_failed
user_login_failed.connect(handle_failed_login)
+
+ from allauth.socialaccount.signals import social_account_updated
+
+ social_account_updated.connect(handle_social_account_updated)
+
AppConfig.ready(self)
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
+ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_ACCOUNT_DEFAULT_GROUPS")
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean(
SOCIALACCOUNT_PROVIDERS = json.loads(
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
)
+SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS")
+SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS")
MFA_TOTP_ISSUER = "Paperless-ngx"
log_output += f" from private IP `{client_ip}`."
logger.info(log_output)
+
+
+def handle_social_account_updated(sender, request, sociallogin, **kwargs):
+ """
+ Handle the social account update signal.
+ """
+ from django.contrib.auth.models import Group
+
+ social_account_groups = sociallogin.account.extra_data.get(
+ "groups",
+ [],
+ ) # None if not found
+ if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None:
+ groups = Group.objects.filter(name__in=social_account_groups)
+ logger.debug(
+ f"Syncing groups for user `{sociallogin.user}`: {social_account_groups}",
+ )
+ sociallogin.user.groups.set(groups, clear=True)
from allauth.core import context
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.forms import ValidationError
from django.http import HttpRequest
from django.test import TestCase
expected_url,
)
+ @override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
+ def test_save_user_adds_groups(self):
+ Group.objects.create(name="group1")
+ user = User.objects.create_user("testuser")
+ adapter = get_adapter()
+ form = mock.Mock(
+ cleaned_data={
+ "username": "testuser",
+ "email": "user@example.com",
+ },
+ )
+
+ user = adapter.save_user(HttpRequest(), user, form, commit=True)
+
+ self.assertEqual(user.groups.count(), 1)
+ self.assertTrue(user.groups.filter(name="group1").exists())
+ self.assertFalse(user.groups.filter(name="group2").exists())
+
class TestCustomSocialAccountAdapter(TestCase):
def test_is_open_for_signup(self):
adapter.get_connect_redirect_url(request, socialaccount),
expected_url,
)
+
+ @override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
+ def test_save_user_adds_groups(self):
+ Group.objects.create(name="group1")
+ adapter = get_social_adapter()
+ request = HttpRequest()
+ user = User.objects.create_user("testuser")
+ sociallogin = mock.Mock(
+ user=user,
+ )
+
+ user = adapter.save_user(request, sociallogin, None)
+
+ self.assertEqual(user.groups.count(), 1)
+ self.assertTrue(user.groups.filter(name="group1").exists())
+ self.assertFalse(user.groups.filter(name="group2").exists())
+from unittest.mock import Mock
+
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
from django.http import HttpRequest
from django.test import TestCase
+from django.test import override_settings
from paperless.signals import handle_failed_login
+from paperless.signals import handle_social_account_updated
class TestFailedLoginLogging(TestCase):
"INFO:paperless.auth:Login failed for user `john lennon` from private IP `10.0.0.1`.",
],
)
+
+
+class TestSyncSocialLoginGroups(TestCase):
+ @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
+ def test_sync_enabled(self):
+ """
+ GIVEN:
+ - Enabled group syncing, a user, and a social login
+ WHEN:
+ - The social login is updated via signal after login
+ THEN:
+ - The user's groups are updated to match the social login's groups
+ """
+ group = Group.objects.create(name="group1")
+ user = User.objects.create_user(username="testuser")
+ sociallogin = Mock(
+ user=user,
+ account=Mock(
+ extra_data={
+ "groups": ["group1"],
+ },
+ ),
+ )
+ handle_social_account_updated(
+ sender=None,
+ request=HttpRequest(),
+ sociallogin=sociallogin,
+ )
+ self.assertEqual(list(user.groups.all()), [group])
+
+ @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=False)
+ def test_sync_disabled(self):
+ """
+ GIVEN:
+ - Disabled group syncing, a user, and a social login
+ WHEN:
+ - The social login is updated via signal after login
+ THEN:
+ - The user's groups are not updated
+ """
+ Group.objects.create(name="group1")
+ user = User.objects.create_user(username="testuser")
+ sociallogin = Mock(
+ user=user,
+ account=Mock(
+ extra_data={
+ "groups": ["group1"],
+ },
+ ),
+ )
+ handle_social_account_updated(
+ sender=None,
+ request=HttpRequest(),
+ sociallogin=sociallogin,
+ )
+ self.assertEqual(list(user.groups.all()), [])
+
+ @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True)
+ def test_no_groups(self):
+ """
+ GIVEN:
+ - Enabled group syncing, a user, and a social login with no groups
+ WHEN:
+ - The social login is updated via signal after login
+ THEN:
+ - The user's groups are cleared to match the social login's groups
+ """
+ group = Group.objects.create(name="group1")
+ user = User.objects.create_user(username="testuser")
+ user.groups.add(group)
+ user.save()
+ sociallogin = Mock(
+ user=user,
+ account=Mock(
+ extra_data={
+ "groups": [],
+ },
+ ),
+ )
+ handle_social_account_updated(
+ sender=None,
+ request=HttpRequest(),
+ sociallogin=sociallogin,
+ )
+ self.assertEqual(list(user.groups.all()), [])