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 = "*"
|
||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||
django-multiselectfield = "*"
|
||||
social-auth-core = {version = "*", extras = ["openidconnect"]}
|
||||
social-auth-app-django = "*"
|
||||
|
||||
[dev-packages]
|
||||
# Linting
|
||||
|
@ -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
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 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",
|
||||
)
|
||||
|
@ -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
|
||||
|
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.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"),
|
||||
]
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user