From 853ff30bf4abc0b7e38e99dbcb1f796da226b3e3 Mon Sep 17 00:00:00 2001 From: Louis Chauvet Date: Sun, 9 Jul 2023 01:16:39 +0200 Subject: [PATCH] OpenIDConnect backend Use social-auth to authenticate with OpenIDConnect. Add API to manage sso groups. --- Pipfile | 2 + .../templates/registration/login.html | 34 ++++++++------ src/paperless/admin.py | 5 +++ src/paperless/context_processors.py | 5 +++ src/paperless/migrations/0001_initial.py | 44 +++++++++++++++++++ .../paperless/migrations/__init__.py | 0 src/paperless/models.py | 21 +++++++++ src/paperless/serialisers.py | 17 +++++++ src/paperless/settings.py | 32 ++++++++++++++ src/paperless/social_auth.py | 25 +++++++++++ src/paperless/urls.py | 3 ++ src/paperless/views.py | 8 ++++ 12 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 src/paperless/admin.py create mode 100644 src/paperless/context_processors.py create mode 100644 src/paperless/migrations/0001_initial.py rename src-ui/src/app/app.component.scss => src/paperless/migrations/__init__.py (100%) create mode 100644 src/paperless/models.py create mode 100644 src/paperless/social_auth.py diff --git a/Pipfile b/Pipfile index af6f4e4fd..1d83bca86 100644 --- a/Pipfile +++ b/Pipfile @@ -51,6 +51,8 @@ flower = "*" bleach = "*" zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"} django-multiselectfield = "*" +social-auth-core = {version = "*", extras = ["openidconnect"]} +social-auth-app-django = "*" [dev-packages] # Linting diff --git a/src/documents/templates/registration/login.html b/src/documents/templates/registration/login.html index c4c936182..21d403209 100644 --- a/src/documents/templates/registration/login.html +++ b/src/documents/templates/registration/login.html @@ -51,20 +51,26 @@ - {% endif %} - {% translate "Username" as i18n_username %} - {% translate "Password" as i18n_password %} -
- - -
-
- - -
-
- -
+ {% endif %} + {% if not settings.SOCIAL_AUTH_DISABLE_NORMAL_AUTH %} + {% translate "Username" as i18n_username %} + {% translate "Password" as i18n_password %} +
+ + +
+
+ + +
+
+ +
+ {% endif %} + {% if settings.SOCIAL_AUTH_OIDC_ENABLE %} + {{ settings.SOCIAL_AUTH_OIDC_NAME }} + {% endif %} + diff --git a/src/paperless/admin.py b/src/paperless/admin.py new file mode 100644 index 000000000..ab67e468b --- /dev/null +++ b/src/paperless/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from paperless.models import SSOGroup + +admin.site.register(SSOGroup) diff --git a/src/paperless/context_processors.py b/src/paperless/context_processors.py new file mode 100644 index 000000000..6fd946a5b --- /dev/null +++ b/src/paperless/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings as django_settings + + +def settings(request): + return {"settings": django_settings} diff --git a/src/paperless/migrations/0001_initial.py b/src/paperless/migrations/0001_initial.py new file mode 100644 index 000000000..175a698e4 --- /dev/null +++ b/src/paperless/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.1.10 on 2023-07-09 13:26 + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="SSOGroup", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=256, unique=True)), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sso_groups", + to="auth.group", + ), + ), + ], + options={ + "verbose_name": "SSO group", + "verbose_name_plural": "SSO groups", + "ordering": ("name",), + }, + ), + ] diff --git a/src-ui/src/app/app.component.scss b/src/paperless/migrations/__init__.py similarity index 100% rename from src-ui/src/app/app.component.scss rename to src/paperless/migrations/__init__.py diff --git a/src/paperless/models.py b/src/paperless/models.py new file mode 100644 index 000000000..4e5f71d8f --- /dev/null +++ b/src/paperless/models.py @@ -0,0 +1,21 @@ +from django.contrib.auth.models import Group +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class SSOGroup(models.Model): + group = models.ForeignKey( + Group, + related_name="sso_groups", + on_delete=models.CASCADE, + null=False, + ) + name = models.CharField(max_length=256, blank=False, null=False, unique=True) + + class Meta: + ordering = ("name",) + verbose_name = _("SSO group") + verbose_name_plural = _("SSO groups") + + def __str__(self): + return self.name diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 4094a6538..62578ff85 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -3,6 +3,8 @@ from django.contrib.auth.models import Permission from django.contrib.auth.models import User from rest_framework import serializers +from paperless.models import SSOGroup + class ObfuscatedUserPasswordField(serializers.Field): """ @@ -89,6 +91,11 @@ class GroupSerializer(serializers.ModelSerializer): queryset=Permission.objects.all(), slug_field="codename", ) + sso_groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=SSOGroup.objects.all(), + required=False, + ) class Meta: model = Group @@ -96,4 +103,14 @@ class GroupSerializer(serializers.ModelSerializer): "id", "name", "permissions", + "sso_groups", + ) + + +class SSOGroupSerializer(serializers.ModelSerializer): + class Meta: + model = SSOGroup + fields = ( + "name", + "group", ) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 6d25a53cc..dfe602e43 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -287,6 +287,7 @@ INSTALLED_APPS = [ "django_filters", "django_celery_results", "guardian", + "social_django", *env_apps, ] @@ -357,6 +358,8 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "paperless.context_processors.settings", + "social_django.context_processors.backends", ], }, }, @@ -383,8 +386,37 @@ AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", ] +SOCIAL_AUTH_DISABLE_NORMAL_AUTH = __get_boolean("PAPERLESS_SSO_DISABLE_NORMAL_AUTH") + +# Only support OIDC, but it should be easy to enable more backends +if __get_boolean("PAPERLESS_SSO_OIDC_ENABLE"): + SOCIAL_AUTH_OIDC_ENABLE = True + AUTHENTICATION_BACKENDS.append( + "social_core.backends.open_id_connect.OpenIdConnectAuth", + ) + SOCIAL_AUTH_OIDC_KEY = os.environ.get("PAPERLESS_SSO_OIDC_KEY") + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = os.environ.get("PAPERLESS_SSO_OIDC_ENDPOINT") + SOCIAL_AUTH_OIDC_SECRET = os.environ.get("PAPERLESS_SSO_OIDC_SECRET") + SOCIAL_AUTH_OIDC_NAME = os.environ.get("PAPERLESS_SSO_OIDC_NAME", "OpenID Connect") + +LOGIN_REDIRECT_URL = BASE_URL + AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME") +SOCIAL_AUTH_PIPELINE = ( + "social_core.pipeline.social_auth.social_details", + "social_core.pipeline.social_auth.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.user.get_username", + "social_core.pipeline.user.create_user", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", + "social_core.pipeline.social_auth.load_extra_data", + "paperless.social_auth.update_groups", +) + if AUTO_LOGIN_USERNAME: _index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") # This overrides everything the auth middleware is doing but still allows diff --git a/src/paperless/social_auth.py b/src/paperless/social_auth.py new file mode 100644 index 000000000..44484ee00 --- /dev/null +++ b/src/paperless/social_auth.py @@ -0,0 +1,25 @@ +from paperless.models import SSOGroup + + +def update_groups(response, user, *args, **kwargs): + # This works at least for openidconnect, if you want to implement new SSO + # you need to check that groups are strings in the list "groups" + + # Search all existing groups associated with sso groups + sso_groups = set() + for group in response.get("groups", []): + for g in SSOGroup.objects.filter(name__exact=group): + sso_groups.add(g.group) + # Extract current sso groups currently connected to the user + actual_sso_groups = set() + for group in user.groups.filter(sso_groups__isnull=False): + if group.sso_groups.count() != 0: + for sso_group in group.sso_groups.all(): + actual_sso_groups.add(sso_group.group) + # Add missing groups + for g in sso_groups - actual_sso_groups: + g.user_set.add(user) + # Remove groups which are connected to a sso group, but not connected + # anymore + for g in actual_sso_groups - sso_groups: + g.user_set.remove(user) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 415efc4de..0ab0aa254 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -35,6 +35,7 @@ from documents.views import UnifiedSearchViewSet from paperless.consumers import StatusConsumer from paperless.views import FaviconView from paperless.views import GroupViewSet +from paperless.views import SSOGroupViewSet from paperless.views import UserViewSet from paperless_mail.views import MailAccountTestView from paperless_mail.views import MailAccountViewSet @@ -55,6 +56,7 @@ api_router.register(r"mail_accounts", MailAccountViewSet) api_router.register(r"mail_rules", MailRuleViewSet) api_router.register(r"share_links", ShareLinkViewSet) api_router.register(r"consumption_templates", ConsumptionTemplateViewSet) +api_router.register(r"sso_groups", SSOGroupViewSet) urlpatterns = [ @@ -167,6 +169,7 @@ urlpatterns = [ # TODO: with localization, this is even worse! :/ # login, logout path("accounts/", include("django.contrib.auth.urls")), + path("", include("social_django.urls", namespace="social")), # Root of the Frontend re_path(r".*", login_required(IndexView.as_view()), name="base"), ] diff --git a/src/paperless/views.py b/src/paperless/views.py index e872cc19c..ddde65731 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -16,7 +16,9 @@ from rest_framework.viewsets import ModelViewSet from documents.permissions import PaperlessObjectPermissions from paperless.filters import GroupFilterSet from paperless.filters import UserFilterSet +from paperless.models import SSOGroup from paperless.serialisers import GroupSerializer +from paperless.serialisers import SSOGroupSerializer from paperless.serialisers import UserSerializer @@ -106,3 +108,9 @@ class GroupViewSet(ModelViewSet): filter_backends = (DjangoFilterBackend, OrderingFilter) filterset_class = GroupFilterSet ordering_fields = ("name",) + + +class SSOGroupViewSet(ModelViewSet): + model = SSOGroup + queryset = SSOGroup.objects + serializer_class = SSOGroupSerializer