From 40d1e18faf39dd50dd41c5e94ab13350df384e51 Mon Sep 17 00:00:00 2001 From: Antoine Martin Date: Wed, 14 Jun 2023 10:20:42 -0400 Subject: [PATCH 1/1] Add OpenID Connect SSO support via django-allauth (#1746) --- src/documents/templates/account/base.html | 14 + src/documents/templates/account/login.html | 1 + src/documents/templates/account/logout.html | 1 + .../templates/registration/login.html | 72 ++- .../socialaccount/authentication_error.html | 1 + src/documents/templatetags/django_settings.py | 9 + src/paperless/allauth_custom.py | 73 +++ src/paperless/settings.py | 114 +++++ src/paperless/urls.py | 9 +- 10 files changed, 711 insertions(+), 18 deletions(-) create mode 100644 0001-Add-OpenID-Connect-SSO-support-via-django-allauth-17.patch create mode 100644 src/documents/templates/account/base.html create mode 120000 src/documents/templates/account/login.html create mode 120000 src/documents/templates/account/logout.html create mode 120000 src/documents/templates/socialaccount/authentication_error.html create mode 100644 src/documents/templatetags/django_settings.py create mode 100644 src/paperless/allauth_custom.py diff --git a/src/documents/templates/account/base.html b/src/documents/templates/account/base.html new file mode 100644 index 00000000..0912c609 --- /dev/null +++ b/src/documents/templates/account/base.html @@ -0,0 +1,14 @@ +{% load i18n %} + + + {% translate "Redirecting" %} + + + + + {% translate "Redirecting" %}... + + diff --git a/src/documents/templates/account/login.html b/src/documents/templates/account/login.html new file mode 120000 index 00000000..03fd169e --- /dev/null +++ b/src/documents/templates/account/login.html @@ -0,0 +1 @@ +../registration/login.html \ No newline at end of file diff --git a/src/documents/templates/account/logout.html b/src/documents/templates/account/logout.html new file mode 120000 index 00000000..7cad0aa6 --- /dev/null +++ b/src/documents/templates/account/logout.html @@ -0,0 +1 @@ +../registration/logged_out.html \ No newline at end of file diff --git a/src/documents/templates/registration/login.html b/src/documents/templates/registration/login.html index d9ff86a7..404f37e6 100644 --- a/src/documents/templates/registration/login.html +++ b/src/documents/templates/registration/login.html @@ -1,4 +1,7 @@ +{% load django_settings %} +{% load socialaccount %} +{% settings_value "LOGIN_HIDE_PASSWORD_FORM" as hide_password_form %} {% load static %} {% load i18n %} @@ -38,8 +41,7 @@ -
- {% csrf_token %} +
+ diff --git a/src/documents/templates/socialaccount/authentication_error.html b/src/documents/templates/socialaccount/authentication_error.html new file mode 120000 index 00000000..b2ef5434 --- /dev/null +++ b/src/documents/templates/socialaccount/authentication_error.html @@ -0,0 +1 @@ +../account/login.html \ No newline at end of file diff --git a/src/documents/templatetags/django_settings.py b/src/documents/templatetags/django_settings.py new file mode 100644 index 00000000..cf415d5c --- /dev/null +++ b/src/documents/templatetags/django_settings.py @@ -0,0 +1,9 @@ +from django import template +from django.conf import settings + +register = template.Library() + + +@register.simple_tag +def settings_value(name): + return getattr(settings, name, "") diff --git a/src/paperless/allauth_custom.py b/src/paperless/allauth_custom.py new file mode 100644 index 00000000..0e716346 --- /dev/null +++ b/src/paperless/allauth_custom.py @@ -0,0 +1,73 @@ +import logging + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings +from django.http import Http404 +from django.urls import include +from django.urls import path +from django.urls import re_path +from django.urls import reverse +from django.urls import reverse_lazy +from django.views.generic import RedirectView + +logger = logging.getLogger("paperless.allauth") + + +def raise_404(*args, **kwargs): + raise Http404 + + +class CustomAccountAdapter(DefaultAccountAdapter): + def get_login_redirect_url(self, request): + return reverse("base") + + def get_signup_redirect_url(self, request): + return self.get_login_redirect_url(request) + + def is_open_for_signup(self, request): + return getattr(settings, "LOGIN_ENABLE_SIGNUP", False) + + +class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): + def authentication_error( + self, + request, + provider_id, + error=None, + exception=None, + extra_context=None, + ): + logger.error(f"Authentication error: {exception}") + return super().authentication_error( + request, + provider_id, + error, + exception, + extra_context, + ) + + def is_auto_signup_allowed(self, *args, **kwargs): + if getattr(settings, "SSO_AUTO_LINK_MULTIPLE", True): + # Skip allauth default logic of checking for an existing user with + # the same email address. This requires paperless administrators to + # trust the SSO providers connected to paperless. + return True + return super().is_auto_signup_allowed(*args, **kwargs) + + def is_open_for_signup(self, request, sociallogin): + # True indicates a user should be automatically created on successful + # login via configured external provider + return getattr(settings, "SSO_AUTO_LINK", True) + + +base_url = reverse_lazy("base") +urlpatterns = [ + # Override allauth URLs to disable features we don't want + path("signup/", RedirectView.as_view(url=base_url)), + re_path("confirm-email/.*", RedirectView.as_view(url=base_url)), + re_path("email/.*", RedirectView.as_view(url=base_url)), + re_path("password/.*", RedirectView.as_view(url=base_url)), + # Import allauth-provided URL patterns + path("", include("allauth.urls")), +] diff --git a/src/paperless/settings.py b/src/paperless/settings.py index d3c239b4..a2cba20b 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -18,6 +18,7 @@ from urllib.parse import urlparse from celery.schedules import crontab from concurrent_log_handler.queue import setup_logging_queues +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from dotenv import load_dotenv @@ -91,6 +92,22 @@ def __get_list( return [] +def __get_list( + key: str, + sep: str = ",", + default: Optional[List[str]] = None, +) -> List[str]: + """ + Return a list of strings based on the environment variable or an given default + list, if provided or an empty list + """ + if key in os.environ: + os.getenv(key).split(sep) + elif default is not None: + return default + return [] + + def _parse_redis_url(env_redis: Optional[str]) -> Tuple[str]: """ Gets the Redis information from the environment or a default and handles @@ -256,6 +273,40 @@ SCRATCH_DIR = __get_path( Path(tempfile.gettempdir()) / "paperless", ) +############################################################################### +# SSO Configuration +############################################################################### + + +def _get_oidc_server() -> Optional[Dict]: + config_id = os.environ.get("PAPERLESS_SSO_OIDC_ID") + name = os.environ.get("PAPERLESS_SSO_OIDC_NAME") + url = os.environ.get("PAPERLESS_SSO_OIDC_URL") + client_id = os.environ.get("PAPERLESS_SSO_OIDC_CLIENT_ID") + secret = os.environ.get("PAPERLESS_SSO_OIDC_SECRET") + if name and url and client_id and secret: + return { + "id": config_id or slugify(name)[:30], + "name": name, + "server_url": url, + "APP": { + "client_id": client_id, + "secret": secret, + }, + } + return None + + +_allauth_provider_modules = set(__get_list("PAPERLESS_SSO_MODULES")) +_oidc_server = _get_oidc_server() +if _oidc_server: + _allauth_provider_modules.add("openid_connect") + +SSO_ENABLED = __get_boolean( + "PAPERLESS_SSO_ENABLED", + str(bool(_allauth_provider_modules)), +) + ############################################################################### # Application Definition # ############################################################################### @@ -282,9 +333,18 @@ INSTALLED_APPS = [ "django_filters", "django_celery_results", "guardian", + "allauth", + "allauth.account", + "allauth.socialaccount", *env_apps, ] +if SSO_ENABLED: + INSTALLED_APPS += [ + f"allauth.socialaccount.providers.{provider}" + for provider in _allauth_provider_modules + ] + if DEBUG: INSTALLED_APPS.append("channels") @@ -394,6 +454,10 @@ HTTP_REMOTE_USER_HEADER_NAME = os.getenv( if ENABLE_HTTP_REMOTE_USER: MIDDLEWARE.append("paperless.auth.HttpRemoteUserMiddleware") AUTHENTICATION_BACKENDS.insert(0, "django.contrib.auth.backends.RemoteUserBackend") + if SSO_ENABLED: + AUTHENTICATION_BACKENDS.append( + "allauth.account.auth_backends.AuthenticationBackend", + ) REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append( "rest_framework.authentication.RemoteUserAuthentication", ) @@ -949,3 +1013,53 @@ def _get_nltk_language_setting(ocr_lang: str) -> Optional[str]: NLTK_ENABLED: Final[bool] = __get_boolean("PAPERLESS_ENABLE_NLTK", "yes") NLTK_LANGUAGE: Optional[str] = _get_nltk_language_setting(OCR_LANGUAGE) + + +############################################################################### +# Single Sign-On (SSO) +############################################################################### + + +if SSO_ENABLED: + SSO_AUTO_LINK = __get_boolean("PAPERLESS_SSO_AUTO_LINK", "yes") + SSO_AUTO_LINK_MULTIPLE = __get_boolean( + "PAPERLESS_SSO_AUTO_LINK_MULTIPLE", + "yes", + ) + + # TODO This setting is unused and not part of django-allauth + SSO_SIGNUP_ONLY = __get_boolean( + "PAPERLESS_SSO_SIGNUP_ONLY", + "yes", + ) + ACCOUNT_ADAPTER = "paperless.allauth_custom.CustomAccountAdapter" + ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" + ACCOUNT_EMAIL_VERIFICATION = "none" + LOGIN_ENABLE_SIGNUP = __get_boolean("PAPERLESS_LOGIN_ENABLE_SIGNUP", "no") + LOGIN_HIDE_PASSWORD_FORM = __get_boolean( + "PAPERLESS_LOGIN_HIDE_PASSWORD_FORM", + "no", + ) + ACCOUNT_LOGOUT_ON_GET = True + + # Disable all allauth forms except for the login form + class AllauthFormsOverride(dict): + def get(self, key, default=None): + return super().get(key, "paperless.allauth_custom.raise_404") + + ACCOUNT_FORMS = AllauthFormsOverride( + login="allauth.account.forms.LoginForm", + ) + + SOCIALACCOUNT_ADAPTER = "paperless.allauth_custom.CustomSocialAccountAdapter" + SOCIALACCOUNT_LOGIN_ON_GET = __get_boolean( + "PAPERLESS_SSO_LOGIN_ON_GET", + "no", + ) + SOCIALACCOUNT_PROVIDERS = json.loads( + os.environ.get("PAPERLESS_SSO_PROVIDERS", "{}"), + ) + if _oidc_server: + SOCIALACCOUNT_PROVIDERS.setdefault("openid_connect", {}) + SOCIALACCOUNT_PROVIDERS["openid_connect"].setdefault("SERVERS", []) + SOCIALACCOUNT_PROVIDERS["openid_connect"]["SERVERS"] += [_oidc_server] diff --git a/src/paperless/urls.py b/src/paperless/urls.py index c2b72d7b..3a3bf342 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -154,7 +154,14 @@ urlpatterns = [ ), # TODO: with localization, this is even worse! :/ # login, logout - path("accounts/", include("django.contrib.auth.urls")), + path( + "accounts/", + include( + "paperless.allauth_custom" + if settings.SSO_ENABLED + else "django.contrib.auth.urls", + ), + ), # Root of the Frontent re_path(r".*", login_required(IndexView.as_view()), name="base"), ] -- 2.40.1