OpenIDConnect backend
Use social-auth to authenticate with OpenIDConnect. Add API to manage sso groups.
This commit is contained in:
parent
cdcd22e6a6
commit
853ff30bf4
2
Pipfile
2
Pipfile
@ -51,6 +51,8 @@ flower = "*"
|
|||||||
bleach = "*"
|
bleach = "*"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
django-multiselectfield = "*"
|
django-multiselectfield = "*"
|
||||||
|
social-auth-core = {version = "*", extras = ["openidconnect"]}
|
||||||
|
social-auth-app-django = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
# Linting
|
# Linting
|
||||||
|
@ -52,6 +52,7 @@
|
|||||||
{% translate "Share link has expired." %}
|
{% translate "Share link has expired." %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not settings.SOCIAL_AUTH_DISABLE_NORMAL_AUTH %}
|
||||||
{% translate "Username" as i18n_username %}
|
{% translate "Username" as i18n_username %}
|
||||||
{% translate "Password" as i18n_password %}
|
{% translate "Password" as i18n_password %}
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
@ -65,6 +66,11 @@
|
|||||||
<div class="d-grid mt-3">
|
<div class="d-grid mt-3">
|
||||||
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
5
src/paperless/admin.py
Normal file
5
src/paperless/admin.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from paperless.models import SSOGroup
|
||||||
|
|
||||||
|
admin.site.register(SSOGroup)
|
5
src/paperless/context_processors.py
Normal file
5
src/paperless/context_processors.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
|
||||||
|
def settings(request):
|
||||||
|
return {"settings": django_settings}
|
44
src/paperless/migrations/0001_initial.py
Normal file
44
src/paperless/migrations/0001_initial.py
Normal 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
21
src/paperless/models.py
Normal 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
|
@ -3,6 +3,8 @@ from django.contrib.auth.models import Permission
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from paperless.models import SSOGroup
|
||||||
|
|
||||||
|
|
||||||
class ObfuscatedUserPasswordField(serializers.Field):
|
class ObfuscatedUserPasswordField(serializers.Field):
|
||||||
"""
|
"""
|
||||||
@ -89,6 +91,11 @@ class GroupSerializer(serializers.ModelSerializer):
|
|||||||
queryset=Permission.objects.all(),
|
queryset=Permission.objects.all(),
|
||||||
slug_field="codename",
|
slug_field="codename",
|
||||||
)
|
)
|
||||||
|
sso_groups = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True,
|
||||||
|
queryset=SSOGroup.objects.all(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Group
|
model = Group
|
||||||
@ -96,4 +103,14 @@ class GroupSerializer(serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"permissions",
|
"permissions",
|
||||||
|
"sso_groups",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SSOGroupSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SSOGroup
|
||||||
|
fields = (
|
||||||
|
"name",
|
||||||
|
"group",
|
||||||
)
|
)
|
||||||
|
@ -287,6 +287,7 @@ INSTALLED_APPS = [
|
|||||||
"django_filters",
|
"django_filters",
|
||||||
"django_celery_results",
|
"django_celery_results",
|
||||||
"guardian",
|
"guardian",
|
||||||
|
"social_django",
|
||||||
*env_apps,
|
*env_apps,
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -357,6 +358,8 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"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",
|
"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")
|
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:
|
if AUTO_LOGIN_USERNAME:
|
||||||
_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||||
# This overrides everything the auth middleware is doing but still allows
|
# This overrides everything the auth middleware is doing but still allows
|
||||||
|
25
src/paperless/social_auth.py
Normal file
25
src/paperless/social_auth.py
Normal 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)
|
@ -35,6 +35,7 @@ from documents.views import UnifiedSearchViewSet
|
|||||||
from paperless.consumers import StatusConsumer
|
from paperless.consumers import StatusConsumer
|
||||||
from paperless.views import FaviconView
|
from paperless.views import FaviconView
|
||||||
from paperless.views import GroupViewSet
|
from paperless.views import GroupViewSet
|
||||||
|
from paperless.views import SSOGroupViewSet
|
||||||
from paperless.views import UserViewSet
|
from paperless.views import UserViewSet
|
||||||
from paperless_mail.views import MailAccountTestView
|
from paperless_mail.views import MailAccountTestView
|
||||||
from paperless_mail.views import MailAccountViewSet
|
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"mail_rules", MailRuleViewSet)
|
||||||
api_router.register(r"share_links", ShareLinkViewSet)
|
api_router.register(r"share_links", ShareLinkViewSet)
|
||||||
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
|
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
|
||||||
|
api_router.register(r"sso_groups", SSOGroupViewSet)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -167,6 +169,7 @@ urlpatterns = [
|
|||||||
# TODO: with localization, this is even worse! :/
|
# TODO: with localization, this is even worse! :/
|
||||||
# login, logout
|
# login, logout
|
||||||
path("accounts/", include("django.contrib.auth.urls")),
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
|
path("", include("social_django.urls", namespace="social")),
|
||||||
# Root of the Frontend
|
# Root of the Frontend
|
||||||
re_path(r".*", login_required(IndexView.as_view()), name="base"),
|
re_path(r".*", login_required(IndexView.as_view()), name="base"),
|
||||||
]
|
]
|
||||||
|
@ -16,7 +16,9 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
from paperless.filters import GroupFilterSet
|
from paperless.filters import GroupFilterSet
|
||||||
from paperless.filters import UserFilterSet
|
from paperless.filters import UserFilterSet
|
||||||
|
from paperless.models import SSOGroup
|
||||||
from paperless.serialisers import GroupSerializer
|
from paperless.serialisers import GroupSerializer
|
||||||
|
from paperless.serialisers import SSOGroupSerializer
|
||||||
from paperless.serialisers import UserSerializer
|
from paperless.serialisers import UserSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -106,3 +108,9 @@ class GroupViewSet(ModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
filterset_class = GroupFilterSet
|
filterset_class = GroupFilterSet
|
||||||
ordering_fields = ("name",)
|
ordering_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
class SSOGroupViewSet(ModelViewSet):
|
||||||
|
model = SSOGroup
|
||||||
|
queryset = SSOGroup.objects
|
||||||
|
serializer_class = SSOGroupSerializer
|
||||||
|
Loading…
x
Reference in New Issue
Block a user