OpenIDConnect backend

Use social-auth to authenticate with OpenIDConnect.
Add API to manage sso groups.
This commit is contained in:
Louis Chauvet 2023-07-09 01:16:39 +02:00 committed by shamoon
parent cdcd22e6a6
commit 853ff30bf4
12 changed files with 182 additions and 14 deletions

View File

@ -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

View File

@ -51,20 +51,26 @@
<div class="alert alert-danger" role="alert">
{% translate "Share link has expired." %}
</div>
{% endif %}
{% translate "Username" as i18n_username %}
{% translate "Password" as i18n_password %}
<div class="form-floating">
<input type="text" name="username" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
<label for="inputUsername">{{ i18n_username }}</label>
</div>
<div class="form-floating">
<input type="password" name="password" id="inputPassword" placeholder="{{ i18n_password }}" class="form-control" required>
<label for="inputPassword">{{ i18n_password }}</label>
</div>
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
</div>
{% endif %}
{% if not settings.SOCIAL_AUTH_DISABLE_NORMAL_AUTH %}
{% translate "Username" as i18n_username %}
{% translate "Password" as i18n_password %}
<div class="form-floating">
<input type="text" name="username" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
<label for="inputUsername">{{ i18n_username }}</label>
</div>
<div class="form-floating">
<input type="password" name="password" id="inputPassword" placeholder="{{ i18n_password }}" class="form-control" required>
<label for="inputPassword">{{ i18n_password }}</label>
</div>
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
</div>
{% endif %}
{% if settings.SOCIAL_AUTH_OIDC_ENABLE %}
<a class="btn btn-lg btn-primary btn-block" href="{% url "social:begin" "oidc" %}">{{ settings.SOCIAL_AUTH_OIDC_NAME }}</a>
{% endif %}
</form>
</body>
</html>

5
src/paperless/admin.py Normal file
View File

@ -0,0 +1,5 @@
from django.contrib import admin
from paperless.models import SSOGroup
admin.site.register(SSOGroup)

View File

@ -0,0 +1,5 @@
from django.conf import settings as django_settings
def settings(request):
return {"settings": django_settings}

View File

@ -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",),
},
),
]

21
src/paperless/models.py Normal file
View File

@ -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

View File

@ -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",
)

View File

@ -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

View File

@ -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)

View File

@ -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"),
]

View File

@ -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